Compare commits
No commits in common. "main" and "main" have entirely different histories.
2
.gitignore
vendored
@ -1,5 +1,3 @@
|
||||
.claude/
|
||||
debug/
|
||||
cached_logs/
|
||||
.env
|
||||
fflogs-schema.json
|
||||
|
||||
69
CLAUDE.md
@ -69,7 +69,6 @@ api/
|
||||
analysis.php — POST-Endpunkt: Spieler + AoE-Events + Mitigations → JSON
|
||||
abilities.php — POST-Endpunkt: Ability- + Spielerliste für Event Explorer Dropdowns
|
||||
debug-events.php — POST-Endpunkt: Raw Events für Event Explorer (mit Filterung)
|
||||
players.php — POST-Endpunkt: Spieler + maxHp aus einem beliebigen Fight (für Namen+Job-Import im Planer)
|
||||
assets/
|
||||
icons/mitigation/ — Lokal gespeicherte Ability-Icons (PNG, von XIVAPI)
|
||||
data/
|
||||
@ -109,7 +108,7 @@ Getrackte party-wide Buffs + Schilde + Boss-Debuffs (definiert in `MITIGATION_AB
|
||||
Drei `buffType`-Kategorien:
|
||||
- **`buff`**: Damage Reduction — Passage of Arms (15%), Troubadour/Tactician/Shield Samba (15%), Dark Missionary/Heart of Light/Temperance/Sacred Soil/Expedient/Collective Unconscious/Holos/Kerachole/Magick Barrier (10%), Fey Illumination (5%)
|
||||
- **`shield`**: Barrieren — Divine Veil, Guardian, Shake It Off, Bloodwhetting, Divine Benison, Divine Caress, Intersection, Neutral Sect, the Spire, Panhaima, Holosakos, Eukrasian Prognosis, Eukrasian Diagnosis, Differential Diagnosis, Haima, Galvanize, Seraphic Veil, Catalyze, Radiant Aegis, Tempera Coat, Tempera Grassa, Improvised Finish
|
||||
- **`debuff`**: Boss-Debuffs — Reprisal (10%), Feint (10% phys / 5% magic), Addle (10%)
|
||||
- **`debuff`**: Boss-Debuffs — Reprisal, Feint, Addle (je 10%)
|
||||
|
||||
**Statische Status-IDs (`statusId`-Feld):** Jeder Eintrag in `MITIGATION_ABILITIES` trägt ein `statusId`-Feld (FFLogs Status-ID = XIVAPI Status row_id + 1.000.000). Diese IDs werden als Fallback in `$mitigIdMap` eingetragen wenn masterData den Eintrag nicht enthält. Löst das Pre-Pull-Problem und Name-Mismatches (z.B. FFLogs "Guardian's Will" vs. Key 'Guardian', "Desperate Measures" vs. 'Expedient').
|
||||
|
||||
@ -161,14 +160,6 @@ Konsistentes Healer → DPS → Tank-Ordering überall: im Spieler-Grid, in jede
|
||||
- Schaden-Delta pro Spieler: grün wenn besser (`aoe-delta-better`), rot wenn schlechter (`aoe-delta-worse`)
|
||||
- Gesamt-Delta + Ref-Debuff-Icons in der REF-Headerzeile (`aoe-ref-label`)
|
||||
|
||||
### Plan als Referenz (Analyse-Tab)
|
||||
- Button "+ Plan als Referenz" (`#ref-plan-toggle`) öffnet Dropdown mit gespeicherten Plänen
|
||||
- Bei Auswahl: Plan-Mechaniken werden zu `refEvents[]` konvertiert (`isPlanRef: true` Flag)
|
||||
- `planToRefEvents(plan)`: wandelt Plan-Assignments in `refEvents` um — nur Abilities mit bekanntem `buffType`
|
||||
- In `renderTimeline()`: `isPlanRef` steuert Darstellung — kein Schaden-Delta, kein Dead-State, "Schild"-Text statt absorbed-Wert, Label "PLAN" statt "REF"
|
||||
- Spieler des Plans werden im Spieler-Grid angezeigt wenn sie sich vom aktuellen Fight unterscheiden (via `refPlayers`)
|
||||
- Same-Report, Cross-Report und Plan-Ref schließen sich gegenseitig aus
|
||||
|
||||
### Cross-Report-Vergleich
|
||||
- Button "+ Anderer Report" (`#ref-ext-toggle`) öffnet Panel mit Eingabefeld + Laden-Button
|
||||
- `api/fight.php` wird mit dem externen Report-Code aufgerufen → Fight-Dropdown befüllt
|
||||
@ -252,11 +243,6 @@ Raid-Cooldown-Planer: Welche Mitigation-Ability wird für welche Mechanik einges
|
||||
"mitigationNames": { "Reprisal": "Vergeltung" },
|
||||
"folderId": null,
|
||||
"jobComposition": ["PLD", "WAR", "WHM", "SCH", "MNK", "DRG", "BRD", "SMN"],
|
||||
"playerRoster": [
|
||||
{ "name": "Xziino", "maxHp": 82340 },
|
||||
{ "name": "Healer1", "maxHp": 75000 }
|
||||
],
|
||||
"shieldK": 30,
|
||||
"mechanics": [
|
||||
{
|
||||
"id": "uuid",
|
||||
@ -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.
|
||||
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.
|
||||
@ -306,35 +289,37 @@ Der Haupteinstieg ist der Analyse-Tab — der Nutzer hat die Daten bereits gelad
|
||||
| 1 | ✅ | **Datenfundament** — Plan-Datenmodell + localStorage CRUD |
|
||||
| 2 | ✅ | **Tab-Grundgerüst** — Plan-Liste, Ordner, Mechanik-Timeline |
|
||||
| 3 | ✅ | **Import aus Analyse-Tab** — Export-Button + Dialog + Merge |
|
||||
| 4 | ✅ | **Jobaufstellung** — 8 Slots mit Job-Dropdown + Rollenfärbung + Namen+Job-Import |
|
||||
| 4 | ✅ | **Jobaufstellung** — 8 Slots mit Job-Dropdown + Rollenfärbung |
|
||||
| 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 |
|
||||
|
||||
### 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).
|
||||
- `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
|
||||
**Konzept:** Ergänzende Ansicht zur Mechaniken-Übersicht, nicht Ersatz. Umschaltbar per Toggle/Tab.
|
||||
|
||||
### 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:**
|
||||
- X-Achse: Kampfzeit aus `source.fightEnd - source.fightStart`
|
||||
- **Eine Zeile pro (Job, Ability)** — nicht pro Job. `timelinePlayerRows(plan)` expandiert `JOB_ABILITIES` pro Job.
|
||||
- Schilde in Gantt ausgeblendet, außer **Panhaima** (SGE)
|
||||
- Erste Zeile jedes Jobs hat eine visuelle Trennlinie (`.timeline-player-row--job-start`)
|
||||
- Balken: linker Teil (aktive Dauer, opak) + rechter Teil (Cooldown-Rest, transparent) via CSS `linear-gradient` auf `--active-width` / `--cd-width`
|
||||
- Klick auf leere Zeile → fügt diese Ability direkt der nächsten Mechanik hinzu (kein Menü)
|
||||
- Drag & Drop: nur zwischen Zeilen derselben Ability
|
||||
- Bestehende Balken per Drag verschiebbar; Rechtsklick zum Entfernen
|
||||
**Drag & Drop:**
|
||||
- Ability-Icons aus Palette auf Timeline ziehen → Icon am Startpunkt, Balken zeigt Dauer
|
||||
- Freies Ziehen ohne Snapping — Timestamp wird aus X-Position berechnet
|
||||
- Bestehende Balken sind ebenfalls verschiebbar
|
||||
|
||||
**Noch offen:**
|
||||
- Konflikte visuell hervorheben wenn Balken eine Mechaniken-Linie überlappt
|
||||
**Update-Button:**
|
||||
- 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:
|
||||
```json
|
||||
@ -360,7 +345,7 @@ Jobaufstellung → verfügbare Abilities (Subset von `MITIGATION_ABILITIES`):
|
||||
| WHM | Temperance, Divine Benison, Divine Caress |
|
||||
| SCH | Sacred Soil, Expedient, Fey Illumination, Galvanize, Seraphic Veil, Catalyze, Addle |
|
||||
| AST | Collective Unconscious, Neutral Sect, Intersection, the Spire |
|
||||
| SGE | Kerachole, Holos, Holosakos, Panhaima, Eukrasian Prognosis, Eukrasian Diagnosis, Differential Diagnosis, Haima |
|
||||
| SGE | Kerachole, Holos, Holosakos, Panhaima, Eukrasian Prognosis, Eukrasian Diagnosis, Haima, Addle |
|
||||
| BRD | Troubadour |
|
||||
| MCH | Tactician |
|
||||
| DNC | Shield Samba, Improvised Finish |
|
||||
@ -406,7 +391,7 @@ Bekannte Werte (Beispiele):
|
||||
- **Persistenz:** `localStorage` — kein Backend nötig
|
||||
- **IDs:** `crypto.randomUUID()` für Plan-, Mechanik- und Ordner-IDs
|
||||
- **Eindeutige Namen:** `uniquePlanName()` verhindert Duplikate beim Erstellen und Importieren
|
||||
- **Spielernamen in `playerRoster`:** Assignments bleiben Job-basiert (`{ ability, job }`). Namen + maxHp werden separat in `playerRoster[8]` gespeichert — für ∅-HP-Anzeige und Analyse-Tab-Vergleich, nicht für Assignments
|
||||
- **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
|
||||
- **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.
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
<?php
|
||||
ini_set('display_errors', '0');
|
||||
require_once __DIR__ . '/../config.php';
|
||||
require_once __DIR__ . '/cache.php';
|
||||
session_start_safe();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed']); exit; }
|
||||
if (empty($_SESSION['access_token'])) { echo json_encode(['reauth' => true]); exit; }
|
||||
if (($_SESSION['token_expires'] ?? 0) <= time()) { echo json_encode(['reauth' => true]); exit; }
|
||||
|
||||
$reportCode = preg_replace('/[^a-zA-Z0-9]/', '', $_POST['report_code'] ?? '');
|
||||
$fightId = (int)($_POST['fight_id'] ?? 0);
|
||||
@ -18,16 +19,6 @@ $translate = 'true';
|
||||
|
||||
if (!$reportCode || !$fightId || !$endTime) { http_response_code(400); echo json_encode(['error' => 'Missing params']); exit; }
|
||||
|
||||
$cacheParts = [$fightId, (int)$startTime, (int)$endTime];
|
||||
$cached = read_cached_log('abilities', $reportCode, $language, $cacheParts);
|
||||
if ($cached !== null) {
|
||||
echo $cached;
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($_SESSION['access_token'])) { echo json_encode(['reauth' => true]); exit; }
|
||||
if (($_SESSION['token_expires'] ?? 0) <= time()) { echo json_encode(['reauth' => true]); exit; }
|
||||
|
||||
$token = $_SESSION['access_token'];
|
||||
|
||||
function localized_graphql_uri(string $language): string {
|
||||
@ -132,12 +123,4 @@ foreach (array_keys($seenIds) as $id) {
|
||||
}
|
||||
usort($abilities, fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
|
||||
$response = json_encode(['abilities' => $abilities, 'players' => $players]);
|
||||
if ($response === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Could not encode abilities response']);
|
||||
exit;
|
||||
}
|
||||
|
||||
write_cached_log('abilities', $reportCode, $language, $cacheParts, $response);
|
||||
echo $response;
|
||||
echo json_encode(['abilities' => $abilities, 'players' => $players]);
|
||||
|
||||
274
api/analysis.php
@ -1,7 +1,6 @@
|
||||
<?php
|
||||
ini_set('display_errors', '0');
|
||||
require_once __DIR__ . '/../config.php';
|
||||
require_once __DIR__ . '/cache.php';
|
||||
session_start_safe();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
@ -12,6 +11,15 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($_SESSION['access_token'])) {
|
||||
echo json_encode(['reauth' => true]);
|
||||
exit;
|
||||
}
|
||||
if (($_SESSION['token_expires'] ?? 0) <= time()) {
|
||||
echo json_encode(['reauth' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$reportCode = preg_replace('/[^a-zA-Z0-9]/', '', $_POST['report_code'] ?? '');
|
||||
$fightId = (int)($_POST['fight_id'] ?? 0);
|
||||
$startTime = (float)($_POST['start_time'] ?? 0);
|
||||
@ -26,22 +34,6 @@ if (!$reportCode || !$fightId || !$endTime) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$cacheParts = [$fightId, (int)$startTime, (int)$endTime];
|
||||
$cached = read_cached_log('analysis', $reportCode, $language, $cacheParts);
|
||||
if ($cached !== null) {
|
||||
echo $cached;
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($_SESSION['access_token'])) {
|
||||
echo json_encode(['reauth' => true]);
|
||||
exit;
|
||||
}
|
||||
if (($_SESSION['token_expires'] ?? 0) <= time()) {
|
||||
echo json_encode(['reauth' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$token = $_SESSION['access_token'];
|
||||
|
||||
function localized_graphql_uri(string $language): string {
|
||||
@ -84,101 +76,60 @@ function fflogs_gql(string $query): array {
|
||||
// buffType 'debuff' = boss debuff, shown in event header
|
||||
const MITIGATION_ABILITIES = [
|
||||
// ── Damage reduction buffs ──────────────────────────────────────────────
|
||||
'Passage of Arms' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001175, 'extraAbilityGameID' => 7385],
|
||||
'Dark Missionary' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001894, 'extraAbilityGameID' => 16471],
|
||||
'Heart of Light' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001839, 'extraAbilityGameID' => 16160],
|
||||
'Temperance' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001873, 'extraAbilityGameID' => 16536],
|
||||
'Aquaveil' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1002708, 'extraAbilityGameID' => 25861], // Personal, WHM auf Ziel
|
||||
'Sacred Soil' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001944, 'extraAbilityGameID' => 188],
|
||||
'Expedient' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002711, 'extraAbilityGameID' => 25868], // FFLogs: "Desperate Measures"
|
||||
'Fey Illumination' => ['dr' => 5, 'buffType' => 'buff', 'statusId' => 1000317, 'extraAbilityGameID' => 16538],
|
||||
'Collective Unconscious' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1000849, 'extraAbilityGameID' => 3613],
|
||||
'Exaltation' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002717, 'extraAbilityGameID' => 25873], // Personal, AST auf Ziel
|
||||
'Holos' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1003003, 'extraAbilityGameID' => 24310],
|
||||
'Kerachole' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002618, 'extraAbilityGameID' => 24298],
|
||||
'Troubadour' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001934, 'extraAbilityGameID' => 7405],
|
||||
'Tactician' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001951, 'extraAbilityGameID' => 16889],
|
||||
'Shield Samba' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001826, 'extraAbilityGameID' => 16012],
|
||||
'Magick Barrier' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002707, 'extraAbilityGameID' => 25857],
|
||||
// ── Personal / targeted mitigation ─────────────────────────────────────
|
||||
'Rampart' => ['dr' => 20, 'buffType' => 'buff', 'extraAbilityGameID' => 7531],
|
||||
// PLD
|
||||
'Hallowed Ground' => ['dr' => 100, 'buffType' => 'buff', 'extraAbilityGameID' => 30],
|
||||
'Sentinel' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 17],
|
||||
'Bulwark' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 22],
|
||||
'Holy Sheltron' => ['dr' => 15, 'buffType' => 'buff', 'extraAbilityGameID' => 25746],
|
||||
'Intervention' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 7382],
|
||||
'Knight\'s Resolve' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002675, 'extraAbilityGameID' => 7382], // Proc von Intervention
|
||||
// WAR
|
||||
'Holmgang' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 43],
|
||||
'Vengeance' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 44],
|
||||
'Damnation' => ['dr' => 40, 'buffType' => 'buff', 'extraAbilityGameID' => 36923],
|
||||
'Thrill of Battle' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 40],
|
||||
'Raw Intuition' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 3551],
|
||||
'Nascent Glint' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001858, 'extraAbilityGameID' => 16464], // Proc von Nascent Flash auf Ziel
|
||||
'Stem the Flow' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002679, 'extraAbilityGameID' => 25751], // Proc von Bloodwhetting / Nascent Flash
|
||||
// DRK
|
||||
'Living Dead' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 3638],
|
||||
'Shadow Wall' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 3636],
|
||||
'Shadowed Vigil' => ['dr' => 40, 'buffType' => 'buff', 'extraAbilityGameID' => 36927],
|
||||
'Dark Mind' => ['dr' => 20, 'buffType' => 'buff', 'extraAbilityGameID' => 3634],
|
||||
'The Blackest Night' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 7393],
|
||||
'Oblation' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 25754],
|
||||
// GNB
|
||||
'Superbolide' => ['dr' => 100, 'buffType' => 'buff', 'extraAbilityGameID' => 16152],
|
||||
'Nebula' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 16148],
|
||||
'Great Nebula' => ['dr' => 40, 'buffType' => 'buff', 'statusId' => 1003838, 'extraAbilityGameID' => 36935],
|
||||
'Camouflage' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 16140],
|
||||
'Heart of Stone' => ['dr' => 15, 'buffType' => 'buff', 'extraAbilityGameID' => 16161],
|
||||
'Heart of Corundum' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1002683, 'extraAbilityGameID' => 25758],
|
||||
'Clarity of Corundum' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1002684, 'extraAbilityGameID' => 25758], // Proc von Heart of Corundum, kann beliebiges Partymitglied treffen
|
||||
// DPS
|
||||
'Riddle of Earth' => ['dr' => 20, 'buffType' => 'buff', 'extraAbilityGameID' => 7394],
|
||||
'Shade Shift' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 2241],
|
||||
'Third Eye' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 7498],
|
||||
'Arcane Crest' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 24404],
|
||||
'Manaward' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 157],
|
||||
'Passage of Arms' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001175],
|
||||
'Dark Missionary' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001894],
|
||||
'Heart of Light' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001839],
|
||||
'Temperance' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001873],
|
||||
'Sacred Soil' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001944],
|
||||
'Expedient' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002711], // FFLogs: "Desperate Measures"
|
||||
'Fey Illumination' => ['dr' => 5, 'buffType' => 'buff', 'statusId' => 1000317],
|
||||
'Collective Unconscious' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1000849],
|
||||
'Holos' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1003003],
|
||||
'Kerachole' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002618],
|
||||
'Troubadour' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001934],
|
||||
'Tactician' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001951],
|
||||
'Shield Samba' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001826],
|
||||
'Magick Barrier' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002707],
|
||||
// ── Shields ─────────────────────────────────────────────────────────────
|
||||
// PLD
|
||||
'Divine Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001362, 'extraAbilityGameID' => 3540],
|
||||
'Guardian' => ['dr' => 40, 'buffType' => 'shield', 'statusId' => 1003830, 'extraAbilityGameID' => 36920], // FFLogs: "Guardian's Will"
|
||||
'Divine Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001362],
|
||||
'Guardian' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003830], // FFLogs: "Guardian's Will"
|
||||
// WAR
|
||||
'Shake It Off' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001457, 'extraAbilityGameID' => 7388],
|
||||
'Bloodwhetting' => ['dr' => 10, 'buffType' => 'shield', 'statusId' => 1002678, 'extraAbilityGameID' => 25751],
|
||||
'Stem the Tide' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002680, 'extraAbilityGameID' => 25751], // Proc von Bloodwhetting / Nascent Flash
|
||||
'Shake It Off' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001457],
|
||||
'Bloodwhetting' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002678],
|
||||
// WHM
|
||||
'Divine Benison' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001218, 'extraAbilityGameID' => 7432],
|
||||
'Divine Caress' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003903, 'extraAbilityGameID' => 37011],
|
||||
'Divine Benison' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001218],
|
||||
'Divine Caress' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003903],
|
||||
// AST
|
||||
'Intersection' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001889, 'extraAbilityGameID' => 16556],
|
||||
'Neutral Sect' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001921, 'extraAbilityGameID' => 16559],
|
||||
'the Spire' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003892, 'extraAbilityGameID' => 37025], // FFLogs: "The Spire"
|
||||
'Intersection' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001889],
|
||||
'Neutral Sect' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001921],
|
||||
'the Spire' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003892], // FFLogs: "The Spire"
|
||||
// SGE
|
||||
'Panhaima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002613, 'extraAbilityGameID' => 24311],
|
||||
'Holosakos' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003365, 'extraAbilityGameID' => 24310],
|
||||
'Eukrasian Prognosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002609, 'extraAbilityGameID' => 24292],
|
||||
'Eukrasian Prognosis II' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 37034],
|
||||
'Eukrasian Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002607, 'extraAbilityGameID' => 24291],
|
||||
'Differential Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002608, 'extraAbilityGameID' => 24291],
|
||||
'Haima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002612, 'extraAbilityGameID' => 24305],
|
||||
'Panhaima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002613],
|
||||
'Holosakos' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003365],
|
||||
'Eukrasian Prognosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002609],
|
||||
'Eukrasian Prognosis II' => ['dr' => 0, 'buffType' => 'shield'], // TODO
|
||||
'Eukrasian Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002607],
|
||||
'Differential Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002608],
|
||||
'Haima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002612],
|
||||
// SCH
|
||||
'Galvanize' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1000297, 'extraAbilityGameID' => 185],
|
||||
'Seraphic Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001917, 'extraAbilityGameID' => 16548],
|
||||
'Catalyze' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001918, 'extraAbilityGameID' => 185],
|
||||
'Galvanize' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1000297],
|
||||
'Seraphic Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001917],
|
||||
'Catalyze' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001918],
|
||||
// SMN
|
||||
'Radiant Aegis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002702, 'extraAbilityGameID' => 25799],
|
||||
'Radiant Aegis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002702],
|
||||
// PCT
|
||||
'Tempera Coat' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003686, 'extraAbilityGameID' => 34685],
|
||||
'Tempera Grassa' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003687, 'extraAbilityGameID' => 34686],
|
||||
'Tempera Coat' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003686],
|
||||
'Tempera Grassa' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003687],
|
||||
// DNC
|
||||
'Improvised Finish' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002697, 'extraAbilityGameID' => 25789],
|
||||
'Improvised Finish' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002697],
|
||||
// ── Boss debuffs ────────────────────────────────────────────────────────
|
||||
'Reprisal' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001193, 'extraAbilityGameID' => 7535],
|
||||
'Feint' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001195, 'extraAbilityGameID' => 7549],
|
||||
'Addle' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001203, 'extraAbilityGameID' => 7560],
|
||||
'Reprisal' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001193],
|
||||
'Feint' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001195],
|
||||
'Addle' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001203],
|
||||
];
|
||||
|
||||
function resolveMitigations(string $buffStr, array $mitigIdMap, array $buffSourceTimeline = [], array $players = [], float $ts = 0): array {
|
||||
function resolveMitigations(string $buffStr, array $mitigIdMap): array {
|
||||
if ($buffStr === '') return [];
|
||||
$result = [];
|
||||
$seen = [];
|
||||
@ -188,33 +139,17 @@ function resolveMitigations(string $buffStr, array $mitigIdMap, array $buffSourc
|
||||
$name = $mitigIdMap[$id]['name'];
|
||||
if (isset($seen[$name])) continue;
|
||||
$seen[$name] = true;
|
||||
$entry = [
|
||||
$result[] = [
|
||||
'key' => $mitigIdMap[$id]['key'] ?? $name,
|
||||
'name' => $name,
|
||||
'dr' => $mitigIdMap[$id]['dr'],
|
||||
'buffType' => $mitigIdMap[$id]['buffType'],
|
||||
'extraAbilityGameID' => $mitigIdMap[$id]['extraAbilityGameID'] ?? null,
|
||||
];
|
||||
$source = findBuffSourcePlayer($buffSourceTimeline, $id, $ts, $players);
|
||||
if ($source) $entry['sourcePlayerType'] = $source['type'];
|
||||
$result[] = $entry;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Findet den Spieler der einen Buff zum Zeitpunkt $ts gecastet hat (anhand der applybuff-Timeline).
|
||||
function findBuffSourcePlayer(array $sourceTimeline, int $statusId, float $ts, array $players): ?array {
|
||||
$best = null;
|
||||
foreach ($sourceTimeline[$statusId] ?? [] as $entry) {
|
||||
if ($entry['apply'] > $ts + 200) continue; // noch nicht aktiv
|
||||
if ($entry['remove'] !== null && $entry['remove'] < $ts - 200) continue; // schon abgelaufen
|
||||
if ($best === null || $entry['apply'] > $best['apply']) $best = $entry;
|
||||
}
|
||||
if ($best === null || empty($best['sourceId'])) return null;
|
||||
return $players[$best['sourceId']] ?? null;
|
||||
}
|
||||
|
||||
// Fallback for shields consumed by a hit: the damage event's buffs field no
|
||||
// longer contains the shield ID (already removed), but the applybuff/removebuff
|
||||
// timeline shows it was active just before the hit.
|
||||
@ -226,13 +161,7 @@ function shieldsActiveAt(array $shieldTimeline, int $targetId, float $ts, array
|
||||
if ($iv['apply'] <= $ts && ($iv['remove'] === null || $iv['remove'] >= $ts - 200)) {
|
||||
if (isset($mitigIdMap[$statusId])) {
|
||||
$m = $mitigIdMap[$statusId];
|
||||
$result[] = [
|
||||
'key' => $m['key'] ?? $m['name'],
|
||||
'name' => $m['name'],
|
||||
'dr' => $m['dr'],
|
||||
'buffType' => $m['buffType'],
|
||||
'extraAbilityGameID' => $m['extraAbilityGameID'] ?? null,
|
||||
];
|
||||
$result[] = ['key' => $m['key'] ?? $m['name'], 'name' => $m['name'], 'dr' => $m['dr'], 'buffType' => $m['buffType']];
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -348,9 +277,7 @@ for ($page = 0; $page < 10; $page++) {
|
||||
// Builds applybuff/removebuff intervals per target so we can detect shields
|
||||
// that were consumed by a hit (absent from the damage event's buffs snapshot).
|
||||
$shieldTimeline = []; // targetId → statusId → [[apply, remove|null], ...]
|
||||
$buffSourceTimeline = []; // statusId → [[apply, remove|null, sourceId], ...] — wer hat den Buff gecastet?
|
||||
$statusNames = []; // statusId → localized display name from Buffs events
|
||||
$statusActionIds = []; // statusId → applybuff extraAbilityGameID from FFLogs
|
||||
|
||||
if (!empty($trackedStatusIds)) {
|
||||
$nextPage = $startTime;
|
||||
@ -385,31 +312,12 @@ if (!empty($trackedStatusIds)) {
|
||||
if (is_string($evName) && $evName !== '') {
|
||||
$statusNames[$abId] = $evName;
|
||||
}
|
||||
$extraAbilityGameID = (int)($ev['extraAbilityGameID'] ?? 0);
|
||||
if ($extraAbilityGameID > 0) {
|
||||
$statusActionIds[$abId] = $extraAbilityGameID;
|
||||
}
|
||||
|
||||
$tgtId = (int)($ev['targetID'] ?? 0);
|
||||
$ts = (float)($ev['timestamp'] ?? 0);
|
||||
$type = $ev['type'] ?? '';
|
||||
$meta = $mitigIdMap[$abId] ?? null;
|
||||
|
||||
// Source-Tracking für alle getrackten Abilities (unabhängig von buffType)
|
||||
$srcId = (int)($ev['sourceID'] ?? 0);
|
||||
if ($srcId > 0 && isset($players[$srcId])) {
|
||||
if ($type === 'applybuff') {
|
||||
$buffSourceTimeline[$abId][] = ['apply' => $ts, 'remove' => null, 'sourceId' => $srcId];
|
||||
} elseif ($type === 'removebuff') {
|
||||
for ($i = count($buffSourceTimeline[$abId] ?? []) - 1; $i >= 0; $i--) {
|
||||
if ($buffSourceTimeline[$abId][$i]['remove'] === null) {
|
||||
$buffSourceTimeline[$abId][$i]['remove'] = $ts;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (($meta['buffType'] ?? null) !== 'shield') continue;
|
||||
|
||||
if ($type === 'applybuff') {
|
||||
@ -431,60 +339,11 @@ if (!empty($trackedStatusIds)) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2c. Boss-Debuff-Source via Casts ───────────────────────────────────────
|
||||
// dataType: Buffs liefert nur Events auf Spieler (Friendly). Reprisal/Feint/Addle
|
||||
// werden auf den Boss (Hostile) angewendet und tauchen dort nicht auf.
|
||||
// Lösung: Cast-Events der drei Abilities direkt abfragen — 1 GQL-Request, 3 Aliase.
|
||||
$dbReprisalActionId = (int)(MITIGATION_ABILITIES['Reprisal']['extraAbilityGameID'] ?? 0);
|
||||
$dbFeintActionId = (int)(MITIGATION_ABILITIES['Feint']['extraAbilityGameID'] ?? 0);
|
||||
$dbAddleActionId = (int)(MITIGATION_ABILITIES['Addle']['extraAbilityGameID'] ?? 0);
|
||||
$dbReprisalStatusId = (int)(MITIGATION_ABILITIES['Reprisal']['statusId'] ?? 0);
|
||||
$dbFeintStatusId = (int)(MITIGATION_ABILITIES['Feint']['statusId'] ?? 0);
|
||||
$dbAddleStatusId = (int)(MITIGATION_ABILITIES['Addle']['statusId'] ?? 0);
|
||||
|
||||
if ($dbReprisalActionId && $dbFeintActionId && $dbAddleActionId) {
|
||||
$dbResult = fflogs_gql(<<<GQL
|
||||
{
|
||||
reportData {
|
||||
report(code: "$reportCode") {
|
||||
reprisal: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbReprisalActionId, startTime: $startTime, endTime: $endTime) { data }
|
||||
feint: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbFeintActionId, startTime: $startTime, endTime: $endTime) { data }
|
||||
addle: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbAddleActionId, startTime: $startTime, endTime: $endTime) { data }
|
||||
}
|
||||
}
|
||||
}
|
||||
GQL);
|
||||
if (isset($dbResult['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
|
||||
|
||||
foreach ([
|
||||
'reprisal' => ['statusId' => $dbReprisalStatusId, 'durationMs' => 10000],
|
||||
'feint' => ['statusId' => $dbFeintStatusId, 'durationMs' => 10000],
|
||||
'addle' => ['statusId' => $dbAddleStatusId, 'durationMs' => 10000],
|
||||
] as $alias => $info) {
|
||||
foreach ($dbResult['data']['reportData']['report'][$alias]['data'] ?? [] as $ev) {
|
||||
if (($ev['type'] ?? '') !== 'cast') continue;
|
||||
$srcId = (int)($ev['sourceID'] ?? 0);
|
||||
if ($srcId <= 0 || !isset($players[$srcId]) || !$info['statusId']) continue;
|
||||
$ts = (float)($ev['timestamp'] ?? 0);
|
||||
$buffSourceTimeline[$info['statusId']][] = [
|
||||
'apply' => $ts,
|
||||
'remove' => $ts + $info['durationMs'],
|
||||
'sourceId' => $srcId,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($statusNames as $statusId => $displayName) {
|
||||
if (isset($mitigIdMap[$statusId])) {
|
||||
$mitigIdMap[$statusId]['name'] = $displayName;
|
||||
}
|
||||
}
|
||||
foreach ($statusActionIds as $statusId => $extraAbilityGameID) {
|
||||
if (isset($mitigIdMap[$statusId])) {
|
||||
$mitigIdMap[$statusId]['extraAbilityGameID'] = $extraAbilityGameID;
|
||||
}
|
||||
}
|
||||
|
||||
$mitigationNames = [];
|
||||
foreach ($mitigIdMap as $meta) {
|
||||
@ -509,9 +368,6 @@ foreach ($allEvents as $ev) {
|
||||
$tgtId = (int)($ev['targetID'] ?? 0);
|
||||
if (!$abId || !$tgtId || $abId <= 7) continue;
|
||||
|
||||
$srcId = (int)($ev['sourceID'] ?? 0);
|
||||
if ($srcId > 0 && isset($players[$srcId])) continue;
|
||||
|
||||
$byAbility[$abId][] = [
|
||||
'ts' => (float)($ev['timestamp'] ?? 0),
|
||||
'tgtId' => $tgtId,
|
||||
@ -571,7 +427,6 @@ foreach ($byAbility as $abId => $events) {
|
||||
if ($current !== null) $clusters[] = $current;
|
||||
}
|
||||
|
||||
$bossEvents = [];
|
||||
$aoeEvents = [];
|
||||
foreach ($clusters as $group) {
|
||||
$targetCount = count($group['targets']);
|
||||
@ -593,6 +448,8 @@ foreach ($clusters as $group) {
|
||||
}
|
||||
}
|
||||
|
||||
if ($targetCount < 3 && !$isHeavyTankbuster) continue;
|
||||
|
||||
$targets = [];
|
||||
foreach ($group['targets'] as $tgtId => $tgt) {
|
||||
$p = $players[$tgtId] ?? null;
|
||||
@ -608,8 +465,8 @@ foreach ($clusters as $group) {
|
||||
'overkill' => $tgt['overkill'],
|
||||
'hp' => $tgt['hp'],
|
||||
'maxHp' => $tgt['maxHp'],
|
||||
'mitigations' => (function() use ($tgt, $mitigIdMap, $shieldTimeline, $buffSourceTimeline, $players) {
|
||||
$mitigations = resolveMitigations($tgt['buffs'], $mitigIdMap, $buffSourceTimeline, $players, $tgt['ts']);
|
||||
'mitigations' => (function() use ($tgt, $mitigIdMap, $shieldTimeline) {
|
||||
$mitigations = resolveMitigations($tgt['buffs'], $mitigIdMap);
|
||||
if ($tgt['absorbed'] > 0 && !empty($shieldTimeline)) {
|
||||
$existing = [];
|
||||
foreach ($mitigations as $m) {
|
||||
@ -629,7 +486,7 @@ foreach ($clusters as $group) {
|
||||
return $roleCmp !== 0 ? $roleCmp : strcmp($a['name'], $b['name']);
|
||||
});
|
||||
|
||||
$bossEvent = [
|
||||
$aoeEvents[] = [
|
||||
'timestamp' => $group['timestamp'],
|
||||
'abilityId' => $group['abilityId'],
|
||||
'abilityName' => $group['abilityName'],
|
||||
@ -637,27 +494,12 @@ foreach ($clusters as $group) {
|
||||
'totalDamage' => array_sum(array_column($targets, 'amount')),
|
||||
'isHeavyTankbuster' => $isHeavyTankbuster,
|
||||
];
|
||||
$bossEvents[] = $bossEvent;
|
||||
|
||||
if ($targetCount < 3 && !$isHeavyTankbuster) continue;
|
||||
|
||||
$aoeEvents[] = $bossEvent;
|
||||
}
|
||||
usort($bossEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);
|
||||
usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);
|
||||
|
||||
$response = json_encode([
|
||||
echo json_encode([
|
||||
'players' => array_values($players),
|
||||
'boss_events' => $bossEvents,
|
||||
'aoe_events' => $aoeEvents,
|
||||
'fight_start' => (int)$startTime,
|
||||
'mitigation_names' => $mitigationNames,
|
||||
]);
|
||||
if ($response === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Could not encode analysis response']);
|
||||
exit;
|
||||
}
|
||||
|
||||
write_cached_log('analysis', $reportCode, $language, $cacheParts, $response);
|
||||
echo $response;
|
||||
|
||||
@ -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,' : '';
|
||||
|
||||
$result = dbg_gql(<<<GQL
|
||||
{
|
||||
reportData {
|
||||
report(code: "$reportCode") {
|
||||
masterData(translate: false) {
|
||||
abilities {
|
||||
gameID
|
||||
name
|
||||
type
|
||||
}
|
||||
}
|
||||
events(
|
||||
fightIDs: [$fightId],
|
||||
dataType: $dataType,
|
||||
@ -118,14 +111,7 @@ GQL);
|
||||
|
||||
$events = $result['data']['reportData']['report']['events']['data'] ?? [];
|
||||
|
||||
// Build abilityGameID → { name, type } lookup
|
||||
$abilityMeta = [];
|
||||
foreach ($result['data']['reportData']['report']['masterData']['abilities'] ?? [] as $ab) {
|
||||
$abilityMeta[(int)$ab['gameID']] = ['name' => $ab['name'], 'type' => $ab['type'] ?? null];
|
||||
}
|
||||
|
||||
// Filter by raw event type, player (source OR target), then apply limit
|
||||
// Enrich each event with ability meta (name + type) from masterData
|
||||
$filtered = [];
|
||||
foreach ($events as $ev) {
|
||||
if ($eventType !== '' && ($ev['type'] ?? '') !== $eventType) continue;
|
||||
@ -135,11 +121,6 @@ foreach ($events as $ev) {
|
||||
$tgtId = (int)($ev['targetID'] ?? -1);
|
||||
if (!in_array($srcId, $playerIds) && !in_array($tgtId, $playerIds)) continue;
|
||||
}
|
||||
// Inject ability meta so ability.type is visible directly on the event
|
||||
$gid = (int)($ev['abilityGameID'] ?? 0);
|
||||
if ($gid && isset($abilityMeta[$gid])) {
|
||||
$ev['_ability'] = $abilityMeta[$gid];
|
||||
}
|
||||
$filtered[] = $ev;
|
||||
if (count($filtered) >= $limit) break;
|
||||
}
|
||||
@ -150,9 +131,11 @@ echo json_encode([
|
||||
'ability_id' => $abilityId ?: null,
|
||||
'player_name' => $playerName ?: null,
|
||||
'player_ids' => $playerIds ?: null,
|
||||
'time_range' => ['from_ms' => (int)$queryStart, 'to_ms' => (int)$queryEnd],
|
||||
'time_range' => [
|
||||
'from_ms' => (int)$queryStart,
|
||||
'to_ms' => (int)$queryEnd,
|
||||
],
|
||||
'total_before_limit' => count($events),
|
||||
'count' => count($filtered),
|
||||
'ability_meta' => $abilityMeta, // vollständige Lookup-Tabelle: gameID → {name, type}
|
||||
'events' => $filtered,
|
||||
], JSON_PRETTY_PRINT);
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
<?php
|
||||
ini_set('display_errors', '0');
|
||||
require_once __DIR__ . '/../config.php';
|
||||
require_once __DIR__ . '/cache.php';
|
||||
session_start_safe();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
@ -22,12 +21,6 @@ if (strlen($reportCode) < 1) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$cached = read_cached_log('fight', $reportCode, $language);
|
||||
if ($cached !== null) {
|
||||
echo $cached;
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($_SESSION['access_token'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Not authenticated', 'reauth' => true]);
|
||||
@ -49,7 +42,6 @@ query GetReportData($reportCode: String!) {
|
||||
endTime
|
||||
fights {
|
||||
id
|
||||
encounterID
|
||||
name
|
||||
startTime
|
||||
endTime
|
||||
@ -83,7 +75,9 @@ function localized_graphql_uri(string $language): string {
|
||||
return preg_replace('#https://[^/]+#', 'https://' . $host, GRAPHQL_URI);
|
||||
}
|
||||
|
||||
$ch = curl_init(localized_graphql_uri($language));
|
||||
// Fight names must be stable regardless of language — always use the English endpoint.
|
||||
// Localization only matters for ability/player names in analysis.php.
|
||||
$ch = curl_init(GRAPHQL_URI);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
@ -91,14 +85,12 @@ curl_setopt_array($ch, [
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $_SESSION['access_token'],
|
||||
'Accept-Language: ' . ($language === 'jp' ? 'ja' : $language),
|
||||
],
|
||||
CURLOPT_SSL_VERIFYPEER => !DEV_MODE,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$httpStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlError) {
|
||||
http_response_code(502);
|
||||
@ -120,8 +112,5 @@ if ($httpStatus === 401) {
|
||||
}
|
||||
|
||||
http_response_code($httpStatus === 200 ? 200 : $httpStatus);
|
||||
if ($httpStatus === 200) {
|
||||
write_cached_log('fight', $reportCode, $language, [], $body);
|
||||
}
|
||||
echo $body;
|
||||
exit;
|
||||
|
||||
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['oauth_state'] = $state;
|
||||
$_SESSION['oauth_return'] = safe_return_path($_GET['return'] ?? null);
|
||||
$_SESSION['oauth_return'] = null;
|
||||
|
||||
$params = http_build_query([
|
||||
'response_type' => 'code',
|
||||
|
||||
87
config.php
@ -1,51 +1,11 @@
|
||||
<?php
|
||||
|
||||
function load_env_file(string $path): void {
|
||||
if (!is_file($path)) return;
|
||||
|
||||
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || str_starts_with($line, '#')) continue;
|
||||
|
||||
[$key, $value] = array_pad(explode('=', $line, 2), 2, '');
|
||||
$key = trim($key);
|
||||
if ($key === '') continue;
|
||||
|
||||
$value = trim($value);
|
||||
if (
|
||||
strlen($value) >= 2
|
||||
&& (($value[0] === '"' && $value[-1] === '"') || ($value[0] === "'" && $value[-1] === "'"))
|
||||
) {
|
||||
$value = substr($value, 1, -1);
|
||||
}
|
||||
|
||||
$_ENV[$key] = $value;
|
||||
putenv($key . '=' . $value);
|
||||
}
|
||||
}
|
||||
|
||||
function env_value(string $key, ?string $default = null): string {
|
||||
$value = $_ENV[$key] ?? getenv($key);
|
||||
if ($value === false || $value === null || $value === '') {
|
||||
if ($default !== null) return $default;
|
||||
throw new RuntimeException('Missing required environment value: ' . $key);
|
||||
}
|
||||
return (string)$value;
|
||||
}
|
||||
|
||||
function env_bool(string $key, bool $default = false): bool {
|
||||
$value = strtolower(env_value($key, $default ? 'true' : 'false'));
|
||||
return in_array($value, ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
load_env_file(__DIR__ . '/.env');
|
||||
|
||||
define('DEV_MODE', env_bool('DEV_MODE'));
|
||||
define('CLIENT_ID', env_value('CLIENT_ID'));
|
||||
define('REDIRECT_URI', env_value('REDIRECT_URI'));
|
||||
define('AUTHORIZE_URI', env_value('AUTHORIZE_URI'));
|
||||
define('TOKEN_URI', env_value('TOKEN_URI'));
|
||||
define('GRAPHQL_URI', env_value('GRAPHQL_URI'));
|
||||
define('DEV_MODE', true); // set to false in production
|
||||
define('CLIENT_ID', 'a1d27cba-b7f8-48dd-aefd-4697b457cc67');
|
||||
define('REDIRECT_URI', 'http://localhost:8080/auth/callback.php');
|
||||
define('AUTHORIZE_URI','https://www.fflogs.com/oauth/authorize');
|
||||
define('TOKEN_URI', 'https://www.fflogs.com/oauth/token');
|
||||
define('GRAPHQL_URI', 'https://www.fflogs.com/api/v2/user');
|
||||
|
||||
function session_start_safe(): void {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
@ -59,38 +19,3 @@ function session_start_safe(): void {
|
||||
session_start();
|
||||
}
|
||||
}
|
||||
|
||||
function default_return_path(): string {
|
||||
$script = str_replace('\\', '/', $_SERVER['SCRIPT_NAME'] ?? '/index.php');
|
||||
$base = rtrim(dirname(dirname($script)), '/');
|
||||
return ($base === '' ? '' : $base) . '/index.php';
|
||||
}
|
||||
|
||||
function safe_return_path(?string $value): string {
|
||||
$value = trim((string)$value);
|
||||
if ($value === '') return default_return_path();
|
||||
|
||||
$parts = parse_url($value);
|
||||
if ($parts === false) return default_return_path();
|
||||
if (isset($parts['host'])) {
|
||||
$currentHost = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
|
||||
if (strtolower($parts['host']) !== $currentHost) return default_return_path();
|
||||
} elseif (str_starts_with($value, '//')) {
|
||||
return default_return_path();
|
||||
}
|
||||
|
||||
$path = $parts['path'] ?? '';
|
||||
if ($path === '') $path = default_return_path();
|
||||
if ($path[0] !== '/') $path = '/' . ltrim($path, '/');
|
||||
|
||||
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
|
||||
return $path . $query;
|
||||
}
|
||||
|
||||
function current_return_path(): string {
|
||||
return safe_return_path($_SERVER['REQUEST_URI'] ?? null);
|
||||
}
|
||||
|
||||
function auth_start_href(?string $returnPath = null): string {
|
||||
return 'auth/start.php?return=' . rawurlencode($returnPath ?? current_return_path());
|
||||
}
|
||||
|
||||
@ -80,30 +80,6 @@ select option { background: var(--bg2); }
|
||||
|
||||
.btn-sm { padding: 5px 13px; font-size: 13px; }
|
||||
|
||||
/* ── Export choice dropdown ─────────────────────────────────────────────────── */
|
||||
.export-choice-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--bg3);
|
||||
border-radius: var(--r);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||||
}
|
||||
.export-choice-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 9px 18px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--t1);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.export-choice-item:hover { background: var(--bg3); color: var(--gold); }
|
||||
|
||||
/* ── Stats row ──────────────────────────────────────────────────────────────── */
|
||||
.stats-row {
|
||||
display: flex;
|
||||
|
||||
756
css/planner.css
@ -4,12 +4,6 @@
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#plan-detail-panel,
|
||||
#plan-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Plan Sidebar ────────────────────────────────────────────────────────────── */
|
||||
@ -165,63 +159,6 @@
|
||||
.job-slot--healer select { border-left-color: var(--green); }
|
||||
.job-slot--dps select { border-left-color: rgba(200,168,75,.5); }
|
||||
|
||||
/* ── Job Slot Player Names ───────────────────────────────────────────────────── */
|
||||
.job-slot-name {
|
||||
font-size: 11px;
|
||||
color: var(--t2);
|
||||
text-align: center;
|
||||
margin-top: 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ── Name Import Modal ───────────────────────────────────────────────────────── */
|
||||
.name-import-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.name-import-input-row input { flex: 1; }
|
||||
|
||||
.name-import-preview {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.name-import-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.name-import-row:last-child { border-bottom: none; }
|
||||
|
||||
.name-import-name {
|
||||
font-size: 14px;
|
||||
color: var(--t1);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.name-import-name--none {
|
||||
font-size: 13px;
|
||||
color: var(--t3);
|
||||
font-style: italic;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.name-import-disambig {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
/* ── Mechanic Cards ──────────────────────────────────────────────────────────── */
|
||||
.mechanic-card {
|
||||
display: grid;
|
||||
@ -285,16 +222,6 @@
|
||||
font-size: 13px;
|
||||
color: var(--t2);
|
||||
}
|
||||
.mechanic-avg-hp {
|
||||
font-size: 12px;
|
||||
color: var(--t3);
|
||||
margin-left: 6px;
|
||||
}
|
||||
.mechanic-mitig-row { margin-top: -2px; display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; }
|
||||
.mechanic-mitig-shield { font-size: 12px; color: var(--t3); }
|
||||
.mechanic-mitig-val { font-weight: 500; }
|
||||
.mechanic-mitig--ok { color: var(--green); }
|
||||
.mechanic-mitig--risk { color: var(--red); }
|
||||
|
||||
.mechanic-assignments {
|
||||
display: flex;
|
||||
@ -323,7 +250,6 @@
|
||||
.badge-assign-buff { background: rgba(200,168,75,.08); border-color: rgba(200,168,75,.4); color: var(--gold); }
|
||||
.badge-assign-debuff { background: rgba(224,92,92,.08); border-color: rgba(224,92,92,.4); color: var(--red); }
|
||||
.badge-assign-shield { background: rgba(74,158,255,.08); border-color: rgba(74,158,255,.4); color: var(--blue); }
|
||||
.badge-assign-personal { background: rgba(177,112,255,.08); border-color: rgba(177,112,255,.4); color: #dbc7ff; }
|
||||
|
||||
.badge-assign--missing-job {
|
||||
border-style: dashed;
|
||||
@ -489,7 +415,6 @@
|
||||
.ability-chip.badge-assign-buff.ability-chip--active { background: rgba(200,168,75,.18); border-color: rgba(200,168,75,.6); color: var(--gold); }
|
||||
.ability-chip.badge-assign-debuff.ability-chip--active { background: rgba(224,92,92,.18); border-color: rgba(224,92,92,.6); color: var(--red); }
|
||||
.ability-chip.badge-assign-shield.ability-chip--active { background: rgba(74,158,255,.18); border-color: rgba(74,158,255,.6); color: var(--blue); }
|
||||
.ability-chip.badge-assign-personal.ability-chip--active { background: rgba(177,112,255,.18); border-color: rgba(177,112,255,.6); color: #dbc7ff; }
|
||||
|
||||
.ability-chip--other-job { opacity: 0.45; }
|
||||
|
||||
@ -528,9 +453,9 @@
|
||||
|
||||
/* ── Info Panel ──────────────────────────────────────────────────────────────── */
|
||||
.planner-info-panel {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.info-section { margin-bottom: 12px; }
|
||||
@ -565,7 +490,6 @@
|
||||
.info-legend-dot--buff { background: rgba(200,168,75,.8); }
|
||||
.info-legend-dot--debuff { background: var(--red); }
|
||||
.info-legend-dot--shield { background: var(--blue); }
|
||||
.info-legend-dot--personal { background: rgba(177,112,255,.9); }
|
||||
|
||||
.info-legend-label {
|
||||
font-size: 12px;
|
||||
@ -581,38 +505,6 @@
|
||||
.info-warning--job { color: var(--t2); }
|
||||
.info-warning--missing { color: var(--red); }
|
||||
|
||||
.info-section--extra { margin-top: 12px; }
|
||||
|
||||
.info-extra-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-extra-label {
|
||||
font-size: 12px;
|
||||
color: var(--t2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-extra-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info-extra-input {
|
||||
width: 52px;
|
||||
font-size: 13px;
|
||||
padding: 3px 6px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.info-extra-unit {
|
||||
font-size: 12px;
|
||||
color: var(--t3);
|
||||
}
|
||||
|
||||
/* ── Folder Sidebar ──────────────────────────────────────────────────────────── */
|
||||
.folder-section { margin-bottom: 2px; }
|
||||
|
||||
@ -705,645 +597,3 @@
|
||||
}
|
||||
.folder-picker-option:hover { background: var(--bg2); color: var(--t1); }
|
||||
.folder-picker-option.active { color: var(--gold); }
|
||||
|
||||
/* ── Planner Timeline ───────────────────────────────────────────────────────── */
|
||||
.timeline-hint {
|
||||
font-size: 12px;
|
||||
color: var(--t3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.timeline-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.timeline-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
background: rgba(255,255,255,.025);
|
||||
color: var(--t2);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.timeline-toggle:hover {
|
||||
border-color: var(--borderem);
|
||||
color: var(--t1);
|
||||
}
|
||||
|
||||
.timeline-toggle input {
|
||||
width: auto !important;
|
||||
min-width: 0 !important;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.timeline-empty,
|
||||
.timeline-settings-empty {
|
||||
font-size: 13px;
|
||||
color: var(--t3);
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.timeline-scroll {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg1);
|
||||
scrollbar-color: var(--border) var(--bg1);
|
||||
scrollbar-width: thin;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.timeline-scroll--dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.timeline-scroll::-webkit-scrollbar { height: 6px; }
|
||||
.timeline-scroll::-webkit-scrollbar-track { background: var(--bg1); }
|
||||
.timeline-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
.timeline-scroll::-webkit-scrollbar-thumb:hover { background: var(--t3); }
|
||||
|
||||
.timeline-grid {
|
||||
width: calc(180px + var(--timeline-width));
|
||||
display: grid;
|
||||
grid-template-columns: 180px var(--timeline-width);
|
||||
}
|
||||
|
||||
.timeline-row,
|
||||
.timeline-axis {
|
||||
display: grid;
|
||||
grid-template-columns: 180px var(--timeline-width);
|
||||
min-height: 38px;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.timeline-row-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
background: var(--bgcard);
|
||||
border-right: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--t2);
|
||||
min-width: 0;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 8;
|
||||
box-shadow: 8px 0 12px rgba(0,0,0,.18);
|
||||
}
|
||||
|
||||
.timeline-boss-row {
|
||||
min-height: var(--boss-row-height, 52px);
|
||||
}
|
||||
|
||||
.timeline-boss-row .timeline-row-label {
|
||||
color: var(--gold);
|
||||
font-weight: 600;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.timeline-boss-label {
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.timeline-boss-label:hover {
|
||||
background: rgba(200,168,75,.08);
|
||||
}
|
||||
|
||||
.timeline-track,
|
||||
.timeline-axis-track {
|
||||
position: relative;
|
||||
min-height: inherit;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to right,
|
||||
transparent 0,
|
||||
transparent 79px,
|
||||
rgba(255,255,255,0.07) 80px
|
||||
);
|
||||
}
|
||||
|
||||
.timeline-hit-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(200,168,75,.38);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-hit-line--tankbuster {
|
||||
width: 2px;
|
||||
background: rgba(177,112,255,.62);
|
||||
}
|
||||
|
||||
.timeline-player-row .timeline-track {
|
||||
background-color: rgba(255,255,255,0.015);
|
||||
}
|
||||
|
||||
.timeline-job {
|
||||
width: 36px;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
padding: 2px 0;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timeline-player-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-row-ability {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-row-ability-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.timeline-row-ability-name {
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-player-row--job-start .timeline-row-label,
|
||||
.timeline-player-row--job-start .timeline-track {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.timeline-player-row--drop-ok .timeline-track {
|
||||
background-color: rgba(88,180,116,.08);
|
||||
}
|
||||
|
||||
.timeline-player-row--drop-bad .timeline-track {
|
||||
background-color: rgba(224,92,92,.08);
|
||||
}
|
||||
|
||||
.timeline-boss-action {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
transform: translateX(-50%);
|
||||
max-width: 150px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid rgba(224,92,92,.35);
|
||||
border-radius: var(--r);
|
||||
background: rgba(224,92,92,.14);
|
||||
color: var(--t1);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.timeline-boss-action:hover {
|
||||
border-color: rgba(224,92,92,.7);
|
||||
background: rgba(224,92,92,.22);
|
||||
}
|
||||
|
||||
.timeline-boss-action--tankbuster {
|
||||
border-color: rgba(177,112,255,.55);
|
||||
background: rgba(177,112,255,.18);
|
||||
color: #dbc7ff;
|
||||
}
|
||||
|
||||
.timeline-boss-action--tankbuster:hover {
|
||||
border-color: rgba(177,112,255,.85);
|
||||
background: rgba(177,112,255,.28);
|
||||
}
|
||||
|
||||
.timeline-mitigation {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
width: var(--cd-width);
|
||||
min-width: 28px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--borderem);
|
||||
border-radius: var(--r);
|
||||
background: var(--bg2);
|
||||
color: var(--t1);
|
||||
font-size: 11px;
|
||||
cursor: grab;
|
||||
z-index: 4;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-mitigation-active { display: none; }
|
||||
|
||||
.timeline-mitigation:active { cursor: grabbing; }
|
||||
.timeline-mitigation.selected {
|
||||
outline: 2px solid var(--gold);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.timeline-mitigation--candidate {
|
||||
border-style: dashed;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.timeline-mitigation img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-mitigation span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-mitigation .timeline-mitigation-active {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.timeline-mitigation--buff {
|
||||
border-color: rgba(200,168,75,.5);
|
||||
color: var(--gold);
|
||||
background: linear-gradient(to right, rgba(200,168,75,.30) 0%, rgba(200,168,75,.30) var(--active-width), rgba(200,168,75,.06) var(--active-width), rgba(200,168,75,.06) 100%);
|
||||
}
|
||||
.timeline-mitigation--debuff {
|
||||
border-color: rgba(224,92,92,.5);
|
||||
color: var(--red);
|
||||
background: linear-gradient(to right, rgba(224,92,92,.30) 0%, rgba(224,92,92,.30) var(--active-width), rgba(224,92,92,.06) var(--active-width), rgba(224,92,92,.06) 100%);
|
||||
}
|
||||
.timeline-mitigation--shield {
|
||||
border-color: rgba(74,158,255,.5);
|
||||
color: var(--blue);
|
||||
background: linear-gradient(to right, rgba(74,158,255,.30) 0%, rgba(74,158,255,.30) var(--active-width), rgba(74,158,255,.06) var(--active-width), rgba(74,158,255,.06) 100%);
|
||||
}
|
||||
|
||||
.timeline-drag-preview {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
width: var(--cd-width);
|
||||
min-width: 28px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid rgba(200,168,75,.85);
|
||||
border-radius: var(--r);
|
||||
background: linear-gradient(to right, rgba(200,168,75,.42) 0%, rgba(200,168,75,.42) var(--active-width), rgba(200,168,75,.14) var(--active-width), rgba(200,168,75,.14) 100%);
|
||||
color: var(--gold);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
z-index: 6;
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,.25), 0 0 16px rgba(200,168,75,.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-drag-preview::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -6px;
|
||||
bottom: -6px;
|
||||
width: 1px;
|
||||
background: var(--gold);
|
||||
box-shadow: 0 0 10px rgba(200,168,75,.55);
|
||||
}
|
||||
|
||||
.timeline-drag-preview--bad {
|
||||
border-color: rgba(224,92,92,.85);
|
||||
background: rgba(224,92,92,.18);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.timeline-drag-preview--bad::before {
|
||||
background: var(--red);
|
||||
box-shadow: 0 0 10px rgba(224,92,92,.55);
|
||||
}
|
||||
|
||||
.timeline-drag-preview img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-drag-preview span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-drag-preview-active {
|
||||
position: absolute !important;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: var(--active-width);
|
||||
background: currentColor;
|
||||
opacity: .20;
|
||||
z-index: 0 !important;
|
||||
}
|
||||
|
||||
.timeline-axis {
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.timeline-axis-track {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.timeline-tick {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 11px;
|
||||
color: var(--t3);
|
||||
}
|
||||
|
||||
.timeline-settings-panel {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.timeline-settings-title {
|
||||
font-size: 13px;
|
||||
color: var(--t1);
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.timeline-settings-meta {
|
||||
font-size: 12px;
|
||||
color: var(--t3);
|
||||
width: 100%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.timeline-settings-panel label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--t3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.timeline-setting-input {
|
||||
width: 86px !important;
|
||||
font-size: 13px !important;
|
||||
padding: 5px 7px !important;
|
||||
}
|
||||
|
||||
.timeline-context-menu {
|
||||
position: fixed;
|
||||
z-index: 300;
|
||||
min-width: 190px;
|
||||
max-width: 280px;
|
||||
max-height: min(520px, calc(100vh - 24px));
|
||||
overflow-y: auto;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--borderem);
|
||||
border-radius: var(--r);
|
||||
background: var(--bgcard);
|
||||
box-shadow: 0 10px 24px rgba(0,0,0,0.45);
|
||||
}
|
||||
|
||||
.timeline-menu-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 9px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--t2);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline-menu-item:hover {
|
||||
background: var(--bg2);
|
||||
color: var(--t1);
|
||||
}
|
||||
|
||||
.timeline-menu-item.disabled,
|
||||
.timeline-menu-item:disabled {
|
||||
opacity: 0.38;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.timeline-menu-header:disabled {
|
||||
opacity: 1;
|
||||
padding-top: 9px;
|
||||
padding-bottom: 4px;
|
||||
color: var(--gold);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.timeline-menu-header:hover {
|
||||
background: transparent;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.timeline-menu-item img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timeline-menu-item span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-menu-empty {
|
||||
padding: 8px 10px;
|
||||
color: var(--t3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── View Toggle ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.view-toggle-btns {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.view-toggle-btn {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
background: transparent;
|
||||
color: var(--t2);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-b);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.view-toggle-btn:hover {
|
||||
color: var(--t1);
|
||||
border-color: var(--t3);
|
||||
}
|
||||
|
||||
.view-toggle-btn.active {
|
||||
background: var(--gold);
|
||||
border-color: var(--gold);
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Meine Spells ────────────────────────────────────────────────────────── */
|
||||
|
||||
.myspells-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.myspells-controls select {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
background: var(--bg2);
|
||||
color: var(--t1);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.myspells-row {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 180px 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 4px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.myspells-abilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.myspells-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.myspells-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
color: var(--gold);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.myspells-mechanic {
|
||||
color: var(--t2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.myspells-ability {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: var(--t1);
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
padding: 3px 8px 3px 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.myspells-ability.myspells-type--debuff { color: var(--orange); border-color: rgba(255,140,0,0.3); }
|
||||
.myspells-ability.myspells-type--shield { color: var(--blue); border-color: rgba(88,166,255,0.3); }
|
||||
.myspells-ability.myspells-type--personal { color: #dbc7ff; border-color: rgba(177,112,255,0.4); }
|
||||
|
||||
.myspells-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.myspells-empty {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
color: var(--t3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.planner-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.plan-sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
389
js/analysis.js
@ -1,18 +1,58 @@
|
||||
(function () {
|
||||
const { MITIG_ICONS, JOB_ABBR, ABILITY_JOBS, JOB_ROLE } = window.FF14_DATA;
|
||||
const MITIG_ICONS = {
|
||||
// DR buffs
|
||||
'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png',
|
||||
'Dark Missionary': 'assets/icons/mitigation/dark-missionary.png',
|
||||
'Heart of Light': 'assets/icons/mitigation/heart-of-light.png',
|
||||
'Temperance': 'assets/icons/mitigation/temperance.png',
|
||||
'Sacred Soil': 'assets/icons/mitigation/sacred-soil.png',
|
||||
'Expedient': 'assets/icons/mitigation/expedient.png',
|
||||
'Fey Illumination': 'assets/icons/mitigation/fey-illumination.png',
|
||||
'Collective Unconscious': 'assets/icons/mitigation/collective-unconscious.png',
|
||||
'Holos': 'assets/icons/mitigation/holos.png',
|
||||
'Kerachole': 'assets/icons/mitigation/kerachole.png',
|
||||
'Troubadour': 'assets/icons/mitigation/troubadour.png',
|
||||
'Tactician': 'assets/icons/mitigation/tactician.png',
|
||||
'Shield Samba': 'assets/icons/mitigation/shield-samba.png',
|
||||
'Magick Barrier': 'assets/icons/mitigation/magick-barrier.png',
|
||||
// Debuffs
|
||||
'Reprisal': 'assets/icons/mitigation/reprisal.png',
|
||||
'Feint': 'assets/icons/mitigation/feint.png',
|
||||
'Addle': 'assets/icons/mitigation/addle.png',
|
||||
// Shields
|
||||
'Divine Veil': 'assets/icons/mitigation/divine-veil.png',
|
||||
'Guardian': 'assets/icons/mitigation/guardian.png',
|
||||
'Shake It Off': 'assets/icons/mitigation/shake-it-off.png',
|
||||
'Bloodwhetting': 'assets/icons/mitigation/bloodwhetting.png',
|
||||
'Divine Benison': 'assets/icons/mitigation/divine-benison.png',
|
||||
'Divine Caress': 'assets/icons/mitigation/divine-caress.png',
|
||||
'Intersection': 'assets/icons/mitigation/intersection.png',
|
||||
'Neutral Sect': 'assets/icons/mitigation/neutral-sect.png',
|
||||
'the Spire': 'assets/icons/mitigation/the-spire.png',
|
||||
'Panhaima': 'assets/icons/mitigation/panhaima.png',
|
||||
'Holosakos': 'assets/icons/mitigation/holos.png',
|
||||
'Eukrasian Prognosis': 'assets/icons/mitigation/eukrasian-prognosis.png',
|
||||
'Eukrasian Prognosis II': 'assets/icons/mitigation/eukrasian-prognosis-ii.png',
|
||||
'Eukrasian Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
||||
'Differential Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
||||
'Haima': 'assets/icons/mitigation/haima.png',
|
||||
'Galvanize': 'assets/icons/mitigation/galvanize.png',
|
||||
'Seraphic Veil': 'assets/icons/mitigation/seraphic-veil.png',
|
||||
'Radiant Aegis': 'assets/icons/mitigation/radiant-aegis.png',
|
||||
'Tempera Coat': 'assets/icons/mitigation/tempera-coat.png',
|
||||
'Tempera Grassa': 'assets/icons/mitigation/tempera-grassa.png',
|
||||
'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png',
|
||||
};
|
||||
|
||||
// Deduplicated list of all mitigations across all targets of a ref event
|
||||
function collectRefMitigs(refEvent) {
|
||||
if (!refEvent) return [];
|
||||
const seen = new Set(), result = [];
|
||||
for (const t of refEvent.targets ?? []) {
|
||||
for (const m of (t.mitigations ?? [])) {
|
||||
const k = m.key ?? m.name;
|
||||
if (!seen.has(k)) { seen.add(k); result.push(m); }
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const JOB_ABBR = {
|
||||
'Paladin': 'PLD', 'Warrior': 'WAR', 'DarkKnight': 'DRK', 'Gunbreaker': 'GNB',
|
||||
'WhiteMage': 'WHM', 'Scholar': 'SCH', 'Astrologian': 'AST', 'Sage': 'SGE',
|
||||
'Monk': 'MNK', 'Dragoon': 'DRG', 'Ninja': 'NIN', 'Samurai': 'SAM',
|
||||
'Reaper': 'RPR', 'Viper': 'VPR',
|
||||
'Bard': 'BRD', 'Machinist': 'MCH', 'Dancer': 'DNC',
|
||||
'BlackMage': 'BLM', 'Summoner': 'SMN', 'RedMage': 'RDM',
|
||||
'Pictomancer': 'PCT', 'BlueMage': 'BLU',
|
||||
};
|
||||
|
||||
function abbr(type) {
|
||||
return JOB_ABBR[type] ?? type.slice(0, 3).toUpperCase();
|
||||
@ -41,24 +81,13 @@
|
||||
return String(name ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function fightEncounterId(fight) {
|
||||
return parseInt(fight?.encounterID ?? fight?.encounterId ?? 0, 10) || 0;
|
||||
function currentFightName() {
|
||||
const fight = (window.App?.fights ?? []).find(f => f.id === window.App?.fightId);
|
||||
return normalizeFightName(fight?.name);
|
||||
}
|
||||
|
||||
function currentFight() {
|
||||
return (window.App?.fights ?? []).find(f => f.id === window.App?.fightId) ?? null;
|
||||
}
|
||||
|
||||
function isSameEncounter(fight) {
|
||||
const selectedFight = currentFight();
|
||||
const selectedEncounterId = fightEncounterId(selectedFight);
|
||||
const encounterId = fightEncounterId(fight);
|
||||
|
||||
if (selectedEncounterId && encounterId) {
|
||||
return encounterId === selectedEncounterId;
|
||||
}
|
||||
|
||||
const name = normalizeFightName(selectedFight?.name);
|
||||
function isSameFightName(fight) {
|
||||
const name = currentFightName();
|
||||
return name !== '' && normalizeFightName(fight?.name) === name;
|
||||
}
|
||||
|
||||
@ -75,28 +104,6 @@
|
||||
let extFights = [];
|
||||
let extReportCode = '';
|
||||
let mitigationNames = {};
|
||||
let planRefId = '';
|
||||
let actionIconPromise = null;
|
||||
const actionIconsByName = {};
|
||||
|
||||
function mitigationIcon(m) {
|
||||
return MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name] ?? actionIconsByName[m.key] ?? actionIconsByName[m.name] ?? '';
|
||||
}
|
||||
|
||||
async function ensureActionIconCache() {
|
||||
if (actionIconPromise) return actionIconPromise;
|
||||
actionIconPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch('assets/jsons/Action.json', { cache: 'no-cache' });
|
||||
if (!res.ok) return;
|
||||
const actions = await res.json();
|
||||
for (const action of Object.values(actions ?? {})) {
|
||||
if (action?.names?.en && action?.icon) actionIconsByName[action.names.en] = action.icon;
|
||||
}
|
||||
} catch { }
|
||||
})();
|
||||
return actionIconPromise;
|
||||
}
|
||||
|
||||
// ── Player grid ──────────────────────────────────────────────────────────
|
||||
|
||||
@ -237,11 +244,8 @@
|
||||
const fight = (window.App?.fights ?? []).find(f => f.id === refId);
|
||||
if (!fight) return;
|
||||
|
||||
// Clear ext-report and plan selections
|
||||
// Clear ext-report selection
|
||||
refExtFightSelect.value = '';
|
||||
planRefId = '';
|
||||
refPlanSelect.value = '';
|
||||
refPlanPanel.style.display = 'none';
|
||||
|
||||
refFightSelect.disabled = true;
|
||||
try {
|
||||
@ -260,7 +264,7 @@
|
||||
if (!json.error && !json.reauth) {
|
||||
refEvents = json.aoe_events ?? [];
|
||||
refFightStart = json.fight_start ?? fight.startTime;
|
||||
refPlayers = json.players ?? [];
|
||||
refPlayers = [];
|
||||
window.App.setUrlState?.({
|
||||
compareReportCode: '',
|
||||
compareFightId: refId,
|
||||
@ -276,7 +280,7 @@
|
||||
let allSameReportFights = [];
|
||||
|
||||
function populateRefFightSelect() {
|
||||
const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameEncounter(f));
|
||||
const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameFightName(f));
|
||||
refFightSelect.innerHTML = '<option value="">Kein Vergleich</option>';
|
||||
visible.forEach(f => {
|
||||
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
|
||||
@ -345,7 +349,7 @@
|
||||
extReportCode = code;
|
||||
updateRefFflogsLink();
|
||||
|
||||
const visibleExt = fights.filter(isSameEncounter);
|
||||
const visibleExt = fights.filter(isSameFightName);
|
||||
refExtFightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
|
||||
visibleExt.forEach(f => {
|
||||
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
|
||||
@ -385,11 +389,8 @@
|
||||
const fight = extFights.find(f => f.id === refId);
|
||||
if (!fight) return;
|
||||
|
||||
// Clear same-report and plan selections
|
||||
// Clear same-report selection
|
||||
refFightSelect.value = '';
|
||||
planRefId = '';
|
||||
refPlanSelect.value = '';
|
||||
refPlanPanel.style.display = 'none';
|
||||
|
||||
refExtFightSelect.disabled = true;
|
||||
try {
|
||||
@ -409,7 +410,6 @@
|
||||
refEvents = json.aoe_events ?? [];
|
||||
refFightStart = json.fight_start ?? fight.startTime;
|
||||
refPlayers = json.players ?? [];
|
||||
await ensureActionIconCache();
|
||||
window.App.setUrlState?.({
|
||||
compareReportCode: extReportCode,
|
||||
compareFightId: refId,
|
||||
@ -428,116 +428,6 @@
|
||||
await loadExternalCompare(refId);
|
||||
});
|
||||
|
||||
// ── Plan as reference ─────────────────────────────────────────────────────
|
||||
|
||||
const refPlanToggle = document.getElementById('ref-plan-toggle');
|
||||
const refPlanPanel = document.getElementById('ref-plan-panel');
|
||||
const refPlanSelect = document.getElementById('ref-plan-select');
|
||||
|
||||
function loadPlansForRef() {
|
||||
try { return JSON.parse(localStorage.getItem('ff14-planner-plans') || '[]'); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
function planToRefEvents(plan) {
|
||||
const roster = plan.playerRoster ?? [];
|
||||
const jobComp = plan.jobComposition ?? [];
|
||||
const fightStart = plan.source?.fightStart ?? 0;
|
||||
const mitigNames = plan.mitigationNames ?? {};
|
||||
|
||||
const players = jobComp.map((job, i) => ({
|
||||
job,
|
||||
name: roster[i]?.name ?? '',
|
||||
role: JOB_ROLE[job] ?? 'dps',
|
||||
})).filter(p => p.name && p.job);
|
||||
|
||||
return plan.mechanics.map(m => {
|
||||
const mitigations = (m.assignments ?? []).map(a => ({
|
||||
key: a.ability,
|
||||
name: a.abilityName || mitigNames[a.ability] || a.ability,
|
||||
buffType: a.buffType,
|
||||
dr: 0,
|
||||
}));
|
||||
|
||||
const targets = players.map(p => ({
|
||||
id: 0,
|
||||
name: p.name,
|
||||
type: p.job,
|
||||
role: p.role,
|
||||
amount: 0,
|
||||
absorbed: 0,
|
||||
overkill: 0,
|
||||
hp: 0,
|
||||
maxHp: 0,
|
||||
unmitigatedAmount: 0,
|
||||
mitigations,
|
||||
}));
|
||||
|
||||
return {
|
||||
abilityName: m.name,
|
||||
abilityId: m.abilityId ?? 0,
|
||||
timestamp: fightStart + m.timestamp,
|
||||
totalDamage: 0,
|
||||
targets,
|
||||
isPlanRef: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function populateRefPlanSelect() {
|
||||
const plans = loadPlansForRef();
|
||||
refPlanSelect.innerHTML = '<option value="">— Plan auswählen —</option>';
|
||||
plans.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = `${p.name} (${p.mechanics.length} Mechaniken)`;
|
||||
refPlanSelect.appendChild(opt);
|
||||
});
|
||||
refPlanSelect.value = planRefId || '';
|
||||
}
|
||||
|
||||
refPlanToggle.addEventListener('click', () => {
|
||||
const hidden = refPlanPanel.style.display === 'none';
|
||||
refPlanPanel.style.display = hidden ? '' : 'none';
|
||||
if (hidden) populateRefPlanSelect();
|
||||
});
|
||||
|
||||
refPlanSelect.addEventListener('change', () => {
|
||||
const id = refPlanSelect.value;
|
||||
|
||||
// Clear other ref sources
|
||||
refFightSelect.value = '';
|
||||
refExtFightSelect.value = '';
|
||||
updateRefFflogsLink(0);
|
||||
|
||||
if (!id) {
|
||||
planRefId = '';
|
||||
refEvents = [];
|
||||
refFightStart = 0;
|
||||
refPlayers = [];
|
||||
renderRefPlayers();
|
||||
renderTimeline(lastEvents, lastFightStart);
|
||||
return;
|
||||
}
|
||||
|
||||
const plan = loadPlansForRef().find(p => p.id === id);
|
||||
if (!plan) return;
|
||||
|
||||
planRefId = id;
|
||||
refEvents = planToRefEvents(plan);
|
||||
refFightStart = plan.source?.fightStart ?? 0;
|
||||
refPlayers = (plan.jobComposition ?? [])
|
||||
.map((job, i) => {
|
||||
const name = plan.playerRoster?.[i]?.name ?? '';
|
||||
if (!name || !job) return null;
|
||||
return { name, type: job, role: JOB_ROLE[job] ?? 'dps' };
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
renderRefPlayers();
|
||||
renderTimeline(lastEvents, lastFightStart);
|
||||
});
|
||||
|
||||
// ── Timeline rendering ────────────────────────────────────────────────────
|
||||
|
||||
function renderTimeline(events, fightStart) {
|
||||
@ -551,8 +441,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFightJobSet = new Set(currentPlayers.map(p => JOB_ABBR[p.type]).filter(Boolean));
|
||||
|
||||
// Build reference index: abilityName → [events in order]
|
||||
const refIndex = {};
|
||||
for (const ev of refEvents) {
|
||||
@ -567,11 +455,6 @@
|
||||
const occ = abilityOccurrence[ev.abilityName] ?? 0;
|
||||
abilityOccurrence[ev.abilityName] = occ + 1;
|
||||
const refEv = refEvents.length ? (refIndex[ev.abilityName]?.[occ] ?? null) : null;
|
||||
const allRefMitigs = collectRefMitigs(refEv);
|
||||
const currentEventMitigKeys = new Set();
|
||||
for (const t of ev.targets) {
|
||||
for (const m of (t.mitigations ?? [])) currentEventMitigKeys.add(m.key ?? m.name);
|
||||
}
|
||||
|
||||
const visibleTargets = ev.targets.filter(t =>
|
||||
!hiddenPlayers.has(t.id) &&
|
||||
@ -593,24 +476,18 @@
|
||||
}
|
||||
}
|
||||
const eventMissingDebuffs = refEv
|
||||
? allRefMitigs.filter(m => {
|
||||
if (m.buffType !== 'debuff' || seenDebuffKeys.has(m.key ?? m.name)) return false;
|
||||
const jobs = ABILITY_JOBS[m.key] ?? ABILITY_JOBS[m.name];
|
||||
return jobs ? jobs.some(j => currentFightJobSet.has(j)) : false;
|
||||
})
|
||||
? (refEv.targets[0]?.mitigations ?? []).filter(m => m.buffType === 'debuff' && !seenDebuffKeys.has(m.key ?? m.name))
|
||||
: [];
|
||||
const debuffIconsHtml = [
|
||||
...eventDebuffs.map(m => ({ ...m, missing: false })),
|
||||
...eventMissingDebuffs.map(m => ({ ...m, missing: true })),
|
||||
].map(m => {
|
||||
const iconSrc = mitigationIcon(m);
|
||||
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
|
||||
if (!iconSrc) return '';
|
||||
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
|
||||
const jobAbbr = m.sourcePlayerType ? (JOB_ABBR[m.sourcePlayerType] ?? '') : '';
|
||||
const label = jobAbbr ? `${jobAbbr} · ${m.name}` : m.name;
|
||||
return m.missing
|
||||
? `<img class="aoe-target-buff-icon aoe-buff-missing" src="${iconSrc}" alt="${m.name}" title="${label}${dr} fehlt (war im Referenz-Pull aktiv)">`
|
||||
: `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${label}${dr}">`;
|
||||
? `<img class="aoe-target-buff-icon aoe-buff-missing" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr} fehlt (war im Referenz-Pull aktiv)">`
|
||||
: `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
|
||||
}).join('');
|
||||
|
||||
// Current targets
|
||||
@ -630,9 +507,15 @@
|
||||
</div>`;
|
||||
})() : '';
|
||||
|
||||
const currentMitigKeys = new Set((t.mitigations ?? []).map(m => m.key ?? m.name));
|
||||
const refTarget = refEv?.targets?.find(rt => rt.name === t.name);
|
||||
const missingMitigs = refTarget
|
||||
? (refTarget.mitigations ?? []).filter(m => m.buffType === 'buff' && !currentMitigKeys.has(m.key ?? m.name))
|
||||
: [];
|
||||
|
||||
// DR buff icons (shown below player box)
|
||||
const mitigIcons = (t.mitigations ?? []).filter(m => m.buffType === 'buff').map(m => {
|
||||
const iconSrc = mitigationIcon(m);
|
||||
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
|
||||
if (!iconSrc) return '';
|
||||
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
|
||||
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
|
||||
@ -640,7 +523,13 @@
|
||||
|
||||
// Shield tooltip on absorbed value
|
||||
const activeShields = (t.mitigations ?? []).filter(m => m.buffType === 'shield');
|
||||
const shieldLines = activeShields.map(s => s.name);
|
||||
const missingShields = refTarget
|
||||
? (refTarget.mitigations ?? []).filter(m => m.buffType === 'shield' && !currentMitigKeys.has(m.key ?? m.name))
|
||||
: [];
|
||||
const shieldLines = [
|
||||
...activeShields.map(s => s.name),
|
||||
...missingShields.map(s => `[fehlt: ${s.name}]`),
|
||||
];
|
||||
const shieldTitle = shieldLines.length ? shieldLines.join('\n') : null;
|
||||
|
||||
const dead = t.hp === 0 && t.maxHp > 0;
|
||||
@ -679,31 +568,28 @@
|
||||
const refDebuffIconsHtml = refVisible.flatMap(t => (t.mitigations ?? []))
|
||||
.filter(m => m.buffType === 'debuff' && !seenRefDebuffKeys.has(m.key ?? m.name) && seenRefDebuffKeys.add(m.key ?? m.name))
|
||||
.map(m => {
|
||||
const iconSrc = mitigationIcon(m);
|
||||
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
|
||||
if (!iconSrc) return '';
|
||||
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
|
||||
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
|
||||
}).join('');
|
||||
|
||||
const isPlanRef = !!refEv.isPlanRef;
|
||||
|
||||
const refCards = refVisible.map(t => {
|
||||
const curr = currentByName[t.name];
|
||||
const diff = (!isPlanRef && curr) ? curr.amount - t.amount : 0;
|
||||
const dead = !isPlanRef && t.hp === 0 && t.maxHp > 0;
|
||||
const diff = curr ? curr.amount - t.amount : 0;
|
||||
const dead = t.hp === 0 && t.maxHp > 0;
|
||||
|
||||
const deltaHtml = diff !== 0
|
||||
? `<span class="${diff > 0 ? 'aoe-delta-worse' : 'aoe-delta-better'}">${diff > 0 ? '+' : '-'}${fmtDmg(Math.abs(diff))}</span>`
|
||||
: '';
|
||||
|
||||
const currMitigKeys = new Set((curr?.mitigations ?? []).map(m => m.key ?? m.name));
|
||||
|
||||
const refMitigIcons = (t.mitigations ?? []).filter(m => m.buffType === 'buff').map(m => {
|
||||
const iconSrc = mitigationIcon(m);
|
||||
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
|
||||
if (!iconSrc) return '';
|
||||
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
|
||||
const k = m.key ?? m.name;
|
||||
const jobs = ABILITY_JOBS[k] ?? ABILITY_JOBS[m.name];
|
||||
const currentGroupHasJob = jobs ? jobs.some(j => currentFightJobSet.has(j)) : false;
|
||||
const missing = currentGroupHasJob && !currentEventMitigKeys.has(k);
|
||||
const missing = !currMitigKeys.has(m.key ?? m.name);
|
||||
const cls = missing ? ' aoe-buff-ref-unique' : '';
|
||||
const titleSufx = missing ? ' (fehlt im aktuellen Pull)' : '';
|
||||
return `<img class="aoe-target-buff-icon${cls}" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}${titleSufx}">`;
|
||||
@ -711,19 +597,9 @@
|
||||
|
||||
const refShields = (t.mitigations ?? []).filter(m => m.buffType === 'shield');
|
||||
const refShieldTitle = refShields.length
|
||||
? refShields.map(s => {
|
||||
const k = s.key ?? s.name;
|
||||
const jobs = ABILITY_JOBS[k];
|
||||
const currentGroupHasJob = jobs ? jobs.some(j => currentFightJobSet.has(j)) : false;
|
||||
const isMissing = !isPlanRef && currentGroupHasJob && !currentEventMitigKeys.has(k);
|
||||
return isMissing ? `${s.name} [fehlt im aktuellen Pull]` : s.name;
|
||||
}).join('\n')
|
||||
? refShields.map(s => currMitigKeys.has(s.key ?? s.name) ? s.name : `${s.name} [fehlt im aktuellen Pull]`).join('\n')
|
||||
: null;
|
||||
|
||||
const absorbedHtml = isPlanRef
|
||||
? (refShields.length ? ` <span class="aoe-target-absorbed" title="${refShieldTitle ?? ''}">Schild</span>` : '')
|
||||
: (t.absorbed > 0 ? ` <span class="aoe-target-absorbed" title="${refShieldTitle ?? 'Keine erkannten Schilde'}">+${fmtDmg(t.absorbed)}</span>` : '');
|
||||
|
||||
return `
|
||||
<div class="aoe-target-wrap">
|
||||
<div class="aoe-ref-target${dead ? ' aoe-target--dead' : ''}">
|
||||
@ -732,21 +608,20 @@
|
||||
${deltaHtml}
|
||||
</div>
|
||||
<span class="aoe-target-name">${t.name}</span>
|
||||
<span class="aoe-target-dmg">${isPlanRef ? '' : fmtDmg(t.amount)}${absorbedHtml}</span>
|
||||
<span class="aoe-target-dmg">${fmtDmg(t.amount)}${t.absorbed > 0 ? ` <span class="aoe-target-absorbed" title="${refShieldTitle ?? 'Keine erkannten Schilde'}">+${fmtDmg(t.absorbed)}</span>` : ''}</span>
|
||||
</div>
|
||||
${refMitigIcons ? `<div class="aoe-target-buffs">${refMitigIcons}</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const totalDiff = ev.totalDamage - refEv.totalDamage;
|
||||
const totalDelta = (!isPlanRef && totalDiff !== 0)
|
||||
const totalDelta = totalDiff !== 0
|
||||
? `<span class="${totalDiff > 0 ? 'aoe-delta-worse' : 'aoe-delta-better'}">${totalDiff > 0 ? '+' : ''}${fmtDmg(totalDiff)}</span>`
|
||||
: '';
|
||||
const refLabel = isPlanRef ? 'PLAN' : `REF ${fmtDmg(refEv.totalDamage)} ${totalDelta}`;
|
||||
|
||||
refHtml = `
|
||||
<div class="aoe-ref-row">
|
||||
<span class="aoe-ref-label">${refLabel} ${refDebuffIconsHtml}</span>
|
||||
<span class="aoe-ref-label">REF ${fmtDmg(refEv.totalDamage)} ${totalDelta} ${refDebuffIconsHtml}</span>
|
||||
<div class="aoe-targets">${refCards}</div>
|
||||
</div>`;
|
||||
}
|
||||
@ -810,7 +685,6 @@
|
||||
setupPhases(window.App?.phases ?? []);
|
||||
renderPlayers(json.players ?? []);
|
||||
mitigationNames = json.mitigation_names ?? {};
|
||||
await ensureActionIconCache();
|
||||
renderTimeline(json.aoe_events ?? [], json.fight_start ?? fightStart);
|
||||
|
||||
document.getElementById('analysis-loading').style.display = 'none';
|
||||
@ -850,42 +724,6 @@
|
||||
mitigationNames,
|
||||
};
|
||||
},
|
||||
exportRefForPlanner() {
|
||||
const sameReportId = parseInt(refFightSelect.value, 10);
|
||||
const extId = parseInt(refExtFightSelect.value, 10);
|
||||
let fight = null, reportCode = '', fightId = 0;
|
||||
if (sameReportId) {
|
||||
fight = allSameReportFights.find(f => f.id === sameReportId);
|
||||
reportCode = window.App?.reportCode ?? '';
|
||||
fightId = sameReportId;
|
||||
} else if (extId) {
|
||||
fight = extFights.find(f => f.id === extId);
|
||||
reportCode = extReportCode;
|
||||
fightId = extId;
|
||||
}
|
||||
const transitions = fight?.phaseTransitions ?? [];
|
||||
const phases = transitions.length === 0 ? [] : [
|
||||
{ id: 0, name: 'Ganzer Fight', startTime: fight.startTime, endTime: fight.endTime },
|
||||
...transitions.map((t, i) => ({
|
||||
id: t.id,
|
||||
name: `Phase ${t.id}`,
|
||||
startTime: t.startTime,
|
||||
endTime: transitions[i + 1]?.startTime ?? fight.endTime,
|
||||
})),
|
||||
];
|
||||
return {
|
||||
aoeEvents: refEvents,
|
||||
fightStart: refFightStart,
|
||||
phases,
|
||||
players: refPlayers,
|
||||
fightName: fight?.name ?? 'Referenz-Fight',
|
||||
reportCode,
|
||||
fightId,
|
||||
fightEnd: fight?.endTime ?? 0,
|
||||
mitigationNames,
|
||||
};
|
||||
},
|
||||
hasRefExport() { return refEvents.length > 0 && !planRefId; },
|
||||
reset() {
|
||||
lastFightId = null;
|
||||
refEvents = [];
|
||||
@ -894,7 +732,6 @@
|
||||
extFights = [];
|
||||
extReportCode = '';
|
||||
mitigationNames = {};
|
||||
planRefId = '';
|
||||
document.getElementById('ref-player-section').style.display = 'none';
|
||||
refFightSelect.value = '';
|
||||
refFightSelect.style.display = 'none';
|
||||
@ -903,52 +740,12 @@
|
||||
refFflogsLink.style.display = 'none';
|
||||
refFflogsLink.href = '#';
|
||||
refExtPanel.style.display = 'none';
|
||||
refPlanPanel.style.display = 'none';
|
||||
refPlanSelect.value = '';
|
||||
const exportBtn = document.getElementById('export-to-planner-btn');
|
||||
if (exportBtn) exportBtn.style.display = 'none';
|
||||
},
|
||||
};
|
||||
|
||||
document.getElementById('export-to-planner-btn')?.addEventListener('click', (e) => {
|
||||
if (!refEvents.length) {
|
||||
document.getElementById('export-to-planner-btn')?.addEventListener('click', () => {
|
||||
window.plannerTab?.showImportModal(window.analysisTab.exportForPlanner());
|
||||
return;
|
||||
}
|
||||
showExportChoiceMenu(e.currentTarget);
|
||||
});
|
||||
|
||||
function showExportChoiceMenu(anchor) {
|
||||
document.getElementById('export-choice-menu')?.remove();
|
||||
const menu = document.createElement('div');
|
||||
menu.id = 'export-choice-menu';
|
||||
menu.className = 'export-choice-menu';
|
||||
|
||||
[
|
||||
{ label: 'Aktueller Fight', fn: () => window.analysisTab.exportForPlanner() },
|
||||
{ label: 'Referenz-Fight', fn: () => window.analysisTab.exportRefForPlanner() },
|
||||
].forEach(({ label, fn }) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'export-choice-item';
|
||||
btn.textContent = label;
|
||||
btn.addEventListener('click', () => {
|
||||
menu.remove();
|
||||
window.plannerTab?.showImportModal(fn());
|
||||
});
|
||||
menu.appendChild(btn);
|
||||
});
|
||||
|
||||
document.body.appendChild(menu);
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
menu.style.top = (rect.bottom + 4) + 'px';
|
||||
menu.style.right = (window.innerWidth - rect.right) + 'px';
|
||||
|
||||
const close = (ev) => {
|
||||
if (!menu.contains(ev.target) && ev.target !== anchor) {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
||||
}
|
||||
})();
|
||||
|
||||
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,
|
||||
};
|
||||
})();
|
||||
2094
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; ?>
|
||||
</p>
|
||||
|
||||
<a href="<?= htmlspecialchars(auth_start_href(), ENT_QUOTES) ?>" class="btn btn-gold btn-login">
|
||||
<a href="auth/start.php" class="btn btn-gold btn-login">
|
||||
<?= $tokenExpired ? 'Reconnect to FFLogs' : 'Connect to FFLogs' ?>
|
||||
</a>
|
||||
|
||||
|
||||
@ -80,35 +80,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name Import Modal (Planner) -->
|
||||
<div id="planner-name-import-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-box">
|
||||
<div class="modal-title">Namen importieren</div>
|
||||
|
||||
<div class="modal-section">
|
||||
<div class="modal-label">Report-Code</div>
|
||||
<div class="name-import-input-row">
|
||||
<input type="text" id="name-import-report-input" placeholder="Report-Code oder URL…">
|
||||
<button id="name-import-load-btn" class="btn btn-sm">Laden</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-section" id="name-import-fight-section" style="display:none">
|
||||
<div class="modal-label">Fight</div>
|
||||
<select id="name-import-fight-select">
|
||||
<option value="">— Fight auswählen —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="name-import-preview" class="name-import-preview" style="display:none"></div>
|
||||
|
||||
<div class="modal-actions" style="margin-top:16px">
|
||||
<button id="name-import-confirm-btn" class="btn btn-gold" style="display:none">Übernehmen</button>
|
||||
<button id="name-import-cancel-btn" class="btn">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ability Assignment Modal -->
|
||||
<div id="planner-ability-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-box ability-modal-box">
|
||||
@ -122,7 +93,6 @@
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
<script src="js/tabs.js"></script>
|
||||
<script src="js/ffxiv-data.js"></script>
|
||||
<script src="js/analysis.js"></script>
|
||||
<script src="js/planner.js"></script>
|
||||
<?php endif; ?>
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-gold" type="submit" style="align-self:flex-end">Fetch</button>
|
||||
<a class="btn" href="<?= htmlspecialchars(auth_start_href(), ENT_QUOTES) ?>" style="align-self:flex-end;text-decoration:none">Reconnect</a>
|
||||
<a class="btn" href="auth/start.php" style="align-self:flex-end;text-decoration:none">Reconnect</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -22,15 +22,6 @@
|
||||
<div class="ref-player-label">REF Spieler</div>
|
||||
<div id="ref-player-grid" class="player-grid"></div>
|
||||
</div>
|
||||
<div class="ref-ext-row">
|
||||
<button id="ref-plan-toggle" class="btn btn-sm">+ Plan als Referenz</button>
|
||||
<div id="ref-plan-panel" style="display:none">
|
||||
<select id="ref-plan-select" class="filter-input">
|
||||
<option value="">— Plan auswählen —</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ref-ext-row">
|
||||
<button id="ref-ext-toggle" class="btn btn-sm">+ Anderer Report</button>
|
||||
<div id="ref-ext-panel" style="display:none">
|
||||
|
||||
@ -2,9 +2,6 @@
|
||||
|
||||
<!-- Left: Plan list sidebar -->
|
||||
<div class="plan-sidebar">
|
||||
|
||||
<div id="planner-info-panel" class="planner-info-panel"></div>
|
||||
|
||||
<div class="plan-sidebar-header">
|
||||
<div class="card-title">Pläne</div>
|
||||
<button id="planner-new-folder-btn" class="btn btn-sm" title="Neuer Ordner">+ Ordner</button>
|
||||
@ -29,6 +26,7 @@
|
||||
|
||||
<div id="plan-list"></div>
|
||||
|
||||
<div id="planner-info-panel" class="planner-info-panel"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Plan detail -->
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<nav class="tabs">
|
||||
<button class="tab active" data-tab="report">⚔ Report</button>
|
||||
<button class="tab" data-tab="analysis">⚖ Analyse</button>
|
||||
<button class="tab" data-tab="planner">☰ Planer</button>
|
||||
<button class="tab" data-tab="planner">📋 Planer</button>
|
||||
</nav>
|
||||
<div class="topbar-user">Token gültig bis: <?= date('Y-m-d H:i:s', $_SESSION['token_expires']) ?></div>
|
||||
</header>
|
||||
|
||||