Compare commits
No commits in common. "main" and "main" have entirely different histories.
2
.gitignore
vendored
@ -1,5 +1,3 @@
|
|||||||
.claude/
|
.claude/
|
||||||
debug/
|
debug/
|
||||||
cached_logs/
|
|
||||||
.env
|
|
||||||
fflogs-schema.json
|
fflogs-schema.json
|
||||||
|
|||||||
69
CLAUDE.md
@ -69,7 +69,6 @@ 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/
|
||||||
@ -109,7 +108,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 (10%), Feint (10% phys / 5% magic), Addle (10%)
|
- **`debuff`**: Boss-Debuffs — Reprisal, Feint, Addle (je 10%)
|
||||||
|
|
||||||
**Statische Status-IDs (`statusId`-Feld):** Jeder Eintrag in `MITIGATION_ABILITIES` trägt ein `statusId`-Feld (FFLogs Status-ID = XIVAPI Status row_id + 1.000.000). Diese IDs werden als Fallback in `$mitigIdMap` eingetragen wenn masterData den Eintrag nicht enthält. Löst das Pre-Pull-Problem und Name-Mismatches (z.B. FFLogs "Guardian's Will" vs. Key 'Guardian', "Desperate Measures" vs. 'Expedient').
|
**Statische Status-IDs (`statusId`-Feld):** Jeder Eintrag in `MITIGATION_ABILITIES` trägt ein `statusId`-Feld (FFLogs Status-ID = XIVAPI Status row_id + 1.000.000). Diese IDs werden als Fallback in `$mitigIdMap` eingetragen wenn masterData den Eintrag nicht enthält. Löst das Pre-Pull-Problem und Name-Mismatches (z.B. FFLogs "Guardian's Will" vs. Key 'Guardian', "Desperate Measures" vs. 'Expedient').
|
||||||
|
|
||||||
@ -161,14 +160,6 @@ Konsistentes Healer → DPS → Tank-Ordering überall: im Spieler-Grid, in jede
|
|||||||
- Schaden-Delta pro Spieler: grün wenn besser (`aoe-delta-better`), rot wenn schlechter (`aoe-delta-worse`)
|
- 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
|
||||||
@ -252,11 +243,6 @@ Raid-Cooldown-Planer: Welche Mitigation-Ability wird für welche Mechanik einges
|
|||||||
"mitigationNames": { "Reprisal": "Vergeltung" },
|
"mitigationNames": { "Reprisal": "Vergeltung" },
|
||||||
"folderId": null,
|
"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",
|
||||||
@ -275,9 +261,6 @@ Raid-Cooldown-Planer: Welche Mitigation-Ability wird für welche Mechanik einges
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `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`.
|
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.
|
Aktiver Plan per `ff14-planner-active-plan` (ID) — wird beim Tab-Öffnen wiederhergestellt.
|
||||||
@ -306,35 +289,37 @@ Der Haupteinstieg ist der Analyse-Tab — der Nutzer hat die Daten bereits gelad
|
|||||||
| 1 | ✅ | **Datenfundament** — Plan-Datenmodell + localStorage CRUD |
|
| 1 | ✅ | **Datenfundament** — Plan-Datenmodell + localStorage CRUD |
|
||||||
| 2 | ✅ | **Tab-Grundgerüst** — Plan-Liste, Ordner, Mechanik-Timeline |
|
| 2 | ✅ | **Tab-Grundgerüst** — Plan-Liste, Ordner, Mechanik-Timeline |
|
||||||
| 3 | ✅ | **Import aus Analyse-Tab** — Export-Button + Dialog + Merge |
|
| 3 | ✅ | **Import aus Analyse-Tab** — Export-Button + Dialog + Merge |
|
||||||
| 4 | ✅ | **Jobaufstellung** — 8 Slots mit Job-Dropdown + Rollenfärbung + Namen+Job-Import |
|
| 4 | ✅ | **Jobaufstellung** — 8 Slots mit Job-Dropdown + Rollenfärbung |
|
||||||
| 5 | ✅ | **Ability-Zuweisung** — Modal-Picker + Rechtsklick-Remove + Äquivalenz-Hints |
|
| 5 | ✅ | **Ability-Zuweisung** — Modal-Picker + Rechtsklick-Remove + Äquivalenz-Hints |
|
||||||
| 6 | ✅ | **DR-Simulation + Gantt-Chart** — mitigierter Schaden in Mechanik-Cards, Gantt mit Ability-Zeilen |
|
| 6 | 🔜 | **Gantt-Chart** — Recast-Konflikte + DR%-Anzeige (s. unten) |
|
||||||
| 7 | 🔜 | **Analyse-Overlay** — geplante vs. tatsächlich genutzte CDs |
|
| 7 | 🔜 | **Analyse-Overlay** — geplante vs. tatsächlich genutzte CDs |
|
||||||
|
|
||||||
### DR-Simulation (implementiert)
|
### Gantt-Chart — Design-Entscheidungen (Schritt 6)
|
||||||
|
|
||||||
- `ABILITY_DR` in `planner.js`: Map Ability-Name → DR-Wert (0–1). Feint = 0.05 (5% magical, konservativ).
|
**Konzept:** Ergänzende Ansicht zur Mechaniken-Übersicht, nicht Ersatz. Umschaltbar per Toggle/Tab.
|
||||||
- `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)
|
**Layout:**
|
||||||
|
- X-Achse: Kampfzeit (0 bis Kampfende aus `source.fightEnd - source.fightStart`)
|
||||||
|
- Linke Spalte: Ability-Icons (eine Zeile pro eingesetzter Ability im Plan) — Zeilengranularität TBD
|
||||||
|
- Vertikale Linien: Mechaniken-Timestamps
|
||||||
|
- Farbige Balken: Ability aktiv (Dauer) + Cooldown-Bereich danach (gedimmt)
|
||||||
|
- Konflikte: wenn ein Balken eine Mechaniken-Linie überlappt = visuell hervorgehoben
|
||||||
|
|
||||||
**Aktueller Stand:**
|
**Drag & Drop:**
|
||||||
- X-Achse: Kampfzeit aus `source.fightEnd - source.fightStart`
|
- Ability-Icons aus Palette auf Timeline ziehen → Icon am Startpunkt, Balken zeigt Dauer
|
||||||
- **Eine Zeile pro (Job, Ability)** — nicht pro Job. `timelinePlayerRows(plan)` expandiert `JOB_ABILITIES` pro Job.
|
- Freies Ziehen ohne Snapping — Timestamp wird aus X-Position berechnet
|
||||||
- Schilde in Gantt ausgeblendet, außer **Panhaima** (SGE)
|
- Bestehende Balken sind ebenfalls verschiebbar
|
||||||
- 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:**
|
**Update-Button:**
|
||||||
- Konflikte visuell hervorheben wenn Balken eine Mechaniken-Linie überlappt
|
- Gleicht Gantt-Positionen mit Mechaniken ab
|
||||||
|
- Matching-Logik: TBD (Overlap oder nächste Mechanik) — noch zu klären
|
||||||
|
- Aktualisiert Plan-Assignments entsprechend
|
||||||
|
|
||||||
|
**DR-Simulation:**
|
||||||
|
- Pro Mechanik: `simulierter Schaden = unmitigiert × ∏(1 − dr_i)` für alle zugewiesenen Buffs/Debuffs
|
||||||
|
- Wird in der **Mechaniken-Übersicht** unter dem unmitigierten Schadenswert angezeigt
|
||||||
|
- Schilde werden **nicht** simuliert (Schildwerte nicht verlässlich aus Log ableitbar)
|
||||||
|
- Kein Pass/Fail-Urteil — nur der errechnete Zahlenwert, User entscheidet selbst
|
||||||
|
|
||||||
**Recast-Daten:** `data/recast-times.json` — noch zu befüllen. Enthält Cooldown-Dauer (s) und Aktiv-Dauer (s) pro Ability:
|
**Recast-Daten:** `data/recast-times.json` — noch zu befüllen. Enthält Cooldown-Dauer (s) und Aktiv-Dauer (s) pro Ability:
|
||||||
```json
|
```json
|
||||||
@ -360,7 +345,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, Differential Diagnosis, Haima |
|
| SGE | Kerachole, Holos, Holosakos, Panhaima, Eukrasian Prognosis, Eukrasian Diagnosis, Haima, Addle |
|
||||||
| BRD | Troubadour |
|
| BRD | Troubadour |
|
||||||
| MCH | Tactician |
|
| MCH | Tactician |
|
||||||
| DNC | Shield Samba, Improvised Finish |
|
| DNC | Shield Samba, Improvised Finish |
|
||||||
@ -406,7 +391,7 @@ Bekannte Werte (Beispiele):
|
|||||||
- **Persistenz:** `localStorage` — kein Backend nötig
|
- **Persistenz:** `localStorage` — kein Backend nötig
|
||||||
- **IDs:** `crypto.randomUUID()` für Plan-, Mechanik- und Ordner-IDs
|
- **IDs:** `crypto.randomUUID()` für Plan-, Mechanik- und Ordner-IDs
|
||||||
- **Eindeutige Namen:** `uniquePlanName()` verhindert Duplikate beim Erstellen und Importieren
|
- **Eindeutige Namen:** `uniquePlanName()` verhindert Duplikate beim Erstellen und Importieren
|
||||||
- **Spielernamen in `playerRoster`:** Assignments bleiben Job-basiert (`{ ability, job }`). Namen + maxHp werden separat in `playerRoster[8]` gespeichert — für ∅-HP-Anzeige und Analyse-Tab-Vergleich, nicht für Assignments
|
- **Keine Spielernamen:** Assignments sind Job-basiert (`{ ability, job }`), damit Pläne übertragbar sind
|
||||||
- **Kein Ability-Stacking:** FFXIV erlaubt keine doppelte Anwendung derselben Ability pro Mechanik
|
- **Kein Ability-Stacking:** FFXIV erlaubt keine doppelte Anwendung derselben Ability pro Mechanik
|
||||||
- **Shield-Attribution:** Nicht simulierbar — `absorbed` ist Gesamtwert ohne Aufschlüsselung. Bewusst weggelassen.
|
- **Shield-Attribution:** Nicht simulierbar — `absorbed` ist Gesamtwert ohne Aufschlüsselung. Bewusst weggelassen.
|
||||||
- **DR-Simulation:** Nur Buffs/Debuffs mit bekanntem `dr`-Wert aus `MITIGATION_ABILITIES`. Ergebnis als Zahlenwert, kein Pass/Fail.
|
- **DR-Simulation:** Nur Buffs/Debuffs mit bekanntem `dr`-Wert aus `MITIGATION_ABILITIES`. Ergebnis als Zahlenwert, kein Pass/Fail.
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
<?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);
|
||||||
@ -18,16 +19,6 @@ $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 {
|
||||||
@ -132,12 +123,4 @@ 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']));
|
||||||
|
|
||||||
$response = json_encode(['abilities' => $abilities, 'players' => $players]);
|
echo 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;
|
|
||||||
|
|||||||
278
api/analysis.php
@ -1,7 +1,6 @@
|
|||||||
<?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');
|
||||||
@ -12,6 +11,15 @@ 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);
|
||||||
@ -26,22 +34,6 @@ 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 {
|
||||||
@ -84,101 +76,60 @@ 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, 'extraAbilityGameID' => 7385],
|
'Passage of Arms' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001175],
|
||||||
'Dark Missionary' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001894, 'extraAbilityGameID' => 16471],
|
'Dark Missionary' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001894],
|
||||||
'Heart of Light' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001839, 'extraAbilityGameID' => 16160],
|
'Heart of Light' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001839],
|
||||||
'Temperance' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001873, 'extraAbilityGameID' => 16536],
|
'Temperance' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001873],
|
||||||
'Aquaveil' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1002708, 'extraAbilityGameID' => 25861], // Personal, WHM auf Ziel
|
'Sacred Soil' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001944],
|
||||||
'Sacred Soil' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001944, 'extraAbilityGameID' => 188],
|
'Expedient' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002711], // FFLogs: "Desperate Measures"
|
||||||
'Expedient' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002711, 'extraAbilityGameID' => 25868], // FFLogs: "Desperate Measures"
|
'Fey Illumination' => ['dr' => 5, 'buffType' => 'buff', 'statusId' => 1000317],
|
||||||
'Fey Illumination' => ['dr' => 5, 'buffType' => 'buff', 'statusId' => 1000317, 'extraAbilityGameID' => 16538],
|
'Collective Unconscious' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1000849],
|
||||||
'Collective Unconscious' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1000849, 'extraAbilityGameID' => 3613],
|
'Holos' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1003003],
|
||||||
'Exaltation' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002717, 'extraAbilityGameID' => 25873], // Personal, AST auf Ziel
|
'Kerachole' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002618],
|
||||||
'Holos' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1003003, 'extraAbilityGameID' => 24310],
|
'Troubadour' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001934],
|
||||||
'Kerachole' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002618, 'extraAbilityGameID' => 24298],
|
'Tactician' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001951],
|
||||||
'Troubadour' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001934, 'extraAbilityGameID' => 7405],
|
'Shield Samba' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001826],
|
||||||
'Tactician' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001951, 'extraAbilityGameID' => 16889],
|
'Magick Barrier' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002707],
|
||||||
'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, 'extraAbilityGameID' => 3540],
|
'Divine Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001362],
|
||||||
'Guardian' => ['dr' => 40, 'buffType' => 'shield', 'statusId' => 1003830, 'extraAbilityGameID' => 36920], // FFLogs: "Guardian's Will"
|
'Guardian' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003830], // FFLogs: "Guardian's Will"
|
||||||
// WAR
|
// WAR
|
||||||
'Shake It Off' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001457, 'extraAbilityGameID' => 7388],
|
'Shake It Off' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001457],
|
||||||
'Bloodwhetting' => ['dr' => 10, 'buffType' => 'shield', 'statusId' => 1002678, 'extraAbilityGameID' => 25751],
|
'Bloodwhetting' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002678],
|
||||||
'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, 'extraAbilityGameID' => 7432],
|
'Divine Benison' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001218],
|
||||||
'Divine Caress' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003903, 'extraAbilityGameID' => 37011],
|
'Divine Caress' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003903],
|
||||||
// AST
|
// AST
|
||||||
'Intersection' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001889, 'extraAbilityGameID' => 16556],
|
'Intersection' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001889],
|
||||||
'Neutral Sect' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001921, 'extraAbilityGameID' => 16559],
|
'Neutral Sect' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001921],
|
||||||
'the Spire' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003892, 'extraAbilityGameID' => 37025], // FFLogs: "The Spire"
|
'the Spire' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003892], // FFLogs: "The Spire"
|
||||||
// SGE
|
// SGE
|
||||||
'Panhaima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002613, 'extraAbilityGameID' => 24311],
|
'Panhaima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002613],
|
||||||
'Holosakos' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003365, 'extraAbilityGameID' => 24310],
|
'Holosakos' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003365],
|
||||||
'Eukrasian Prognosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002609, 'extraAbilityGameID' => 24292],
|
'Eukrasian Prognosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002609],
|
||||||
'Eukrasian Prognosis II' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 37034],
|
'Eukrasian Prognosis II' => ['dr' => 0, 'buffType' => 'shield'], // TODO
|
||||||
'Eukrasian Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002607, 'extraAbilityGameID' => 24291],
|
'Eukrasian Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002607],
|
||||||
'Differential Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002608, 'extraAbilityGameID' => 24291],
|
'Differential Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002608],
|
||||||
'Haima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002612, 'extraAbilityGameID' => 24305],
|
'Haima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002612],
|
||||||
// SCH
|
// SCH
|
||||||
'Galvanize' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1000297, 'extraAbilityGameID' => 185],
|
'Galvanize' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1000297],
|
||||||
'Seraphic Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001917, 'extraAbilityGameID' => 16548],
|
'Seraphic Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001917],
|
||||||
'Catalyze' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001918, 'extraAbilityGameID' => 185],
|
'Catalyze' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001918],
|
||||||
// SMN
|
// SMN
|
||||||
'Radiant Aegis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002702, 'extraAbilityGameID' => 25799],
|
'Radiant Aegis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002702],
|
||||||
// PCT
|
// PCT
|
||||||
'Tempera Coat' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003686, 'extraAbilityGameID' => 34685],
|
'Tempera Coat' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003686],
|
||||||
'Tempera Grassa' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003687, 'extraAbilityGameID' => 34686],
|
'Tempera Grassa' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003687],
|
||||||
// DNC
|
// DNC
|
||||||
'Improvised Finish' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002697, 'extraAbilityGameID' => 25789],
|
'Improvised Finish' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002697],
|
||||||
// ── Boss debuffs ────────────────────────────────────────────────────────
|
// ── Boss debuffs ────────────────────────────────────────────────────────
|
||||||
'Reprisal' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001193, 'extraAbilityGameID' => 7535],
|
'Reprisal' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001193],
|
||||||
'Feint' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001195, 'extraAbilityGameID' => 7549],
|
'Feint' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001195],
|
||||||
'Addle' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001203, 'extraAbilityGameID' => 7560],
|
'Addle' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001203],
|
||||||
];
|
];
|
||||||
|
|
||||||
function resolveMitigations(string $buffStr, array $mitigIdMap, array $buffSourceTimeline = [], array $players = [], float $ts = 0): array {
|
function resolveMitigations(string $buffStr, array $mitigIdMap): array {
|
||||||
if ($buffStr === '') return [];
|
if ($buffStr === '') return [];
|
||||||
$result = [];
|
$result = [];
|
||||||
$seen = [];
|
$seen = [];
|
||||||
@ -188,33 +139,17 @@ function resolveMitigations(string $buffStr, array $mitigIdMap, array $buffSourc
|
|||||||
$name = $mitigIdMap[$id]['name'];
|
$name = $mitigIdMap[$id]['name'];
|
||||||
if (isset($seen[$name])) continue;
|
if (isset($seen[$name])) continue;
|
||||||
$seen[$name] = true;
|
$seen[$name] = true;
|
||||||
$entry = [
|
$result[] = [
|
||||||
'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.
|
||||||
@ -226,13 +161,7 @@ function shieldsActiveAt(array $shieldTimeline, int $targetId, float $ts, array
|
|||||||
if ($iv['apply'] <= $ts && ($iv['remove'] === null || $iv['remove'] >= $ts - 200)) {
|
if ($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[] = [
|
$result[] = ['key' => $m['key'] ?? $m['name'], 'name' => $m['name'], 'dr' => $m['dr'], 'buffType' => $m['buffType']];
|
||||||
'key' => $m['key'] ?? $m['name'],
|
|
||||||
'name' => $m['name'],
|
|
||||||
'dr' => $m['dr'],
|
|
||||||
'buffType' => $m['buffType'],
|
|
||||||
'extraAbilityGameID' => $m['extraAbilityGameID'] ?? null,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -347,10 +276,8 @@ for ($page = 0; $page < 10; $page++) {
|
|||||||
// ── 2b. Shield buff/debuff timeline ────────────────────────────────────────
|
// ── 2b. Shield buff/debuff timeline ────────────────────────────────────────
|
||||||
// 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;
|
||||||
@ -385,31 +312,12 @@ 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') {
|
||||||
@ -431,60 +339,11 @@ if (!empty($trackedStatusIds)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2c. Boss-Debuff-Source via Casts ───────────────────────────────────────
|
|
||||||
// dataType: Buffs liefert nur Events auf Spieler (Friendly). Reprisal/Feint/Addle
|
|
||||||
// werden auf den Boss (Hostile) angewendet und tauchen dort nicht auf.
|
|
||||||
// Lösung: Cast-Events der drei Abilities direkt abfragen — 1 GQL-Request, 3 Aliase.
|
|
||||||
$dbReprisalActionId = (int)(MITIGATION_ABILITIES['Reprisal']['extraAbilityGameID'] ?? 0);
|
|
||||||
$dbFeintActionId = (int)(MITIGATION_ABILITIES['Feint']['extraAbilityGameID'] ?? 0);
|
|
||||||
$dbAddleActionId = (int)(MITIGATION_ABILITIES['Addle']['extraAbilityGameID'] ?? 0);
|
|
||||||
$dbReprisalStatusId = (int)(MITIGATION_ABILITIES['Reprisal']['statusId'] ?? 0);
|
|
||||||
$dbFeintStatusId = (int)(MITIGATION_ABILITIES['Feint']['statusId'] ?? 0);
|
|
||||||
$dbAddleStatusId = (int)(MITIGATION_ABILITIES['Addle']['statusId'] ?? 0);
|
|
||||||
|
|
||||||
if ($dbReprisalActionId && $dbFeintActionId && $dbAddleActionId) {
|
|
||||||
$dbResult = fflogs_gql(<<<GQL
|
|
||||||
{
|
|
||||||
reportData {
|
|
||||||
report(code: "$reportCode") {
|
|
||||||
reprisal: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbReprisalActionId, startTime: $startTime, endTime: $endTime) { data }
|
|
||||||
feint: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbFeintActionId, startTime: $startTime, endTime: $endTime) { data }
|
|
||||||
addle: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbAddleActionId, startTime: $startTime, endTime: $endTime) { data }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
GQL);
|
|
||||||
if (isset($dbResult['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
|
|
||||||
|
|
||||||
foreach ([
|
|
||||||
'reprisal' => ['statusId' => $dbReprisalStatusId, 'durationMs' => 10000],
|
|
||||||
'feint' => ['statusId' => $dbFeintStatusId, 'durationMs' => 10000],
|
|
||||||
'addle' => ['statusId' => $dbAddleStatusId, 'durationMs' => 10000],
|
|
||||||
] as $alias => $info) {
|
|
||||||
foreach ($dbResult['data']['reportData']['report'][$alias]['data'] ?? [] as $ev) {
|
|
||||||
if (($ev['type'] ?? '') !== 'cast') continue;
|
|
||||||
$srcId = (int)($ev['sourceID'] ?? 0);
|
|
||||||
if ($srcId <= 0 || !isset($players[$srcId]) || !$info['statusId']) continue;
|
|
||||||
$ts = (float)($ev['timestamp'] ?? 0);
|
|
||||||
$buffSourceTimeline[$info['statusId']][] = [
|
|
||||||
'apply' => $ts,
|
|
||||||
'remove' => $ts + $info['durationMs'],
|
|
||||||
'sourceId' => $srcId,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($statusNames as $statusId => $displayName) {
|
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) {
|
||||||
@ -509,9 +368,6 @@ 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,
|
||||||
@ -571,7 +427,6 @@ 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']);
|
||||||
@ -593,6 +448,8 @@ 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;
|
||||||
@ -608,8 +465,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, $buffSourceTimeline, $players) {
|
'mitigations' => (function() use ($tgt, $mitigIdMap, $shieldTimeline) {
|
||||||
$mitigations = resolveMitigations($tgt['buffs'], $mitigIdMap, $buffSourceTimeline, $players, $tgt['ts']);
|
$mitigations = resolveMitigations($tgt['buffs'], $mitigIdMap);
|
||||||
if ($tgt['absorbed'] > 0 && !empty($shieldTimeline)) {
|
if ($tgt['absorbed'] > 0 && !empty($shieldTimeline)) {
|
||||||
$existing = [];
|
$existing = [];
|
||||||
foreach ($mitigations as $m) {
|
foreach ($mitigations as $m) {
|
||||||
@ -629,7 +486,7 @@ foreach ($clusters as $group) {
|
|||||||
return $roleCmp !== 0 ? $roleCmp : strcmp($a['name'], $b['name']);
|
return $roleCmp !== 0 ? $roleCmp : strcmp($a['name'], $b['name']);
|
||||||
});
|
});
|
||||||
|
|
||||||
$bossEvent = [
|
$aoeEvents[] = [
|
||||||
'timestamp' => $group['timestamp'],
|
'timestamp' => $group['timestamp'],
|
||||||
'abilityId' => $group['abilityId'],
|
'abilityId' => $group['abilityId'],
|
||||||
'abilityName' => $group['abilityName'],
|
'abilityName' => $group['abilityName'],
|
||||||
@ -637,27 +494,12 @@ 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']);
|
||||||
|
|
||||||
$response = json_encode([
|
echo 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;
|
|
||||||
|
|||||||
@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
const CACHED_LOG_DIR = __DIR__ . '/../cached_logs';
|
|
||||||
const CACHED_LOG_VERSION = 'v8';
|
|
||||||
|
|
||||||
function cache_language(string $language): string {
|
|
||||||
$language = strtolower(trim($language));
|
|
||||||
return in_array($language, ['en', 'de', 'fr', 'jp'], true) ? $language : 'en';
|
|
||||||
}
|
|
||||||
|
|
||||||
function cache_report_code(string $reportCode): string {
|
|
||||||
return preg_replace('/[^a-zA-Z0-9]/', '', $reportCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cache_key_part(string|int|float|null $value): string {
|
|
||||||
$value = (string)($value ?? '');
|
|
||||||
$value = preg_replace('/[^a-zA-Z0-9._-]/', '_', $value);
|
|
||||||
return trim($value, '_') ?: 'all';
|
|
||||||
}
|
|
||||||
|
|
||||||
function cached_log_path(string $kind, string $reportCode, string $language, array $parts = []): string {
|
|
||||||
$reportCode = cache_report_code($reportCode);
|
|
||||||
$language = cache_language($language);
|
|
||||||
$kind = cache_key_part($kind);
|
|
||||||
$safeParts = array_map('cache_key_part', $parts);
|
|
||||||
$suffix = $safeParts ? '_' . implode('_', $safeParts) : '';
|
|
||||||
|
|
||||||
return CACHED_LOG_DIR . '/' . CACHED_LOG_VERSION . '/' . $reportCode . '/' . $language . '/' . $kind . $suffix . '.json';
|
|
||||||
}
|
|
||||||
|
|
||||||
function read_cached_log(string $kind, string $reportCode, string $language, array $parts = []): ?string {
|
|
||||||
$path = cached_log_path($kind, $reportCode, $language, $parts);
|
|
||||||
if (!is_file($path)) return null;
|
|
||||||
|
|
||||||
$body = file_get_contents($path);
|
|
||||||
if ($body === false || trim($body) === '') return null;
|
|
||||||
return $body;
|
|
||||||
}
|
|
||||||
|
|
||||||
function write_cached_log(string $kind, string $reportCode, string $language, array $parts, string $body): void {
|
|
||||||
$decoded = json_decode($body, true);
|
|
||||||
if (!is_array($decoded) || isset($decoded['error'], $decoded['errors'], $decoded['reauth'])) return;
|
|
||||||
|
|
||||||
$path = cached_log_path($kind, $reportCode, $language, $parts);
|
|
||||||
$dir = dirname($path);
|
|
||||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) return;
|
|
||||||
|
|
||||||
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
|
|
||||||
if (file_put_contents($tmp, $body, LOCK_EX) === false) return;
|
|
||||||
rename($tmp, $path);
|
|
||||||
}
|
|
||||||
@ -87,20 +87,13 @@ if ($playerName !== '') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch events + masterData abilities (incl. type)
|
// Fetch events
|
||||||
$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,
|
||||||
@ -118,14 +111,7 @@ 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;
|
||||||
@ -135,24 +121,21 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'data_type' => $dataType,
|
'data_type' => $dataType,
|
||||||
'event_type' => $eventType ?: null,
|
'event_type' => $eventType ?: null,
|
||||||
'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' => ['from_ms' => (int)$queryStart, 'to_ms' => (int)$queryEnd],
|
'time_range' => [
|
||||||
|
'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,7 +1,6 @@
|
|||||||
<?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');
|
||||||
@ -22,12 +21,6 @@ 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]);
|
||||||
@ -49,7 +42,6 @@ query GetReportData($reportCode: String!) {
|
|||||||
endTime
|
endTime
|
||||||
fights {
|
fights {
|
||||||
id
|
id
|
||||||
encounterID
|
|
||||||
name
|
name
|
||||||
startTime
|
startTime
|
||||||
endTime
|
endTime
|
||||||
@ -83,7 +75,9 @@ function localized_graphql_uri(string $language): string {
|
|||||||
return preg_replace('#https://[^/]+#', 'https://' . $host, GRAPHQL_URI);
|
return preg_replace('#https://[^/]+#', 'https://' . $host, GRAPHQL_URI);
|
||||||
}
|
}
|
||||||
|
|
||||||
$ch = curl_init(localized_graphql_uri($language));
|
// Fight names must be stable regardless of language — always use the English endpoint.
|
||||||
|
// Localization only matters for ability/player names in analysis.php.
|
||||||
|
$ch = curl_init(GRAPHQL_URI);
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
CURLOPT_POSTFIELDS => $payload,
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
@ -91,14 +85,12 @@ 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);
|
||||||
@ -120,8 +112,5 @@ 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
@ -1,112 +0,0 @@
|
|||||||
<?php
|
|
||||||
ini_set('display_errors', '0');
|
|
||||||
require_once __DIR__ . '/../config.php';
|
|
||||||
require_once __DIR__ . '/cache.php';
|
|
||||||
session_start_safe();
|
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed']); exit; }
|
|
||||||
|
|
||||||
$reportCode = preg_replace('/[^a-zA-Z0-9]/', '', $_POST['report_code'] ?? '');
|
|
||||||
$fightId = (int)($_POST['fight_id'] ?? 0);
|
|
||||||
$startTime = (float)($_POST['start_time'] ?? 0);
|
|
||||||
$endTime = (float)($_POST['end_time'] ?? 0);
|
|
||||||
|
|
||||||
if (!$reportCode || !$fightId || !$endTime) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['error' => 'Missing params']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$cacheParts = [$fightId, (int)$startTime, (int)$endTime];
|
|
||||||
$cached = read_cached_log('players', $reportCode, 'en', $cacheParts);
|
|
||||||
if ($cached !== null) {
|
|
||||||
echo $cached;
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($_SESSION['access_token'])) { echo json_encode(['reauth' => true]); exit; }
|
|
||||||
if (($_SESSION['token_expires'] ?? 0) <= time()) { echo json_encode(['reauth' => true]); exit; }
|
|
||||||
|
|
||||||
$token = $_SESSION['access_token'];
|
|
||||||
|
|
||||||
function pl_gql(string $query): array {
|
|
||||||
global $token;
|
|
||||||
$ch = curl_init(GRAPHQL_URI);
|
|
||||||
curl_setopt_array($ch, [
|
|
||||||
CURLOPT_POST => true,
|
|
||||||
CURLOPT_POSTFIELDS => json_encode(['query' => $query]),
|
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
|
||||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token],
|
|
||||||
CURLOPT_SSL_VERIFYPEER => !DEV_MODE,
|
|
||||||
]);
|
|
||||||
$body = curl_exec($ch);
|
|
||||||
$err = curl_error($ch);
|
|
||||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
||||||
curl_close($ch);
|
|
||||||
if ($err) return ['_curl_error' => $err];
|
|
||||||
if ($code === 401) return ['_reauth' => true];
|
|
||||||
return json_decode($body, true) ?? ['_parse_error' => true];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single query: playerDetails + first page of DamageTaken with resources to get maxHp
|
|
||||||
$result = pl_gql(<<<GQL
|
|
||||||
{
|
|
||||||
reportData {
|
|
||||||
report(code: "$reportCode") {
|
|
||||||
playerDetails(fightIDs: [$fightId])
|
|
||||||
events(
|
|
||||||
fightIDs: [$fightId],
|
|
||||||
dataType: DamageTaken,
|
|
||||||
startTime: $startTime,
|
|
||||||
endTime: $endTime,
|
|
||||||
includeResources: true
|
|
||||||
) {
|
|
||||||
data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
GQL);
|
|
||||||
|
|
||||||
if (isset($result['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
|
|
||||||
if (isset($result['_curl_error'])) { http_response_code(502); echo json_encode(['error' => $result['_curl_error']]); exit; }
|
|
||||||
|
|
||||||
// Parse player details: id → {name, type, role, maxHp}
|
|
||||||
$pdRaw = $result['data']['reportData']['report']['playerDetails'] ?? null;
|
|
||||||
$pdParsed = is_string($pdRaw) ? json_decode($pdRaw, true) : $pdRaw;
|
|
||||||
$pdGroups = $pdParsed['data']['playerDetails'] ?? [];
|
|
||||||
|
|
||||||
$players = [];
|
|
||||||
$roleMap = ['tanks' => 'tank', 'healers' => 'healer', 'dps' => 'dps'];
|
|
||||||
foreach ($roleMap as $group => $role) {
|
|
||||||
foreach ($pdGroups[$group] ?? [] as $p) {
|
|
||||||
$players[(int)$p['id']] = [
|
|
||||||
'name' => $p['name'],
|
|
||||||
'type' => $p['type'] ?? '',
|
|
||||||
'role' => $role,
|
|
||||||
'maxHp' => 0,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract maxHp from first damage events (skip DoT ticks — they may lack resources)
|
|
||||||
foreach ($result['data']['reportData']['report']['events']['data'] ?? [] as $ev) {
|
|
||||||
if ($ev['tick'] ?? false) continue;
|
|
||||||
$tid = (int)($ev['targetID'] ?? 0);
|
|
||||||
$maxHp = (int)($ev['targetResources']['maxHitPoints'] ?? 0);
|
|
||||||
if (isset($players[$tid]) && $players[$tid]['maxHp'] === 0 && $maxHp > 0) {
|
|
||||||
$players[$tid]['maxHp'] = $maxHp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = json_encode(['players' => array_values($players)]);
|
|
||||||
if ($response === false) {
|
|
||||||
http_response_code(500);
|
|
||||||
echo json_encode(['error' => 'Could not encode players response']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
write_cached_log('players', $reportCode, 'en', $cacheParts, $response);
|
|
||||||
echo $response;
|
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
@ -1,818 +0,0 @@
|
|||||||
{
|
|
||||||
"17": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Sentinel",
|
|
||||||
"de": "Sentinel",
|
|
||||||
"fr": "Sentinelle",
|
|
||||||
"jp": "センチネル"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/000000/000151_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"22": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 900,
|
|
||||||
"names": {
|
|
||||||
"en": "Bulwark",
|
|
||||||
"de": "Bollwerk",
|
|
||||||
"fr": "Forteresse",
|
|
||||||
"jp": "ブルワーク"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/000000/000167_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"30": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 4200,
|
|
||||||
"names": {
|
|
||||||
"en": "Hallowed Ground",
|
|
||||||
"de": "Heiliger Boden",
|
|
||||||
"fr": "Invincible",
|
|
||||||
"jp": "インビンシブル"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002502_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"40": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 900,
|
|
||||||
"names": {
|
|
||||||
"en": "Thrill of Battle",
|
|
||||||
"de": "Kampfrausch",
|
|
||||||
"fr": "Frisson de la bataille",
|
|
||||||
"jp": "スリル・オブ・バトル"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/000000/000263_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"43": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 2400,
|
|
||||||
"names": {
|
|
||||||
"en": "Holmgang",
|
|
||||||
"de": "Holmgang",
|
|
||||||
"fr": "Holmgang",
|
|
||||||
"jp": "ホルムギャング"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/000000/000266_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"44": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Vengeance",
|
|
||||||
"de": "Rachsucht",
|
|
||||||
"fr": "Représailles",
|
|
||||||
"jp": "ヴェンジェンス"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/000000/000267_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"157": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Manaward",
|
|
||||||
"de": "Mana-Schild",
|
|
||||||
"fr": "Barrière de mana",
|
|
||||||
"jp": "マバリア"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/000000/000463_hr1.png",
|
|
||||||
"shield": "30% max HP"
|
|
||||||
},
|
|
||||||
"185": {
|
|
||||||
"cast": 20,
|
|
||||||
"recast": 25,
|
|
||||||
"names": {
|
|
||||||
"en": "Adloquium",
|
|
||||||
"de": "Adloquium",
|
|
||||||
"fr": "Traité du réconfort",
|
|
||||||
"jp": "鼓舞激励の策"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002801_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"188": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 300,
|
|
||||||
"names": {
|
|
||||||
"en": "Sacred Soil",
|
|
||||||
"de": "Geweihte Erde",
|
|
||||||
"fr": "Dogme de survie",
|
|
||||||
"jp": "野戦治療の陣"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002804_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"2241": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Shade Shift",
|
|
||||||
"de": "Superkniff",
|
|
||||||
"fr": "Décalage d'ombre",
|
|
||||||
"jp": "残影"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/000000/000607_hr1.png",
|
|
||||||
"shield": "20% max HP"
|
|
||||||
},
|
|
||||||
"3540": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 900,
|
|
||||||
"names": {
|
|
||||||
"en": "Divine Veil",
|
|
||||||
"de": "Heiliger Quell",
|
|
||||||
"fr": "Voile divin",
|
|
||||||
"jp": "ディヴァインヴェール"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002508_hr1.png",
|
|
||||||
"shield": "10% max HP"
|
|
||||||
},
|
|
||||||
"3551": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 250,
|
|
||||||
"names": {
|
|
||||||
"en": "Raw Intuition",
|
|
||||||
"de": "Urinstinkt",
|
|
||||||
"fr": "Intuition pure",
|
|
||||||
"jp": "原初の直感"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002559_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"3613": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 600,
|
|
||||||
"names": {
|
|
||||||
"en": "Collective Unconscious",
|
|
||||||
"de": "Numinosum",
|
|
||||||
"fr": "Inconscient collectif",
|
|
||||||
"jp": "運命の輪"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003140_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"3634": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 600,
|
|
||||||
"names": {
|
|
||||||
"en": "Dark Mind",
|
|
||||||
"de": "Dunkler Geist",
|
|
||||||
"fr": "Esprit ténébreux",
|
|
||||||
"jp": "ダークマインド"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003076_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"3636": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Shadow Wall",
|
|
||||||
"de": "Schattenwand",
|
|
||||||
"fr": "Mur d'ombre",
|
|
||||||
"jp": "シャドウウォール"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003075_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"3638": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 3000,
|
|
||||||
"names": {
|
|
||||||
"en": "Living Dead",
|
|
||||||
"de": "Totenerweckung",
|
|
||||||
"fr": "Mort-vivant",
|
|
||||||
"jp": "リビングデッド"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003077_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"7382": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 100,
|
|
||||||
"names": {
|
|
||||||
"en": "Intervention",
|
|
||||||
"de": "Intervention",
|
|
||||||
"fr": "Intervention",
|
|
||||||
"jp": "インターベンション"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002512_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"7385": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Passage of Arms",
|
|
||||||
"de": "Waffengang",
|
|
||||||
"fr": "Passe d'armes",
|
|
||||||
"jp": "パッセージ・オブ・アームズ"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002515_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"7388": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 900,
|
|
||||||
"names": {
|
|
||||||
"en": "Shake It Off",
|
|
||||||
"de": "Abschütteln",
|
|
||||||
"fr": "Débarrassage",
|
|
||||||
"jp": "シェイクオフ"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002563_hr1.png",
|
|
||||||
"shield": "15% max HP"
|
|
||||||
},
|
|
||||||
"7393": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 150,
|
|
||||||
"names": {
|
|
||||||
"en": "The Blackest Night",
|
|
||||||
"de": "Schwärzeste Nacht",
|
|
||||||
"fr": "Nuit noirissime",
|
|
||||||
"jp": "ブラックナイト"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003081_hr1.png",
|
|
||||||
"shield": "25% max HP"
|
|
||||||
},
|
|
||||||
"7394": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Riddle of Earth",
|
|
||||||
"de": "Steinernes Enigma",
|
|
||||||
"fr": "Énigme de la terre",
|
|
||||||
"jp": "金剛の極意"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002537_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"7405": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Troubadour",
|
|
||||||
"de": "Troubadour",
|
|
||||||
"fr": "Troubadour",
|
|
||||||
"jp": "トルバドゥール"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002612_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"7432": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 300,
|
|
||||||
"names": {
|
|
||||||
"en": "Divine Benison",
|
|
||||||
"de": "Göttlicher Segen",
|
|
||||||
"fr": "Faveur divine",
|
|
||||||
"jp": "ディヴァインベニゾン"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002638_hr1.png",
|
|
||||||
"shield": "500 potency"
|
|
||||||
},
|
|
||||||
"7498": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 150,
|
|
||||||
"names": {
|
|
||||||
"en": "Third Eye",
|
|
||||||
"de": "Drittes Auge",
|
|
||||||
"fr": "Troisième œil",
|
|
||||||
"jp": "心眼"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003153_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"7531": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 900,
|
|
||||||
"names": {
|
|
||||||
"en": "Rampart",
|
|
||||||
"de": "Schutzwall",
|
|
||||||
"fr": "Rempart",
|
|
||||||
"jp": "ランパート"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/000000/000801_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"7535": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 600,
|
|
||||||
"names": {
|
|
||||||
"en": "Reprisal",
|
|
||||||
"de": "Reflexion",
|
|
||||||
"fr": "Rétorsion",
|
|
||||||
"jp": "リプライザル"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/000000/000806_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"7549": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 900,
|
|
||||||
"names": {
|
|
||||||
"en": "Feint",
|
|
||||||
"de": "Zermürben",
|
|
||||||
"fr": "Restreinte",
|
|
||||||
"jp": "牽制"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/000000/000828_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"7560": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 900,
|
|
||||||
"names": {
|
|
||||||
"en": "Addle",
|
|
||||||
"de": "Stumpfsinn",
|
|
||||||
"fr": "Embrouillement",
|
|
||||||
"jp": "アドル"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/000000/000861_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"16012": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Shield Samba",
|
|
||||||
"de": "Schildsamba",
|
|
||||||
"fr": "Samba protectrice",
|
|
||||||
"jp": "守りのサンバ"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003469_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"16140": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 900,
|
|
||||||
"names": {
|
|
||||||
"en": "Camouflage",
|
|
||||||
"de": "Camouflage",
|
|
||||||
"fr": "Camouflage",
|
|
||||||
"jp": "カモフラージュ"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003404_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"16148": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Nebula",
|
|
||||||
"de": "Nebula",
|
|
||||||
"fr": "Nébuleuse",
|
|
||||||
"jp": "ネビュラ"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003412_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"16152": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 3600,
|
|
||||||
"names": {
|
|
||||||
"en": "Superbolide",
|
|
||||||
"de": "Meteoritenfall",
|
|
||||||
"fr": "Bolide",
|
|
||||||
"jp": "ボーライド"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003416_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"16160": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 900,
|
|
||||||
"names": {
|
|
||||||
"en": "Heart of Light",
|
|
||||||
"de": "Wackeres Herz",
|
|
||||||
"fr": "Cœur de Lumière",
|
|
||||||
"jp": "ハート・オブ・ライト"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003424_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"16161": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 250,
|
|
||||||
"names": {
|
|
||||||
"en": "Heart of Stone",
|
|
||||||
"de": "Steinernes Herz",
|
|
||||||
"fr": "Cœur de pierre",
|
|
||||||
"jp": "ハート・オブ・ストーン"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003425_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"16471": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 900,
|
|
||||||
"names": {
|
|
||||||
"en": "Dark Missionary",
|
|
||||||
"de": "Dunkler Bote",
|
|
||||||
"fr": "Missionnaire des Ténèbres",
|
|
||||||
"jp": "ダークミッショナリー"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003087_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"16536": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Temperance",
|
|
||||||
"de": "Linderung",
|
|
||||||
"fr": "Tempérance",
|
|
||||||
"jp": "テンパランス"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002645_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"16538": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Fey Illumination",
|
|
||||||
"de": "Illumination",
|
|
||||||
"fr": "Illumination féérique",
|
|
||||||
"jp": "フェイイルミネーション"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002853_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"16548": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 30,
|
|
||||||
"names": {
|
|
||||||
"en": "Seraphic Veil",
|
|
||||||
"de": "Schleier der Seraphim",
|
|
||||||
"fr": "Voile séraphique",
|
|
||||||
"jp": "セラフィックヴェール"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002847_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"16556": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 300,
|
|
||||||
"names": {
|
|
||||||
"en": "Celestial Intersection",
|
|
||||||
"de": "Kongruenz",
|
|
||||||
"fr": "Rencontre céleste",
|
|
||||||
"jp": "星天交差"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003556_hr1.png",
|
|
||||||
"shield": "200% of HP restored"
|
|
||||||
},
|
|
||||||
"16559": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Neutral Sect",
|
|
||||||
"de": "Neutral",
|
|
||||||
"fr": "Adepte de la neutralité",
|
|
||||||
"jp": "ニュートラルセクト"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003552_hr1.png",
|
|
||||||
"shield": "250% of HP restored"
|
|
||||||
},
|
|
||||||
"16889": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Tactician",
|
|
||||||
"de": "Taktiker",
|
|
||||||
"fr": "Tacticien",
|
|
||||||
"jp": "タクティシャン"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003040_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"24291": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 15,
|
|
||||||
"names": {
|
|
||||||
"en": "Eukrasian Diagnosis",
|
|
||||||
"de": "Eukratische Diagnose",
|
|
||||||
"fr": "Diagnosis eucrasique",
|
|
||||||
"jp": "エウクラシア・ディアグノシス"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003659_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"24292": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 15,
|
|
||||||
"names": {
|
|
||||||
"en": "Eukrasian Prognosis",
|
|
||||||
"de": "Eukratische Prognose",
|
|
||||||
"fr": "Prognosis eucrasique",
|
|
||||||
"jp": "エウクラシア・プログノシス"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003660_hr1.png",
|
|
||||||
"shield": "320% of HP restored"
|
|
||||||
},
|
|
||||||
"24298": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 300,
|
|
||||||
"names": {
|
|
||||||
"en": "Kerachole",
|
|
||||||
"de": "Kerachole",
|
|
||||||
"fr": "Kerachole",
|
|
||||||
"jp": "ケーラコレ"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003666_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"24305": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Haima",
|
|
||||||
"de": "Haima",
|
|
||||||
"fr": "Haima",
|
|
||||||
"jp": "ハイマ"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003673_hr1.png",
|
|
||||||
"shield": "300 potency"
|
|
||||||
},
|
|
||||||
"24310": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Holos",
|
|
||||||
"de": "Holos",
|
|
||||||
"fr": "Holos",
|
|
||||||
"jp": "ホーリズム"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003678_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"24311": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Panhaima",
|
|
||||||
"de": "Panhaima",
|
|
||||||
"fr": "Panhaima",
|
|
||||||
"jp": "パンハイマ"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003679_hr1.png",
|
|
||||||
"shield": "200 potency"
|
|
||||||
},
|
|
||||||
"24404": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 300,
|
|
||||||
"names": {
|
|
||||||
"en": "Arcane Crest",
|
|
||||||
"de": "Arkanes Wappen",
|
|
||||||
"fr": "Blason arcanique",
|
|
||||||
"jp": "アルケインクレスト"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003632_hr1.png",
|
|
||||||
"shield": "10% max HP"
|
|
||||||
},
|
|
||||||
"25746": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 50,
|
|
||||||
"names": {
|
|
||||||
"en": "Holy Sheltron",
|
|
||||||
"de": "Heiliges Schiltron",
|
|
||||||
"fr": "Schiltron sacré",
|
|
||||||
"jp": "ホーリーシェルトロン"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002950_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"25751": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 250,
|
|
||||||
"names": {
|
|
||||||
"en": "Bloodwhetting",
|
|
||||||
"de": "Urimpuls",
|
|
||||||
"fr": "Intuition fougueuse",
|
|
||||||
"jp": "原初の血気"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002569_hr1.png",
|
|
||||||
"shield": "400 potency"
|
|
||||||
},
|
|
||||||
"25754": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 600,
|
|
||||||
"names": {
|
|
||||||
"en": "Oblation",
|
|
||||||
"de": "Opfergabe",
|
|
||||||
"fr": "Oblation",
|
|
||||||
"jp": "オブレーション"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003089_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"25758": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 250,
|
|
||||||
"names": {
|
|
||||||
"en": "Heart of Corundum",
|
|
||||||
"de": "Herz des Korunds",
|
|
||||||
"fr": "Cœur de corindon",
|
|
||||||
"jp": "ハート・オブ・コランダム"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003430_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"25789": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 15,
|
|
||||||
"names": {
|
|
||||||
"en": "Improvised Finish",
|
|
||||||
"de": "Improvisiertes Finale",
|
|
||||||
"fr": "Final improvisé",
|
|
||||||
"jp": "インプロビゼーション・フィニッシュ"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003479_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"25799": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 600,
|
|
||||||
"names": {
|
|
||||||
"en": "Radiant Aegis",
|
|
||||||
"de": "Schimmerschild",
|
|
||||||
"fr": "Égide rayonnante",
|
|
||||||
"jp": "守りの光"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002750_hr1.png",
|
|
||||||
"shield": "20% max HP"
|
|
||||||
},
|
|
||||||
"25857": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Magick Barrier",
|
|
||||||
"de": "Magiebarriere",
|
|
||||||
"fr": "Barrière anti-magie",
|
|
||||||
"jp": "バマジク"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003237_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"25868": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Expedient",
|
|
||||||
"de": "Sturm und Drang",
|
|
||||||
"fr": "Thèse fluidique",
|
|
||||||
"jp": "疾風怒濤の計"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002878_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"34685": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Tempera Coat",
|
|
||||||
"de": "Tempera-Schicht",
|
|
||||||
"fr": "Enduit a tempera",
|
|
||||||
"jp": "テンペラコート"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003835_hr1.png",
|
|
||||||
"shield": "20% max HP"
|
|
||||||
},
|
|
||||||
"34686": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 10,
|
|
||||||
"names": {
|
|
||||||
"en": "Tempera Grassa",
|
|
||||||
"de": "Fette Tempera",
|
|
||||||
"fr": "Tempera grassa",
|
|
||||||
"jp": "テンペラグラッサ"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003836_hr1.png",
|
|
||||||
"shield": "10% max HP"
|
|
||||||
},
|
|
||||||
"36920": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Guardian",
|
|
||||||
"de": "Heilige Wacht",
|
|
||||||
"fr": "Garde extrême",
|
|
||||||
"jp": "エクストリームガード"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002524_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"36923": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Damnation",
|
|
||||||
"de": "Verdammnis",
|
|
||||||
"fr": "Damnation",
|
|
||||||
"jp": "ダムネーション"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002573_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"36927": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Shadowed Vigil",
|
|
||||||
"de": "Schattenwacht",
|
|
||||||
"fr": "Vigile ténébreux",
|
|
||||||
"jp": "シャドウヴィジル"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003094_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"36935": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 1200,
|
|
||||||
"names": {
|
|
||||||
"en": "Great Nebula",
|
|
||||||
"de": "Große Nebula",
|
|
||||||
"fr": "Grande nébuleuse",
|
|
||||||
"jp": "グレートネビュラ"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003435_hr1.png",
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"37011": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 10,
|
|
||||||
"names": {
|
|
||||||
"en": "Divine Caress",
|
|
||||||
"de": "Göttliche Umarmung",
|
|
||||||
"fr": "Caresse divine",
|
|
||||||
"jp": "ディヴァインカレス"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/002000/002128_hr1.png",
|
|
||||||
"shield": "400 potency"
|
|
||||||
},
|
|
||||||
"37025": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 10,
|
|
||||||
"names": {
|
|
||||||
"en": "the Spire",
|
|
||||||
"de": "Turm",
|
|
||||||
"fr": "La Tour",
|
|
||||||
"jp": "ビエルゴの塔"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003115_hr1.png",
|
|
||||||
"shield": "400 potency"
|
|
||||||
},
|
|
||||||
"37034": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 15,
|
|
||||||
"names": {
|
|
||||||
"en": "Eukrasian Prognosis II",
|
|
||||||
"de": "Eukratische Prognose II",
|
|
||||||
"fr": "Prognosis eucrasique II",
|
|
||||||
"jp": "エウクラシア・プログノシスII"
|
|
||||||
},
|
|
||||||
"icon": "https://xivapi.com/i/003000/003689_hr1.png",
|
|
||||||
"shield": "360% of HP restored"
|
|
||||||
},
|
|
||||||
"16464": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 250,
|
|
||||||
"names": {
|
|
||||||
"en": "Nascent Flash",
|
|
||||||
"de": "Urflackern",
|
|
||||||
"fr": "Exaltation naissante",
|
|
||||||
"jp": "原初の猛り"
|
|
||||||
},
|
|
||||||
"icon": null,
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"25861": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 600,
|
|
||||||
"names": {
|
|
||||||
"en": "Aquaveil",
|
|
||||||
"de": "Wasserschleier",
|
|
||||||
"fr": "Aquavoile",
|
|
||||||
"jp": "アクアヴェール"
|
|
||||||
},
|
|
||||||
"icon": null,
|
|
||||||
"shield": null
|
|
||||||
},
|
|
||||||
"25873": {
|
|
||||||
"cast": 0,
|
|
||||||
"recast": 600,
|
|
||||||
"names": {
|
|
||||||
"en": "Exaltation",
|
|
||||||
"de": "Exaltation",
|
|
||||||
"fr": "Exaltation",
|
|
||||||
"jp": "エクザルテーション"
|
|
||||||
},
|
|
||||||
"icon": null,
|
|
||||||
"shield": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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'] = safe_return_path($_GET['return'] ?? null);
|
$_SESSION['oauth_return'] = null;
|
||||||
|
|
||||||
$params = http_build_query([
|
$params = http_build_query([
|
||||||
'response_type' => 'code',
|
'response_type' => 'code',
|
||||||
|
|||||||
87
config.php
@ -1,51 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
function load_env_file(string $path): void {
|
define('DEV_MODE', true); // set to false in production
|
||||||
if (!is_file($path)) return;
|
define('CLIENT_ID', 'a1d27cba-b7f8-48dd-aefd-4697b457cc67');
|
||||||
|
define('REDIRECT_URI', 'http://localhost:8080/auth/callback.php');
|
||||||
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
define('AUTHORIZE_URI','https://www.fflogs.com/oauth/authorize');
|
||||||
$line = trim($line);
|
define('TOKEN_URI', 'https://www.fflogs.com/oauth/token');
|
||||||
if ($line === '' || str_starts_with($line, '#')) continue;
|
define('GRAPHQL_URI', 'https://www.fflogs.com/api/v2/user');
|
||||||
|
|
||||||
[$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) {
|
||||||
@ -59,38 +19,3 @@ 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,30 +80,6 @@ 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;
|
||||||
|
|||||||
774
css/planner.css
@ -4,12 +4,6 @@
|
|||||||
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 ────────────────────────────────────────────────────────────── */
|
||||||
@ -165,63 +159,6 @@
|
|||||||
.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;
|
||||||
@ -285,16 +222,6 @@
|
|||||||
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;
|
||||||
@ -320,10 +247,9 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--t2);
|
color: var(--t2);
|
||||||
}
|
}
|
||||||
.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;
|
||||||
@ -486,10 +412,9 @@
|
|||||||
}
|
}
|
||||||
.ability-chip:hover { background: var(--bg3); color: var(--t1); }
|
.ability-chip:hover { background: var(--bg3); color: var(--t1); }
|
||||||
|
|
||||||
.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; }
|
||||||
|
|
||||||
@ -528,9 +453,9 @@
|
|||||||
|
|
||||||
/* ── Info Panel ──────────────────────────────────────────────────────────────── */
|
/* ── Info Panel ──────────────────────────────────────────────────────────────── */
|
||||||
.planner-info-panel {
|
.planner-info-panel {
|
||||||
padding-bottom: 16px;
|
margin-top: 14px;
|
||||||
margin-bottom: 16px;
|
padding-top: 14px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-section { margin-bottom: 12px; }
|
.info-section { margin-bottom: 12px; }
|
||||||
@ -562,10 +487,9 @@
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.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;
|
||||||
@ -581,38 +505,6 @@
|
|||||||
.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; }
|
||||||
|
|
||||||
@ -705,645 +597,3 @@
|
|||||||
}
|
}
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
401
js/analysis.js
@ -1,18 +1,58 @@
|
|||||||
(function () {
|
(function () {
|
||||||
const { MITIG_ICONS, JOB_ABBR, ABILITY_JOBS, JOB_ROLE } = window.FF14_DATA;
|
const MITIG_ICONS = {
|
||||||
|
// DR buffs
|
||||||
|
'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png',
|
||||||
|
'Dark Missionary': 'assets/icons/mitigation/dark-missionary.png',
|
||||||
|
'Heart of Light': 'assets/icons/mitigation/heart-of-light.png',
|
||||||
|
'Temperance': 'assets/icons/mitigation/temperance.png',
|
||||||
|
'Sacred Soil': 'assets/icons/mitigation/sacred-soil.png',
|
||||||
|
'Expedient': 'assets/icons/mitigation/expedient.png',
|
||||||
|
'Fey Illumination': 'assets/icons/mitigation/fey-illumination.png',
|
||||||
|
'Collective Unconscious': 'assets/icons/mitigation/collective-unconscious.png',
|
||||||
|
'Holos': 'assets/icons/mitigation/holos.png',
|
||||||
|
'Kerachole': 'assets/icons/mitigation/kerachole.png',
|
||||||
|
'Troubadour': 'assets/icons/mitigation/troubadour.png',
|
||||||
|
'Tactician': 'assets/icons/mitigation/tactician.png',
|
||||||
|
'Shield Samba': 'assets/icons/mitigation/shield-samba.png',
|
||||||
|
'Magick Barrier': 'assets/icons/mitigation/magick-barrier.png',
|
||||||
|
// Debuffs
|
||||||
|
'Reprisal': 'assets/icons/mitigation/reprisal.png',
|
||||||
|
'Feint': 'assets/icons/mitigation/feint.png',
|
||||||
|
'Addle': 'assets/icons/mitigation/addle.png',
|
||||||
|
// Shields
|
||||||
|
'Divine Veil': 'assets/icons/mitigation/divine-veil.png',
|
||||||
|
'Guardian': 'assets/icons/mitigation/guardian.png',
|
||||||
|
'Shake It Off': 'assets/icons/mitigation/shake-it-off.png',
|
||||||
|
'Bloodwhetting': 'assets/icons/mitigation/bloodwhetting.png',
|
||||||
|
'Divine Benison': 'assets/icons/mitigation/divine-benison.png',
|
||||||
|
'Divine Caress': 'assets/icons/mitigation/divine-caress.png',
|
||||||
|
'Intersection': 'assets/icons/mitigation/intersection.png',
|
||||||
|
'Neutral Sect': 'assets/icons/mitigation/neutral-sect.png',
|
||||||
|
'the Spire': 'assets/icons/mitigation/the-spire.png',
|
||||||
|
'Panhaima': 'assets/icons/mitigation/panhaima.png',
|
||||||
|
'Holosakos': 'assets/icons/mitigation/holos.png',
|
||||||
|
'Eukrasian Prognosis': 'assets/icons/mitigation/eukrasian-prognosis.png',
|
||||||
|
'Eukrasian Prognosis II': 'assets/icons/mitigation/eukrasian-prognosis-ii.png',
|
||||||
|
'Eukrasian Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
||||||
|
'Differential Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
||||||
|
'Haima': 'assets/icons/mitigation/haima.png',
|
||||||
|
'Galvanize': 'assets/icons/mitigation/galvanize.png',
|
||||||
|
'Seraphic Veil': 'assets/icons/mitigation/seraphic-veil.png',
|
||||||
|
'Radiant Aegis': 'assets/icons/mitigation/radiant-aegis.png',
|
||||||
|
'Tempera Coat': 'assets/icons/mitigation/tempera-coat.png',
|
||||||
|
'Tempera Grassa': 'assets/icons/mitigation/tempera-grassa.png',
|
||||||
|
'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png',
|
||||||
|
};
|
||||||
|
|
||||||
// Deduplicated list of all mitigations across all targets of a ref event
|
const JOB_ABBR = {
|
||||||
function collectRefMitigs(refEvent) {
|
'Paladin': 'PLD', 'Warrior': 'WAR', 'DarkKnight': 'DRK', 'Gunbreaker': 'GNB',
|
||||||
if (!refEvent) return [];
|
'WhiteMage': 'WHM', 'Scholar': 'SCH', 'Astrologian': 'AST', 'Sage': 'SGE',
|
||||||
const seen = new Set(), result = [];
|
'Monk': 'MNK', 'Dragoon': 'DRG', 'Ninja': 'NIN', 'Samurai': 'SAM',
|
||||||
for (const t of refEvent.targets ?? []) {
|
'Reaper': 'RPR', 'Viper': 'VPR',
|
||||||
for (const m of (t.mitigations ?? [])) {
|
'Bard': 'BRD', 'Machinist': 'MCH', 'Dancer': 'DNC',
|
||||||
const k = m.key ?? m.name;
|
'BlackMage': 'BLM', 'Summoner': 'SMN', 'RedMage': 'RDM',
|
||||||
if (!seen.has(k)) { seen.add(k); result.push(m); }
|
'Pictomancer': 'PCT', 'BlueMage': 'BLU',
|
||||||
}
|
};
|
||||||
}
|
|
||||||
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();
|
||||||
@ -41,24 +81,13 @@
|
|||||||
return String(name ?? '').trim().toLowerCase();
|
return String(name ?? '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function fightEncounterId(fight) {
|
function currentFightName() {
|
||||||
return parseInt(fight?.encounterID ?? fight?.encounterId ?? 0, 10) || 0;
|
const fight = (window.App?.fights ?? []).find(f => f.id === window.App?.fightId);
|
||||||
|
return normalizeFightName(fight?.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentFight() {
|
function isSameFightName(fight) {
|
||||||
return (window.App?.fights ?? []).find(f => f.id === window.App?.fightId) ?? null;
|
const name = currentFightName();
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,28 +104,6 @@
|
|||||||
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 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -237,11 +244,8 @@
|
|||||||
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 and plan selections
|
// Clear ext-report selection
|
||||||
refExtFightSelect.value = '';
|
refExtFightSelect.value = '';
|
||||||
planRefId = '';
|
|
||||||
refPlanSelect.value = '';
|
|
||||||
refPlanPanel.style.display = 'none';
|
|
||||||
|
|
||||||
refFightSelect.disabled = true;
|
refFightSelect.disabled = true;
|
||||||
try {
|
try {
|
||||||
@ -260,7 +264,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 = json.players ?? [];
|
refPlayers = [];
|
||||||
window.App.setUrlState?.({
|
window.App.setUrlState?.({
|
||||||
compareReportCode: '',
|
compareReportCode: '',
|
||||||
compareFightId: refId,
|
compareFightId: refId,
|
||||||
@ -276,7 +280,7 @@
|
|||||||
let allSameReportFights = [];
|
let allSameReportFights = [];
|
||||||
|
|
||||||
function populateRefFightSelect() {
|
function populateRefFightSelect() {
|
||||||
const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameEncounter(f));
|
const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameFightName(f));
|
||||||
refFightSelect.innerHTML = '<option value="">Kein Vergleich</option>';
|
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) + '%' : '?');
|
||||||
@ -345,7 +349,7 @@
|
|||||||
extReportCode = code;
|
extReportCode = code;
|
||||||
updateRefFflogsLink();
|
updateRefFflogsLink();
|
||||||
|
|
||||||
const visibleExt = fights.filter(isSameEncounter);
|
const visibleExt = fights.filter(isSameFightName);
|
||||||
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) + '%' : '?');
|
||||||
@ -385,11 +389,8 @@
|
|||||||
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 and plan selections
|
// Clear same-report selection
|
||||||
refFightSelect.value = '';
|
refFightSelect.value = '';
|
||||||
planRefId = '';
|
|
||||||
refPlanSelect.value = '';
|
|
||||||
refPlanPanel.style.display = 'none';
|
|
||||||
|
|
||||||
refExtFightSelect.disabled = true;
|
refExtFightSelect.disabled = true;
|
||||||
try {
|
try {
|
||||||
@ -409,7 +410,6 @@
|
|||||||
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,116 +428,6 @@
|
|||||||
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) {
|
||||||
@ -551,8 +441,6 @@
|
|||||||
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) {
|
||||||
@ -567,11 +455,6 @@
|
|||||||
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) &&
|
||||||
@ -593,24 +476,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const eventMissingDebuffs = refEv
|
const eventMissingDebuffs = refEv
|
||||||
? allRefMitigs.filter(m => {
|
? (refEv.targets[0]?.mitigations ?? []).filter(m => m.buffType === 'debuff' && !seenDebuffKeys.has(m.key ?? m.name))
|
||||||
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 = mitigationIcon(m);
|
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
|
||||||
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="${label}${dr} fehlt (war im Referenz-Pull aktiv)">`
|
? `<img class="aoe-target-buff-icon aoe-buff-missing" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr} fehlt (war im Referenz-Pull aktiv)">`
|
||||||
: `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${label}${dr}">`;
|
: `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Current targets
|
// Current targets
|
||||||
@ -630,17 +507,29 @@
|
|||||||
</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 = mitigationIcon(m);
|
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
|
||||||
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('');
|
||||||
|
|
||||||
// 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 shieldLines = activeShields.map(s => s.name);
|
const missingShields = refTarget
|
||||||
|
? (refTarget.mitigations ?? []).filter(m => m.buffType === 'shield' && !currentMitigKeys.has(m.key ?? m.name))
|
||||||
|
: [];
|
||||||
|
const shieldLines = [
|
||||||
|
...activeShields.map(s => s.name),
|
||||||
|
...missingShields.map(s => `[fehlt: ${s.name}]`),
|
||||||
|
];
|
||||||
const shieldTitle = shieldLines.length ? shieldLines.join('\n') : null;
|
const shieldTitle = shieldLines.length ? shieldLines.join('\n') : null;
|
||||||
|
|
||||||
const dead = t.hp === 0 && t.maxHp > 0;
|
const dead = t.hp === 0 && t.maxHp > 0;
|
||||||
@ -679,31 +568,28 @@
|
|||||||
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 = mitigationIcon(m);
|
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
|
||||||
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 = (!isPlanRef && curr) ? curr.amount - t.amount : 0;
|
const diff = curr ? curr.amount - t.amount : 0;
|
||||||
const dead = !isPlanRef && t.hp === 0 && t.maxHp > 0;
|
const dead = 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 = mitigationIcon(m);
|
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
|
||||||
if (!iconSrc) return '';
|
if (!iconSrc) return '';
|
||||||
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
|
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
|
||||||
const k = m.key ?? m.name;
|
const missing = !currMitigKeys.has(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}">`;
|
||||||
@ -711,19 +597,9 @@
|
|||||||
|
|
||||||
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 => {
|
? refShields.map(s => currMitigKeys.has(s.key ?? s.name) ? s.name : `${s.name} [fehlt im aktuellen Pull]`).join('\n')
|
||||||
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' : ''}">
|
||||||
@ -732,21 +608,20 @@
|
|||||||
${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">${isPlanRef ? '' : fmtDmg(t.amount)}${absorbedHtml}</span>
|
<span class="aoe-target-dmg">${fmtDmg(t.amount)}${t.absorbed > 0 ? ` <span class="aoe-target-absorbed" title="${refShieldTitle ?? 'Keine erkannten Schilde'}">+${fmtDmg(t.absorbed)}</span>` : ''}</span>
|
||||||
</div>
|
</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 = (!isPlanRef && totalDiff !== 0)
|
const totalDelta = 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">${refLabel} ${refDebuffIconsHtml}</span>
|
<span class="aoe-ref-label">REF ${fmtDmg(refEv.totalDamage)} ${totalDelta} ${refDebuffIconsHtml}</span>
|
||||||
<div class="aoe-targets">${refCards}</div>
|
<div class="aoe-targets">${refCards}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@ -810,7 +685,6 @@
|
|||||||
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';
|
||||||
@ -850,42 +724,6 @@
|
|||||||
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 = [];
|
||||||
@ -894,7 +732,6 @@
|
|||||||
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';
|
||||||
@ -903,52 +740,12 @@
|
|||||||
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', (e) => {
|
document.getElementById('export-to-planner-btn')?.addEventListener('click', () => {
|
||||||
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
@ -1,327 +0,0 @@
|
|||||||
(function () {
|
|
||||||
// FFLogs ability type bitmask values for FFXIV
|
|
||||||
// Verified via XIVAPI AttackType cross-reference:
|
|
||||||
// 128 = Physical (all subtypes: Slashing/Piercing/Blunt/Shot)
|
|
||||||
// 1024 = Magical
|
|
||||||
// Used for Feint/Addle DR calculation (Feint: 10% phys / 5% mag, Addle: 10% mag / 5% phys)
|
|
||||||
const ABILITY_TYPE_PHYSICAL = 128;
|
|
||||||
const ABILITY_TYPE_MAGICAL = 1024;
|
|
||||||
|
|
||||||
function abilityTypeIsPhysical(type) { return (parseInt(type) & ABILITY_TYPE_PHYSICAL) !== 0; }
|
|
||||||
function abilityTypeIsMagical(type) { return (parseInt(type) & ABILITY_TYPE_MAGICAL) !== 0; }
|
|
||||||
|
|
||||||
const JOB_FROM_TYPE = {
|
|
||||||
'Paladin': 'PLD', 'Warrior': 'WAR', 'DarkKnight': 'DRK', 'Gunbreaker': 'GNB',
|
|
||||||
'WhiteMage': 'WHM', 'Scholar': 'SCH', 'Astrologian': 'AST', 'Sage': 'SGE',
|
|
||||||
'Monk': 'MNK', 'Dragoon': 'DRG', 'Ninja': 'NIN', 'Samurai': 'SAM',
|
|
||||||
'Reaper': 'RPR', 'Viper': 'VPR', 'Bard': 'BRD', 'Machinist': 'MCH',
|
|
||||||
'Dancer': 'DNC', 'BlackMage': 'BLM', 'Summoner': 'SMN', 'RedMage': 'RDM',
|
|
||||||
'Pictomancer': 'PCT', 'BlueMage': 'BLU',
|
|
||||||
};
|
|
||||||
|
|
||||||
const JOB_ROLE = {
|
|
||||||
'PLD': 'tank', 'WAR': 'tank', 'DRK': 'tank', 'GNB': 'tank',
|
|
||||||
'WHM': 'healer', 'SCH': 'healer', 'AST': 'healer', 'SGE': 'healer',
|
|
||||||
'MNK': 'dps', 'DRG': 'dps', 'NIN': 'dps', 'SAM': 'dps',
|
|
||||||
'RPR': 'dps', 'VPR': 'dps', 'BRD': 'dps', 'MCH': 'dps',
|
|
||||||
'DNC': 'dps', 'BLM': 'dps', 'SMN': 'dps', 'RDM': 'dps', 'PCT': 'dps',
|
|
||||||
'BLU': 'dps',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ALL_JOBS = [
|
|
||||||
{ group: 'Tank', jobs: ['PLD', 'WAR', 'DRK', 'GNB'] },
|
|
||||||
{ group: 'Healer', jobs: ['WHM', 'SCH', 'AST', 'SGE'] },
|
|
||||||
{ group: 'Melee', jobs: ['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR'] },
|
|
||||||
{ group: 'Ranged', jobs: ['BRD', 'MCH', 'DNC'] },
|
|
||||||
{ group: 'Caster', jobs: ['BLM', 'SMN', 'RDM', 'PCT'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
const JOB_ABILITIES = {
|
|
||||||
'PLD': [
|
|
||||||
{ name: 'Passage of Arms', buffType: 'buff' },
|
|
||||||
{ name: 'Divine Veil', buffType: 'shield' },
|
|
||||||
{ name: 'Rampart', buffType: 'buff', extraAbilityGameID: 7531, duration: 20 },
|
|
||||||
{ name: 'Hallowed Ground', buffType: 'buff', extraAbilityGameID: 30, duration: 10 },
|
|
||||||
{ name: 'Sentinel', buffType: 'buff', extraAbilityGameID: 17, duration: 15 },
|
|
||||||
{ name: 'Guardian', buffType: 'shield', extraAbilityGameID: 36920, duration: 15 },
|
|
||||||
{ name: 'Bulwark', buffType: 'buff', extraAbilityGameID: 22, duration: 10 },
|
|
||||||
{ name: 'Holy Sheltron', buffType: 'buff', extraAbilityGameID: 25746, duration: 8 },
|
|
||||||
{ name: 'Intervention', buffType: 'buff', extraAbilityGameID: 7382, duration: 8 },
|
|
||||||
{ name: 'Knight\'s Resolve', buffType: 'buff', extraAbilityGameID: 7382, duration: 4 }, // Proc von Intervention
|
|
||||||
{ name: 'Reprisal', buffType: 'debuff' },
|
|
||||||
],
|
|
||||||
'WAR': [
|
|
||||||
{ name: 'Shake It Off', buffType: 'shield' },
|
|
||||||
{ name: 'Rampart', buffType: 'buff', extraAbilityGameID: 7531, duration: 20 },
|
|
||||||
{ name: 'Holmgang', buffType: 'buff', extraAbilityGameID: 43, duration: 10 },
|
|
||||||
{ name: 'Vengeance', buffType: 'buff', extraAbilityGameID: 44, duration: 15 },
|
|
||||||
{ name: 'Damnation', buffType: 'buff', extraAbilityGameID: 36923, duration: 15 },
|
|
||||||
{ name: 'Thrill of Battle', buffType: 'buff', extraAbilityGameID: 40, duration: 10 },
|
|
||||||
{ name: 'Raw Intuition', buffType: 'buff', extraAbilityGameID: 3551, duration: 6 },
|
|
||||||
{ name: 'Bloodwhetting', buffType: 'shield', extraAbilityGameID: 25751, duration: 8 },
|
|
||||||
{ name: 'Nascent Glint', buffType: 'buff', extraAbilityGameID: 16464, duration: 8 }, // Proc von Nascent Flash auf Ziel
|
|
||||||
{ name: 'Stem the Flow', buffType: 'buff', extraAbilityGameID: 25751, duration: 4 }, // Proc von Bloodwhetting / Nascent Flash
|
|
||||||
{ name: 'Stem the Tide', buffType: 'shield', extraAbilityGameID: 25751, duration: 4 }, // Proc von Bloodwhetting / Nascent Flash
|
|
||||||
{ name: 'Reprisal', buffType: 'debuff' },
|
|
||||||
],
|
|
||||||
'DRK': [
|
|
||||||
{ name: 'Dark Missionary', buffType: 'buff' },
|
|
||||||
{ name: 'Rampart', buffType: 'buff', extraAbilityGameID: 7531, duration: 20 },
|
|
||||||
{ name: 'Living Dead', buffType: 'buff', extraAbilityGameID: 3638, duration: 10 },
|
|
||||||
{ name: 'Shadow Wall', buffType: 'buff', extraAbilityGameID: 3636, duration: 15 },
|
|
||||||
{ name: 'Shadowed Vigil', buffType: 'buff', extraAbilityGameID: 36927, duration: 15 },
|
|
||||||
{ name: 'Dark Mind', buffType: 'buff', extraAbilityGameID: 3634, duration: 10 },
|
|
||||||
{ name: 'The Blackest Night', buffType: 'shield', extraAbilityGameID: 7393, duration: 7 },
|
|
||||||
{ name: 'Oblation', buffType: 'buff', extraAbilityGameID: 25754, duration: 10 },
|
|
||||||
{ name: 'Reprisal', buffType: 'debuff' },
|
|
||||||
],
|
|
||||||
'GNB': [
|
|
||||||
{ name: 'Heart of Light', buffType: 'buff' },
|
|
||||||
{ name: 'Rampart', buffType: 'buff', extraAbilityGameID: 7531, duration: 20 },
|
|
||||||
{ name: 'Superbolide', buffType: 'buff', extraAbilityGameID: 16152, duration: 10 },
|
|
||||||
{ name: 'Nebula', buffType: 'buff', extraAbilityGameID: 16148, duration: 15 },
|
|
||||||
{ name: 'Great Nebula', buffType: 'buff', extraAbilityGameID: 36935, duration: 15 },
|
|
||||||
{ name: 'Camouflage', buffType: 'buff', extraAbilityGameID: 16140, duration: 20 },
|
|
||||||
{ name: 'Heart of Stone', buffType: 'buff', extraAbilityGameID: 16161, duration: 7 },
|
|
||||||
{ name: 'Heart of Corundum', buffType: 'buff', extraAbilityGameID: 25758, duration: 8 },
|
|
||||||
{ name: 'Clarity of Corundum', buffType: 'buff', extraAbilityGameID: 25758, duration: 4 }, // Proc von HoC, geht auf beliebiges Partymitglied
|
|
||||||
{ name: 'Reprisal', buffType: 'debuff' },
|
|
||||||
],
|
|
||||||
'WHM': [
|
|
||||||
{ name: 'Temperance', buffType: 'buff' },
|
|
||||||
{ name: 'Aquaveil', buffType: 'buff', extraAbilityGameID: 25861, duration: 8 }, // Personal, WHM auf Ziel
|
|
||||||
{ name: 'Divine Benison', buffType: 'shield', extraAbilityGameID: 7432, duration: 15 },
|
|
||||||
{ name: 'Divine Caress', buffType: 'shield' },
|
|
||||||
],
|
|
||||||
'SCH': [
|
|
||||||
{ name: 'Sacred Soil', buffType: 'buff' },
|
|
||||||
{ name: 'Expedient', buffType: 'buff' },
|
|
||||||
{ name: 'Fey Illumination', buffType: 'buff' },
|
|
||||||
{ name: 'Galvanize', buffType: 'shield' },
|
|
||||||
{ name: 'Seraphic Veil', buffType: 'shield', extraAbilityGameID: 16548, duration: 30 },
|
|
||||||
{ name: 'Catalyze', buffType: 'shield' },
|
|
||||||
],
|
|
||||||
'AST': [
|
|
||||||
{ name: 'Collective Unconscious', buffType: 'buff' },
|
|
||||||
{ name: 'Exaltation', buffType: 'buff', extraAbilityGameID: 25873, duration: 8 }, // Personal, AST auf Ziel
|
|
||||||
{ name: 'Neutral Sect', buffType: 'shield' },
|
|
||||||
{ name: 'Intersection', buffType: 'shield', extraAbilityGameID: 16556, duration: 30 },
|
|
||||||
{ name: 'the Spire', buffType: 'shield', extraAbilityGameID: 37025, duration: 30 },
|
|
||||||
],
|
|
||||||
'SGE': [
|
|
||||||
{ name: 'Kerachole', buffType: 'buff' },
|
|
||||||
{ name: 'Holos', buffType: 'buff' },
|
|
||||||
{ name: 'Holosakos', buffType: 'shield' },
|
|
||||||
{ name: 'Panhaima', buffType: 'shield' },
|
|
||||||
{ name: 'Eukrasian Prognosis', buffType: 'shield' },
|
|
||||||
{ name: 'Eukrasian Prognosis II', buffType: 'shield' },
|
|
||||||
{ name: 'Eukrasian Diagnosis', buffType: 'shield', extraAbilityGameID: 24291, duration: 30 },
|
|
||||||
{ name: 'Differential Diagnosis', buffType: 'shield', extraAbilityGameID: 24291, duration: 30 },
|
|
||||||
{ name: 'Haima', buffType: 'shield', extraAbilityGameID: 24305, duration: 15 },
|
|
||||||
],
|
|
||||||
'BRD': [{ name: 'Troubadour', buffType: 'buff' }],
|
|
||||||
'MCH': [{ name: 'Tactician', buffType: 'buff' }],
|
|
||||||
'DNC': [
|
|
||||||
{ name: 'Shield Samba', buffType: 'buff' },
|
|
||||||
{ name: 'Improvised Finish', buffType: 'shield' },
|
|
||||||
],
|
|
||||||
'MNK': [
|
|
||||||
{ name: 'Riddle of Earth', buffType: 'buff', extraAbilityGameID: 7394, duration: 10 },
|
|
||||||
{ name: 'Feint', buffType: 'debuff' },
|
|
||||||
],
|
|
||||||
'DRG': [{ name: 'Feint', buffType: 'debuff' }],
|
|
||||||
'NIN': [
|
|
||||||
{ name: 'Shade Shift', buffType: 'shield', extraAbilityGameID: 2241, duration: 20 },
|
|
||||||
{ name: 'Feint', buffType: 'debuff' },
|
|
||||||
],
|
|
||||||
'SAM': [
|
|
||||||
{ name: 'Third Eye', buffType: 'buff', extraAbilityGameID: 7498, duration: 4 },
|
|
||||||
{ name: 'Feint', buffType: 'debuff' },
|
|
||||||
],
|
|
||||||
'RPR': [
|
|
||||||
{ name: 'Arcane Crest', buffType: 'shield', extraAbilityGameID: 24404, duration: 5 },
|
|
||||||
{ name: 'Feint', buffType: 'debuff' },
|
|
||||||
],
|
|
||||||
'VPR': [{ name: 'Feint', buffType: 'debuff' }],
|
|
||||||
'BLM': [
|
|
||||||
{ name: 'Manaward', buffType: 'shield', extraAbilityGameID: 157, duration: 20 },
|
|
||||||
{ name: 'Addle', buffType: 'debuff' },
|
|
||||||
],
|
|
||||||
'SMN': [
|
|
||||||
{ name: 'Addle', buffType: 'debuff' },
|
|
||||||
{ name: 'Radiant Aegis', buffType: 'shield', extraAbilityGameID: 25799, duration: 30 },
|
|
||||||
],
|
|
||||||
'RDM': [
|
|
||||||
{ name: 'Addle', buffType: 'debuff' },
|
|
||||||
{ name: 'Magick Barrier', buffType: 'buff' },
|
|
||||||
],
|
|
||||||
'PCT': [
|
|
||||||
{ name: 'Addle', buffType: 'debuff' },
|
|
||||||
{ name: 'Tempera Coat', buffType: 'shield', extraAbilityGameID: 34685, duration: 10 },
|
|
||||||
{ name: 'Tempera Grassa', buffType: 'shield' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const ABILITY_JOB_MAP = {
|
|
||||||
'Passage of Arms': 'PLD', 'Divine Veil': 'PLD',
|
|
||||||
'Hallowed Ground': 'PLD', 'Sentinel': 'PLD', 'Guardian': 'PLD',
|
|
||||||
'Bulwark': 'PLD', 'Holy Sheltron': 'PLD', 'Intervention': 'PLD',
|
|
||||||
'Knight\'s Resolve': 'PLD',
|
|
||||||
'Shake It Off': 'WAR', 'Holmgang': 'WAR', 'Vengeance': 'WAR',
|
|
||||||
'Damnation': 'WAR', 'Thrill of Battle': 'WAR', 'Raw Intuition': 'WAR',
|
|
||||||
'Bloodwhetting': 'WAR', 'Nascent Glint': 'WAR',
|
|
||||||
'Stem the Flow': 'WAR', 'Stem the Tide': 'WAR',
|
|
||||||
'Dark Missionary': 'DRK', 'Living Dead': 'DRK', 'Shadow Wall': 'DRK',
|
|
||||||
'Shadowed Vigil': 'DRK', 'Dark Mind': 'DRK', 'The Blackest Night': 'DRK',
|
|
||||||
'Oblation': 'DRK',
|
|
||||||
'Heart of Light': 'GNB', 'Superbolide': 'GNB', 'Nebula': 'GNB',
|
|
||||||
'Great Nebula': 'GNB', 'Camouflage': 'GNB', 'Heart of Stone': 'GNB',
|
|
||||||
'Heart of Corundum': 'GNB', 'Clarity of Corundum': 'GNB',
|
|
||||||
'Temperance': 'WHM', 'Aquaveil': 'WHM', 'Divine Benison': 'WHM', 'Divine Caress': 'WHM',
|
|
||||||
'Sacred Soil': 'SCH', 'Expedient': 'SCH', 'Fey Illumination': 'SCH',
|
|
||||||
'Galvanize': 'SCH', 'Seraphic Veil': 'SCH', 'Catalyze': 'SCH',
|
|
||||||
'Collective Unconscious': 'AST', 'Exaltation': 'AST', 'Neutral Sect': 'AST',
|
|
||||||
'Intersection': 'AST', 'the Spire': 'AST',
|
|
||||||
'Kerachole': 'SGE', 'Holos': 'SGE', 'Holosakos': 'SGE',
|
|
||||||
'Panhaima': 'SGE', 'Haima': 'SGE',
|
|
||||||
'Eukrasian Prognosis': 'SGE', 'Eukrasian Prognosis II': 'SGE',
|
|
||||||
'Eukrasian Diagnosis': 'SGE', 'Differential Diagnosis': 'SGE',
|
|
||||||
'Troubadour': 'BRD',
|
|
||||||
'Tactician': 'MCH',
|
|
||||||
'Shield Samba': 'DNC', 'Improvised Finish': 'DNC',
|
|
||||||
'Riddle of Earth': 'MNK',
|
|
||||||
'Shade Shift': 'NIN',
|
|
||||||
'Third Eye': 'SAM',
|
|
||||||
'Arcane Crest': 'RPR',
|
|
||||||
'Manaward': 'BLM',
|
|
||||||
'Radiant Aegis': 'SMN',
|
|
||||||
'Magick Barrier': 'RDM',
|
|
||||||
'Tempera Coat': 'PCT', 'Tempera Grassa': 'PCT',
|
|
||||||
};
|
|
||||||
|
|
||||||
const MITIG_ICONS = {
|
|
||||||
'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png',
|
|
||||||
'Dark Missionary': 'assets/icons/mitigation/dark-missionary.png',
|
|
||||||
'Heart of Light': 'assets/icons/mitigation/heart-of-light.png',
|
|
||||||
'Temperance': 'assets/icons/mitigation/temperance.png',
|
|
||||||
'Sacred Soil': 'assets/icons/mitigation/sacred-soil.png',
|
|
||||||
'Expedient': 'assets/icons/mitigation/expedient.png',
|
|
||||||
'Fey Illumination': 'assets/icons/mitigation/fey-illumination.png',
|
|
||||||
'Collective Unconscious': 'assets/icons/mitigation/collective-unconscious.png',
|
|
||||||
'Holos': 'assets/icons/mitigation/holos.png',
|
|
||||||
'Kerachole': 'assets/icons/mitigation/kerachole.png',
|
|
||||||
'Troubadour': 'assets/icons/mitigation/troubadour.png',
|
|
||||||
'Tactician': 'assets/icons/mitigation/tactician.png',
|
|
||||||
'Shield Samba': 'assets/icons/mitigation/shield-samba.png',
|
|
||||||
'Magick Barrier': 'assets/icons/mitigation/magick-barrier.png',
|
|
||||||
'Reprisal': 'assets/icons/mitigation/reprisal.png',
|
|
||||||
'Feint': 'assets/icons/mitigation/feint.png',
|
|
||||||
'Addle': 'assets/icons/mitigation/addle.png',
|
|
||||||
'Divine Veil': 'assets/icons/mitigation/divine-veil.png',
|
|
||||||
'Guardian': 'assets/icons/mitigation/guardian.png',
|
|
||||||
'Shake It Off': 'assets/icons/mitigation/shake-it-off.png',
|
|
||||||
'Bloodwhetting': 'assets/icons/mitigation/bloodwhetting.png',
|
|
||||||
'Divine Benison': 'assets/icons/mitigation/divine-benison.png',
|
|
||||||
'Divine Caress': 'assets/icons/mitigation/divine-caress.png',
|
|
||||||
'Intersection': 'assets/icons/mitigation/intersection.png',
|
|
||||||
'Neutral Sect': 'assets/icons/mitigation/neutral-sect.png',
|
|
||||||
'the Spire': 'assets/icons/mitigation/the-spire.png',
|
|
||||||
'Panhaima': 'assets/icons/mitigation/panhaima.png',
|
|
||||||
'Holosakos': 'assets/icons/mitigation/holos.png',
|
|
||||||
'Eukrasian Prognosis': 'assets/icons/mitigation/eukrasian-prognosis.png',
|
|
||||||
'Eukrasian Prognosis II': 'assets/icons/mitigation/eukrasian-prognosis-ii.png',
|
|
||||||
'Eukrasian Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
|
||||||
'Differential Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
|
||||||
'Haima': 'assets/icons/mitigation/haima.png',
|
|
||||||
'Galvanize': 'assets/icons/mitigation/galvanize.png',
|
|
||||||
'Seraphic Veil': 'assets/icons/mitigation/seraphic-veil.png',
|
|
||||||
'Radiant Aegis': 'assets/icons/mitigation/radiant-aegis.png',
|
|
||||||
'Tempera Coat': 'assets/icons/mitigation/tempera-coat.png',
|
|
||||||
'Tempera Grassa': 'assets/icons/mitigation/tempera-grassa.png',
|
|
||||||
'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png',
|
|
||||||
'Aquaveil': 'assets/icons/mitigation/aquaveil.png',
|
|
||||||
'Exaltation': 'assets/icons/mitigation/exaltation.png',
|
|
||||||
'Heart of Corundum': 'assets/icons/mitigation/heart-of-corundum.png',
|
|
||||||
'Clarity of Corundum': 'assets/icons/mitigation/clarity-of-corundum.png',
|
|
||||||
'Heart of Stone': 'assets/icons/mitigation/heart-of-stone.png',
|
|
||||||
'Knight\'s Resolve': 'assets/icons/mitigation/knights-resolve.png',
|
|
||||||
'Nascent Glint': 'assets/icons/mitigation/nascent-glint.png',
|
|
||||||
'Stem the Flow': 'assets/icons/mitigation/stem-the-flow.png',
|
|
||||||
'Stem the Tide': 'assets/icons/mitigation/stem-the-tide.png',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ABILITY_DR = {
|
|
||||||
'Passage of Arms': 0.15,
|
|
||||||
'Troubadour': 0.15,
|
|
||||||
'Tactician': 0.15,
|
|
||||||
'Shield Samba': 0.15,
|
|
||||||
'Dark Missionary': 0.10,
|
|
||||||
'Heart of Light': 0.10,
|
|
||||||
'Temperance': 0.10,
|
|
||||||
'Sacred Soil': 0.10,
|
|
||||||
'Expedient': 0.10,
|
|
||||||
'Collective Unconscious': 0.10,
|
|
||||||
'Holos': 0.10,
|
|
||||||
'Kerachole': 0.10,
|
|
||||||
'Magick Barrier': 0.10,
|
|
||||||
'Fey Illumination': 0.05,
|
|
||||||
'Reprisal': 0.10,
|
|
||||||
'Feint': 0.05,
|
|
||||||
'Addle': 0.10,
|
|
||||||
'Rampart': 0.20,
|
|
||||||
'Hallowed Ground': 1.00,
|
|
||||||
'Sentinel': 0.30,
|
|
||||||
'Guardian': 0.40,
|
|
||||||
'Holy Sheltron': 0.15,
|
|
||||||
'Intervention': 0.10,
|
|
||||||
'Knight\'s Resolve': 0.10,
|
|
||||||
'Aquaveil': 0.15,
|
|
||||||
'Exaltation': 0.10,
|
|
||||||
'Vengeance': 0.30,
|
|
||||||
'Damnation': 0.40,
|
|
||||||
'Raw Intuition': 0.10,
|
|
||||||
'Bloodwhetting': 0.10,
|
|
||||||
'Nascent Glint': 0.10,
|
|
||||||
'Stem the Flow': 0.10,
|
|
||||||
'Shadow Wall': 0.30,
|
|
||||||
'Shadowed Vigil': 0.40,
|
|
||||||
'Dark Mind': 0.20,
|
|
||||||
'Oblation': 0.10,
|
|
||||||
'Superbolide': 1.00,
|
|
||||||
'Nebula': 0.30,
|
|
||||||
'Great Nebula': 0.40,
|
|
||||||
'Camouflage': 0.10,
|
|
||||||
'Heart of Stone': 0.15,
|
|
||||||
'Heart of Corundum': 0.15,
|
|
||||||
'Clarity of Corundum': 0.15,
|
|
||||||
'Riddle of Earth': 0.20,
|
|
||||||
'Third Eye': 0.10,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ABILITY_JOBS = {};
|
|
||||||
Object.entries(JOB_ABILITIES).forEach(([job, abilities]) => {
|
|
||||||
abilities.forEach(ability => {
|
|
||||||
if (!ABILITY_JOBS[ability.name]) ABILITY_JOBS[ability.name] = [];
|
|
||||||
ABILITY_JOBS[ability.name].push(job);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
window.FF14_DATA = {
|
|
||||||
JOB_ABBR: JOB_FROM_TYPE,
|
|
||||||
JOB_FROM_TYPE,
|
|
||||||
JOB_ROLE,
|
|
||||||
ALL_JOBS,
|
|
||||||
JOB_ABILITIES,
|
|
||||||
ABILITY_JOB_MAP,
|
|
||||||
ABILITY_JOBS,
|
|
||||||
ABILITY_DR,
|
|
||||||
MITIG_ICONS,
|
|
||||||
TANK_JOBS: new Set(['PLD', 'WAR', 'DRK', 'GNB']),
|
|
||||||
MELEE_JOBS: new Set(['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR']),
|
|
||||||
CASTER_JOBS: new Set(['BLM', 'SMN', 'RDM', 'PCT']),
|
|
||||||
ABILITY_TYPE_PHYSICAL,
|
|
||||||
ABILITY_TYPE_MAGICAL,
|
|
||||||
abilityTypeIsPhysical,
|
|
||||||
abilityTypeIsMagical,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
2110
js/planner.js
@ -1 +0,0 @@
|
|||||||
php -S localhost:8080
|
|
||||||
@ -1,416 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
ini_set('memory_limit', '1024M');
|
|
||||||
|
|
||||||
const ACTION_SOURCE_URL = 'https://ff14.akurosiakamo.de/extras/json/xivapi_data/Action.json';
|
|
||||||
|
|
||||||
$rootDir = dirname(__DIR__);
|
|
||||||
$mitigationSource = $rootDir . '/api/analysis.php';
|
|
||||||
$plannerDataSource = $rootDir . '/js/ffxiv-data.js';
|
|
||||||
$outputFile = $rootDir . '/assets/jsons/Action.json';
|
|
||||||
|
|
||||||
function fail(string $message, int $code = 1): void
|
|
||||||
{
|
|
||||||
fwrite(STDERR, $message . PHP_EOL);
|
|
||||||
exit($code);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extract_constant_array_literal(string $php, string $constantName): string
|
|
||||||
{
|
|
||||||
$needle = 'const ' . $constantName . ' =';
|
|
||||||
$start = strpos($php, $needle);
|
|
||||||
|
|
||||||
if ($start === false) {
|
|
||||||
fail('Could not find const ' . $constantName . ' in api/analysis.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
$arrayStart = strpos($php, '[', $start);
|
|
||||||
if ($arrayStart === false) {
|
|
||||||
fail('Could not find array literal for ' . $constantName);
|
|
||||||
}
|
|
||||||
|
|
||||||
$depth = 0;
|
|
||||||
$length = strlen($php);
|
|
||||||
$inString = false;
|
|
||||||
$stringQuote = '';
|
|
||||||
$escaped = false;
|
|
||||||
|
|
||||||
for ($i = $arrayStart; $i < $length; $i++) {
|
|
||||||
$char = $php[$i];
|
|
||||||
|
|
||||||
if ($inString) {
|
|
||||||
if ($escaped) {
|
|
||||||
$escaped = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($char === '\\') {
|
|
||||||
$escaped = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($char === $stringQuote) {
|
|
||||||
$inString = false;
|
|
||||||
$stringQuote = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($char === '\'' || $char === '"') {
|
|
||||||
$inString = true;
|
|
||||||
$stringQuote = $char;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($char === '[') {
|
|
||||||
$depth++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($char === ']') {
|
|
||||||
$depth--;
|
|
||||||
|
|
||||||
if ($depth === 0) {
|
|
||||||
return substr($php, $arrayStart, $i - $arrayStart + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fail('Could not parse array literal for ' . $constantName);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extract_js_const_object_literal(string $js, string $constantName): string
|
|
||||||
{
|
|
||||||
$needle = 'const ' . $constantName . ' =';
|
|
||||||
$start = strpos($js, $needle);
|
|
||||||
|
|
||||||
if ($start === false) {
|
|
||||||
fail('Could not find const ' . $constantName . ' in js/ffxiv-data.js');
|
|
||||||
}
|
|
||||||
|
|
||||||
$objectStart = strpos($js, '{', $start);
|
|
||||||
if ($objectStart === false) {
|
|
||||||
fail('Could not find object literal for ' . $constantName);
|
|
||||||
}
|
|
||||||
|
|
||||||
$depth = 0;
|
|
||||||
$length = strlen($js);
|
|
||||||
$inString = false;
|
|
||||||
$stringQuote = '';
|
|
||||||
$escaped = false;
|
|
||||||
|
|
||||||
for ($i = $objectStart; $i < $length; $i++) {
|
|
||||||
$char = $js[$i];
|
|
||||||
|
|
||||||
if ($inString) {
|
|
||||||
if ($escaped) {
|
|
||||||
$escaped = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($char === '\\') {
|
|
||||||
$escaped = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($char === $stringQuote) {
|
|
||||||
$inString = false;
|
|
||||||
$stringQuote = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($char === '\'' || $char === '"' || $char === '`') {
|
|
||||||
$inString = true;
|
|
||||||
$stringQuote = $char;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($char === '{') {
|
|
||||||
$depth++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($char === '}') {
|
|
||||||
$depth--;
|
|
||||||
|
|
||||||
if ($depth === 0) {
|
|
||||||
return substr($js, $objectStart, $i - $objectStart + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fail('Could not parse object literal for ' . $constantName);
|
|
||||||
}
|
|
||||||
|
|
||||||
function read_planner_ability_names(string $sourceFile): array
|
|
||||||
{
|
|
||||||
if (!is_file($sourceFile)) {
|
|
||||||
fail('Missing planner data source file: ' . $sourceFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
$js = file_get_contents($sourceFile);
|
|
||||||
if ($js === false) {
|
|
||||||
fail('Could not read planner data source file: ' . $sourceFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
$literal = extract_js_const_object_literal($js, 'JOB_ABILITIES');
|
|
||||||
if (!preg_match_all('/\bname\s*:\s*([\'"])((?:\\\\.|(?!\1).)*)\1/s', $literal, $matches)) {
|
|
||||||
fail('No abilities found in JOB_ABILITIES');
|
|
||||||
}
|
|
||||||
|
|
||||||
$names = [];
|
|
||||||
foreach ($matches[2] as $rawName) {
|
|
||||||
$name = stripcslashes($rawName);
|
|
||||||
if ($name !== '') {
|
|
||||||
$names[$name] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ksort($names, SORT_NATURAL | SORT_FLAG_CASE);
|
|
||||||
return array_keys($names);
|
|
||||||
}
|
|
||||||
|
|
||||||
function read_mitigation_action_ids(string $sourceFile, array $abilityNames): array
|
|
||||||
{
|
|
||||||
if (!is_file($sourceFile)) {
|
|
||||||
fail('Missing mitigation source file: ' . $sourceFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
$php = file_get_contents($sourceFile);
|
|
||||||
if ($php === false) {
|
|
||||||
fail('Could not read mitigation source file: ' . $sourceFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
$literal = extract_constant_array_literal($php, 'MITIGATION_ABILITIES');
|
|
||||||
$abilities = eval('return ' . $literal . ';');
|
|
||||||
|
|
||||||
if (!is_array($abilities)) {
|
|
||||||
fail('MITIGATION_ABILITIES did not parse as an array');
|
|
||||||
}
|
|
||||||
|
|
||||||
$wantedNames = array_fill_keys($abilityNames, true);
|
|
||||||
$ids = [];
|
|
||||||
foreach ($wantedNames as $name => $_) {
|
|
||||||
if (!isset($abilities[$name])) {
|
|
||||||
fwrite(STDERR, 'Planner ability missing in MITIGATION_ABILITIES: ' . $name . PHP_EOL);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ability = $abilities[$name];
|
|
||||||
$id = (int)($ability['extraAbilityGameID'] ?? 0);
|
|
||||||
if ($id <= 0) {
|
|
||||||
fwrite(STDERR, 'Skipping mitigation without extraAbilityGameID: ' . $name . PHP_EOL);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ids[$id] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$ids) {
|
|
||||||
fail('No extraAbilityGameID values found for abilities from js/ffxiv-data.js');
|
|
||||||
}
|
|
||||||
|
|
||||||
ksort($ids, SORT_NUMERIC);
|
|
||||||
return array_keys($ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
function download_url(string $url): string
|
|
||||||
{
|
|
||||||
$lastError = '';
|
|
||||||
$allowInsecureDownload = getenv('FF14_MITIGATOR_INSECURE_DOWNLOAD') === '1';
|
|
||||||
|
|
||||||
if (function_exists('curl_init')) {
|
|
||||||
$ch = curl_init($url);
|
|
||||||
curl_setopt_array($ch, [
|
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
|
||||||
CURLOPT_FOLLOWLOCATION => true,
|
|
||||||
CURLOPT_CONNECTTIMEOUT => 15,
|
|
||||||
CURLOPT_TIMEOUT => 120,
|
|
||||||
CURLOPT_USERAGENT => 'ff14-mitigator-action-cache/1.0',
|
|
||||||
CURLOPT_SSL_VERIFYPEER => !$allowInsecureDownload,
|
|
||||||
CURLOPT_SSL_VERIFYHOST => $allowInsecureDownload ? 0 : 2,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$body = curl_exec($ch);
|
|
||||||
$error = curl_error($ch);
|
|
||||||
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
if ($body !== false && $status >= 200 && $status < 300) {
|
|
||||||
return $body;
|
|
||||||
}
|
|
||||||
|
|
||||||
$lastError = 'cURL HTTP ' . $status . ($error ? ': ' . $error : '');
|
|
||||||
}
|
|
||||||
|
|
||||||
$wrappers = stream_get_wrappers();
|
|
||||||
if (in_array('https', $wrappers, true)) {
|
|
||||||
$context = stream_context_create([
|
|
||||||
'http' => [
|
|
||||||
'timeout' => 120,
|
|
||||||
'user_agent' => 'ff14-mitigator-action-cache/1.0',
|
|
||||||
],
|
|
||||||
'ssl' => [
|
|
||||||
'verify_peer' => !$allowInsecureDownload,
|
|
||||||
'verify_peer_name' => !$allowInsecureDownload,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$body = file_get_contents($url, false, $context);
|
|
||||||
if ($body !== false) {
|
|
||||||
return $body;
|
|
||||||
}
|
|
||||||
|
|
||||||
$lastError = trim($lastError . '; file_get_contents failed', '; ');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($lastError !== '') {
|
|
||||||
fail('Could not download Action.json. ' . $lastError);
|
|
||||||
}
|
|
||||||
|
|
||||||
fail('This PHP installation has neither cURL nor the HTTPS stream wrapper enabled.');
|
|
||||||
}
|
|
||||||
|
|
||||||
function action_field(array $action, string $field): ?int
|
|
||||||
{
|
|
||||||
if (array_key_exists($field, $action) && is_numeric($action[$field])) {
|
|
||||||
return (int)$action[$field];
|
|
||||||
}
|
|
||||||
|
|
||||||
$fields = $action['fields'] ?? null;
|
|
||||||
if (is_array($fields) && array_key_exists($field, $fields) && is_numeric($fields[$field])) {
|
|
||||||
return (int)$fields[$field];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function action_text_field(array $action, string $field): ?string
|
|
||||||
{
|
|
||||||
if (array_key_exists($field, $action) && is_scalar($action[$field])) {
|
|
||||||
$value = trim((string)$action[$field]);
|
|
||||||
return $value !== '' ? $value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$fields = $action['fields'] ?? null;
|
|
||||||
if (is_array($fields) && array_key_exists($field, $fields) && is_scalar($fields[$field])) {
|
|
||||||
$value = trim((string)$fields[$field]);
|
|
||||||
return $value !== '' ? $value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function action_icon_url(array $action): ?string
|
|
||||||
{
|
|
||||||
$icon = $action['Icon'] ?? $action['fields']['Icon'] ?? null;
|
|
||||||
if (!is_array($icon)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = $icon['path_hr1'] ?? $icon['path'] ?? null;
|
|
||||||
if (!is_string($path) || $path === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!preg_match('#ui/icon/([^/]+)/([^/]+)\.tex$#', $path, $matches)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'https://xivapi.com/i/' . $matches[1] . '/' . $matches[2] . '.png';
|
|
||||||
}
|
|
||||||
|
|
||||||
function plain_action_text(?string $text): string
|
|
||||||
{
|
|
||||||
if ($text === null || $text === '') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
||||||
return trim((string)preg_replace('/\s+/u', ' ', $text));
|
|
||||||
}
|
|
||||||
|
|
||||||
function action_shield_text(array $action): ?string
|
|
||||||
{
|
|
||||||
$description = plain_action_text(action_text_field($action, 'Description_en'));
|
|
||||||
if ($description === '' || !preg_match('/barrier|absorbs|absorbed|nullif(?:y|ies)/i', $description)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/(?:absorbs|absorb|nullifies|nullify)[^.]*?(?:totaling|up to)?\s*(\d+)%\s*(?:of\s*)?(?:your\s+|target\'s\s+)?maximum HP/i', $description, $m)) {
|
|
||||||
return $m[1] . '% max HP';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/barrier[^.]*?(?:absorbs|absorb|nullifies|nullify)[^.]*?(\d+)%\s*(?:of\s*)?(?:your\s+|target\'s\s+)?maximum HP/i', $description, $m)) {
|
|
||||||
return $m[1] . '% max HP';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/barrier[^.]*?(\d+)%\s+of\s+the\s+amount\s+of\s+HP\s+restored/i', $description, $m)) {
|
|
||||||
return $m[1] . '% of HP restored';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/barrier[^.]*?(?:heal of|Cure Potency:)\s*(\d+)\s*potency/i', $description, $m)) {
|
|
||||||
return $m[1] . ' potency';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$plannerAbilityNames = read_planner_ability_names($plannerDataSource);
|
|
||||||
$actionIds = read_mitigation_action_ids($mitigationSource, $plannerAbilityNames);
|
|
||||||
$wanted = array_fill_keys(array_map('strval', $actionIds), true);
|
|
||||||
|
|
||||||
$json = download_url(ACTION_SOURCE_URL);
|
|
||||||
$actions = json_decode($json, true);
|
|
||||||
|
|
||||||
if (!is_array($actions)) {
|
|
||||||
fail('Downloaded Action.json is not valid JSON: ' . json_last_error_msg());
|
|
||||||
}
|
|
||||||
|
|
||||||
$filtered = [];
|
|
||||||
foreach ($wanted as $id => $_) {
|
|
||||||
$action = $actions[$id] ?? null;
|
|
||||||
if (!is_array($action)) {
|
|
||||||
fwrite(STDERR, 'Missing action in downloaded Action.json: ' . $id . PHP_EOL);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$filtered[$id] = [
|
|
||||||
'cast' => action_field($action, 'Cast100ms'),
|
|
||||||
'recast' => action_field($action, 'Recast100ms'),
|
|
||||||
'names' => [
|
|
||||||
'en' => action_text_field($action, 'Name_en'),
|
|
||||||
'de' => action_text_field($action, 'Name_de'),
|
|
||||||
'fr' => action_text_field($action, 'Name_fr'),
|
|
||||||
'jp' => action_text_field($action, 'Name_ja'),
|
|
||||||
],
|
|
||||||
'icon' => action_icon_url($action),
|
|
||||||
'shield' => action_shield_text($action),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$filtered) {
|
|
||||||
fail('No matching mitigation actions found in downloaded Action.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
ksort($filtered, SORT_NUMERIC);
|
|
||||||
|
|
||||||
$outputDir = dirname($outputFile);
|
|
||||||
if (!is_dir($outputDir) && !mkdir($outputDir, 0775, true) && !is_dir($outputDir)) {
|
|
||||||
fail('Could not create output directory: ' . $outputDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
$encoded = json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
||||||
if ($encoded === false) {
|
|
||||||
fail('Could not encode filtered Action.json: ' . json_last_error_msg());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file_put_contents($outputFile, $encoded . PHP_EOL, LOCK_EX) === false) {
|
|
||||||
fail('Could not write output file: ' . $outputFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
echo 'Saved ' . count($filtered) . ' actions to ' . $outputFile . PHP_EOL;
|
|
||||||
@ -21,7 +21,7 @@
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a href="<?= htmlspecialchars(auth_start_href(), ENT_QUOTES) ?>" class="btn btn-gold btn-login">
|
<a href="auth/start.php" class="btn btn-gold btn-login">
|
||||||
<?= $tokenExpired ? 'Reconnect to FFLogs' : 'Connect to FFLogs' ?>
|
<?= $tokenExpired ? 'Reconnect to FFLogs' : 'Connect to FFLogs' ?>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
@ -80,35 +80,6 @@
|
|||||||
</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">
|
||||||
@ -122,7 +93,6 @@
|
|||||||
|
|
||||||
<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="<?= htmlspecialchars(auth_start_href(), ENT_QUOTES) ?>" style="align-self:flex-end;text-decoration:none">Reconnect</a>
|
<a class="btn" href="auth/start.php" style="align-self:flex-end;text-decoration:none">Reconnect</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -22,15 +22,6 @@
|
|||||||
<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,9 +2,6 @@
|
|||||||
|
|
||||||
<!-- 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>
|
||||||
@ -29,6 +26,7 @@
|
|||||||
|
|
||||||
<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>
|
||||||
|
|||||||