Compare commits

...

4 Commits

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:04:21 +02:00
18 changed files with 494 additions and 39 deletions

View File

@ -88,10 +88,12 @@ const MITIGATION_ABILITIES = [
'Dark Missionary' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001894, 'extraAbilityGameID' => 16471], 'Dark Missionary' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001894, 'extraAbilityGameID' => 16471],
'Heart of Light' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001839, 'extraAbilityGameID' => 16160], 'Heart of Light' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001839, 'extraAbilityGameID' => 16160],
'Temperance' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001873, 'extraAbilityGameID' => 16536], '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], 'Sacred Soil' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001944, 'extraAbilityGameID' => 188],
'Expedient' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002711, 'extraAbilityGameID' => 25868], // FFLogs: "Desperate Measures" 'Expedient' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002711, 'extraAbilityGameID' => 25868], // FFLogs: "Desperate Measures"
'Fey Illumination' => ['dr' => 5, 'buffType' => 'buff', 'statusId' => 1000317, 'extraAbilityGameID' => 16538], 'Fey Illumination' => ['dr' => 5, 'buffType' => 'buff', 'statusId' => 1000317, 'extraAbilityGameID' => 16538],
'Collective Unconscious' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1000849, 'extraAbilityGameID' => 3613], '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], 'Holos' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1003003, 'extraAbilityGameID' => 24310],
'Kerachole' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002618, 'extraAbilityGameID' => 24298], 'Kerachole' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002618, 'extraAbilityGameID' => 24298],
'Troubadour' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001934, 'extraAbilityGameID' => 7405], 'Troubadour' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001934, 'extraAbilityGameID' => 7405],
@ -106,12 +108,15 @@ const MITIGATION_ABILITIES = [
'Bulwark' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 22], 'Bulwark' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 22],
'Holy Sheltron' => ['dr' => 15, 'buffType' => 'buff', 'extraAbilityGameID' => 25746], 'Holy Sheltron' => ['dr' => 15, 'buffType' => 'buff', 'extraAbilityGameID' => 25746],
'Intervention' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 7382], 'Intervention' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 7382],
'Knight\'s Resolve' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002675, 'extraAbilityGameID' => 7382], // Proc von Intervention
// WAR // WAR
'Holmgang' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 43], 'Holmgang' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 43],
'Vengeance' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 44], 'Vengeance' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 44],
'Damnation' => ['dr' => 40, 'buffType' => 'buff', 'extraAbilityGameID' => 36923], 'Damnation' => ['dr' => 40, 'buffType' => 'buff', 'extraAbilityGameID' => 36923],
'Thrill of Battle' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 40], 'Thrill of Battle' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 40],
'Raw Intuition' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 3551], '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 // DRK
'Living Dead' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 3638], 'Living Dead' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 3638],
'Shadow Wall' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 3636], 'Shadow Wall' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 3636],
@ -122,10 +127,11 @@ const MITIGATION_ABILITIES = [
// GNB // GNB
'Superbolide' => ['dr' => 100, 'buffType' => 'buff', 'extraAbilityGameID' => 16152], 'Superbolide' => ['dr' => 100, 'buffType' => 'buff', 'extraAbilityGameID' => 16152],
'Nebula' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 16148], 'Nebula' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 16148],
'Great Nebula' => ['dr' => 40, 'buffType' => 'buff', 'extraAbilityGameID' => 36935], 'Great Nebula' => ['dr' => 40, 'buffType' => 'buff', 'statusId' => 1003838, 'extraAbilityGameID' => 36935],
'Camouflage' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 16140], 'Camouflage' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 16140],
'Heart of Stone' => ['dr' => 15, 'buffType' => 'buff', 'extraAbilityGameID' => 16161], 'Heart of Stone' => ['dr' => 15, 'buffType' => 'buff', 'extraAbilityGameID' => 16161],
'Heart of Corundum' => ['dr' => 15, 'buffType' => 'buff', 'extraAbilityGameID' => 25758], '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 // DPS
'Riddle of Earth' => ['dr' => 20, 'buffType' => 'buff', 'extraAbilityGameID' => 7394], 'Riddle of Earth' => ['dr' => 20, 'buffType' => 'buff', 'extraAbilityGameID' => 7394],
'Shade Shift' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 2241], 'Shade Shift' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 2241],
@ -139,6 +145,7 @@ const MITIGATION_ABILITIES = [
// WAR // WAR
'Shake It Off' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001457, 'extraAbilityGameID' => 7388], 'Shake It Off' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001457, 'extraAbilityGameID' => 7388],
'Bloodwhetting' => ['dr' => 10, 'buffType' => 'shield', 'statusId' => 1002678, 'extraAbilityGameID' => 25751], 'Bloodwhetting' => ['dr' => 10, 'buffType' => 'shield', 'statusId' => 1002678, 'extraAbilityGameID' => 25751],
'Stem the Tide' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002680, 'extraAbilityGameID' => 25751], // Proc von Bloodwhetting / Nascent Flash
// WHM // WHM
'Divine Benison' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001218, 'extraAbilityGameID' => 7432], 'Divine Benison' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001218, 'extraAbilityGameID' => 7432],
'Divine Caress' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003903, 'extraAbilityGameID' => 37011], 'Divine Caress' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003903, 'extraAbilityGameID' => 37011],
@ -171,7 +178,7 @@ const MITIGATION_ABILITIES = [
'Addle' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001203, 'extraAbilityGameID' => 7560], 'Addle' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001203, 'extraAbilityGameID' => 7560],
]; ];
function resolveMitigations(string $buffStr, array $mitigIdMap): array { function resolveMitigations(string $buffStr, array $mitigIdMap, array $buffSourceTimeline = [], array $players = [], float $ts = 0): array {
if ($buffStr === '') return []; if ($buffStr === '') return [];
$result = []; $result = [];
$seen = []; $seen = [];
@ -181,18 +188,33 @@ function resolveMitigations(string $buffStr, array $mitigIdMap): array {
$name = $mitigIdMap[$id]['name']; $name = $mitigIdMap[$id]['name'];
if (isset($seen[$name])) continue; if (isset($seen[$name])) continue;
$seen[$name] = true; $seen[$name] = true;
$result[] = [ $entry = [
'key' => $mitigIdMap[$id]['key'] ?? $name, 'key' => $mitigIdMap[$id]['key'] ?? $name,
'name' => $name, 'name' => $name,
'dr' => $mitigIdMap[$id]['dr'], 'dr' => $mitigIdMap[$id]['dr'],
'buffType' => $mitigIdMap[$id]['buffType'], 'buffType' => $mitigIdMap[$id]['buffType'],
'extraAbilityGameID' => $mitigIdMap[$id]['extraAbilityGameID'] ?? null, 'extraAbilityGameID' => $mitigIdMap[$id]['extraAbilityGameID'] ?? null,
]; ];
$source = findBuffSourcePlayer($buffSourceTimeline, $id, $ts, $players);
if ($source) $entry['sourcePlayerType'] = $source['type'];
$result[] = $entry;
} }
} }
return $result; return $result;
} }
// Findet den Spieler der einen Buff zum Zeitpunkt $ts gecastet hat (anhand der applybuff-Timeline).
function findBuffSourcePlayer(array $sourceTimeline, int $statusId, float $ts, array $players): ?array {
$best = null;
foreach ($sourceTimeline[$statusId] ?? [] as $entry) {
if ($entry['apply'] > $ts + 200) continue; // noch nicht aktiv
if ($entry['remove'] !== null && $entry['remove'] < $ts - 200) continue; // schon abgelaufen
if ($best === null || $entry['apply'] > $best['apply']) $best = $entry;
}
if ($best === null || empty($best['sourceId'])) return null;
return $players[$best['sourceId']] ?? null;
}
// Fallback for shields consumed by a hit: the damage event's buffs field no // Fallback for shields consumed by a hit: the damage event's buffs field no
// longer contains the shield ID (already removed), but the applybuff/removebuff // longer contains the shield ID (already removed), but the applybuff/removebuff
// timeline shows it was active just before the hit. // timeline shows it was active just before the hit.
@ -325,9 +347,10 @@ for ($page = 0; $page < 10; $page++) {
// ── 2b. Shield buff/debuff timeline ──────────────────────────────────────── // ── 2b. Shield buff/debuff timeline ────────────────────────────────────────
// Builds applybuff/removebuff intervals per target so we can detect shields // Builds applybuff/removebuff intervals per target so we can detect shields
// that were consumed by a hit (absent from the damage event's buffs snapshot). // that were consumed by a hit (absent from the damage event's buffs snapshot).
$shieldTimeline = []; // targetId → statusId → [[apply, remove|null], ...] $shieldTimeline = []; // targetId → statusId → [[apply, remove|null], ...]
$statusNames = []; // statusId → localized display name from Buffs events $buffSourceTimeline = []; // statusId → [[apply, remove|null, sourceId], ...] — wer hat den Buff gecastet?
$statusActionIds = []; // statusId → applybuff extraAbilityGameID from FFLogs $statusNames = []; // statusId → localized display name from Buffs events
$statusActionIds = []; // statusId → applybuff extraAbilityGameID from FFLogs
if (!empty($trackedStatusIds)) { if (!empty($trackedStatusIds)) {
$nextPage = $startTime; $nextPage = $startTime;
@ -372,6 +395,21 @@ if (!empty($trackedStatusIds)) {
$type = $ev['type'] ?? ''; $type = $ev['type'] ?? '';
$meta = $mitigIdMap[$abId] ?? null; $meta = $mitigIdMap[$abId] ?? null;
// Source-Tracking für alle getrackten Abilities (unabhängig von buffType)
$srcId = (int)($ev['sourceID'] ?? 0);
if ($srcId > 0 && isset($players[$srcId])) {
if ($type === 'applybuff') {
$buffSourceTimeline[$abId][] = ['apply' => $ts, 'remove' => null, 'sourceId' => $srcId];
} elseif ($type === 'removebuff') {
for ($i = count($buffSourceTimeline[$abId] ?? []) - 1; $i >= 0; $i--) {
if ($buffSourceTimeline[$abId][$i]['remove'] === null) {
$buffSourceTimeline[$abId][$i]['remove'] = $ts;
break;
}
}
}
}
if (($meta['buffType'] ?? null) !== 'shield') continue; if (($meta['buffType'] ?? null) !== 'shield') continue;
if ($type === 'applybuff') { if ($type === 'applybuff') {
@ -393,6 +431,50 @@ if (!empty($trackedStatusIds)) {
} }
} }
// ── 2c. Boss-Debuff-Source via Casts ───────────────────────────────────────
// dataType: Buffs liefert nur Events auf Spieler (Friendly). Reprisal/Feint/Addle
// werden auf den Boss (Hostile) angewendet und tauchen dort nicht auf.
// Lösung: Cast-Events der drei Abilities direkt abfragen — 1 GQL-Request, 3 Aliase.
$dbReprisalActionId = (int)(MITIGATION_ABILITIES['Reprisal']['extraAbilityGameID'] ?? 0);
$dbFeintActionId = (int)(MITIGATION_ABILITIES['Feint']['extraAbilityGameID'] ?? 0);
$dbAddleActionId = (int)(MITIGATION_ABILITIES['Addle']['extraAbilityGameID'] ?? 0);
$dbReprisalStatusId = (int)(MITIGATION_ABILITIES['Reprisal']['statusId'] ?? 0);
$dbFeintStatusId = (int)(MITIGATION_ABILITIES['Feint']['statusId'] ?? 0);
$dbAddleStatusId = (int)(MITIGATION_ABILITIES['Addle']['statusId'] ?? 0);
if ($dbReprisalActionId && $dbFeintActionId && $dbAddleActionId) {
$dbResult = fflogs_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
reprisal: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbReprisalActionId, startTime: $startTime, endTime: $endTime) { data }
feint: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbFeintActionId, startTime: $startTime, endTime: $endTime) { data }
addle: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbAddleActionId, startTime: $startTime, endTime: $endTime) { data }
}
}
}
GQL);
if (isset($dbResult['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
foreach ([
'reprisal' => ['statusId' => $dbReprisalStatusId, 'durationMs' => 10000],
'feint' => ['statusId' => $dbFeintStatusId, 'durationMs' => 10000],
'addle' => ['statusId' => $dbAddleStatusId, 'durationMs' => 10000],
] as $alias => $info) {
foreach ($dbResult['data']['reportData']['report'][$alias]['data'] ?? [] as $ev) {
if (($ev['type'] ?? '') !== 'cast') continue;
$srcId = (int)($ev['sourceID'] ?? 0);
if ($srcId <= 0 || !isset($players[$srcId]) || !$info['statusId']) continue;
$ts = (float)($ev['timestamp'] ?? 0);
$buffSourceTimeline[$info['statusId']][] = [
'apply' => $ts,
'remove' => $ts + $info['durationMs'],
'sourceId' => $srcId,
];
}
}
}
foreach ($statusNames as $statusId => $displayName) { foreach ($statusNames as $statusId => $displayName) {
if (isset($mitigIdMap[$statusId])) { if (isset($mitigIdMap[$statusId])) {
$mitigIdMap[$statusId]['name'] = $displayName; $mitigIdMap[$statusId]['name'] = $displayName;
@ -526,8 +608,8 @@ foreach ($clusters as $group) {
'overkill' => $tgt['overkill'], 'overkill' => $tgt['overkill'],
'hp' => $tgt['hp'], 'hp' => $tgt['hp'],
'maxHp' => $tgt['maxHp'], 'maxHp' => $tgt['maxHp'],
'mitigations' => (function() use ($tgt, $mitigIdMap, $shieldTimeline) { 'mitigations' => (function() use ($tgt, $mitigIdMap, $shieldTimeline, $buffSourceTimeline, $players) {
$mitigations = resolveMitigations($tgt['buffs'], $mitigIdMap); $mitigations = resolveMitigations($tgt['buffs'], $mitigIdMap, $buffSourceTimeline, $players, $tgt['ts']);
if ($tgt['absorbed'] > 0 && !empty($shieldTimeline)) { if ($tgt['absorbed'] > 0 && !empty($shieldTimeline)) {
$existing = []; $existing = [];
foreach ($mitigations as $m) { foreach ($mitigations as $m) {

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
const CACHED_LOG_DIR = __DIR__ . '/../cached_logs'; const CACHED_LOG_DIR = __DIR__ . '/../cached_logs';
const CACHED_LOG_VERSION = 'v3'; const CACHED_LOG_VERSION = 'v8';
function cache_language(string $language): string { function cache_language(string $language): string {
$language = strtolower(trim($language)); $language = strtolower(trim($language));

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -778,5 +778,41 @@
}, },
"icon": "https://xivapi.com/i/003000/003689_hr1.png", "icon": "https://xivapi.com/i/003000/003689_hr1.png",
"shield": "360% of HP restored" "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
} }
} }

View File

@ -320,9 +320,10 @@
font-size: 13px; font-size: 13px;
color: var(--t2); color: var(--t2);
} }
.badge-assign-buff { background: rgba(200,168,75,.08); border-color: rgba(200,168,75,.4); color: var(--gold); } .badge-assign-buff { background: rgba(200,168,75,.08); border-color: rgba(200,168,75,.4); color: var(--gold); }
.badge-assign-debuff { background: rgba(224,92,92,.08); border-color: rgba(224,92,92,.4); color: var(--red); } .badge-assign-debuff { background: rgba(224,92,92,.08); border-color: rgba(224,92,92,.4); color: var(--red); }
.badge-assign-shield { background: rgba(74,158,255,.08); border-color: rgba(74,158,255,.4); color: var(--blue); } .badge-assign-shield { background: rgba(74,158,255,.08); border-color: rgba(74,158,255,.4); color: var(--blue); }
.badge-assign-personal { background: rgba(177,112,255,.08); border-color: rgba(177,112,255,.4); color: #dbc7ff; }
.badge-assign--missing-job { .badge-assign--missing-job {
border-style: dashed; border-style: dashed;
@ -485,9 +486,10 @@
} }
.ability-chip:hover { background: var(--bg3); color: var(--t1); } .ability-chip:hover { background: var(--bg3); color: var(--t1); }
.ability-chip.badge-assign-buff.ability-chip--active { background: rgba(200,168,75,.18); border-color: rgba(200,168,75,.6); color: var(--gold); } .ability-chip.badge-assign-buff.ability-chip--active { background: rgba(200,168,75,.18); border-color: rgba(200,168,75,.6); color: var(--gold); }
.ability-chip.badge-assign-debuff.ability-chip--active { background: rgba(224,92,92,.18); border-color: rgba(224,92,92,.6); color: var(--red); } .ability-chip.badge-assign-debuff.ability-chip--active { background: rgba(224,92,92,.18); border-color: rgba(224,92,92,.6); color: var(--red); }
.ability-chip.badge-assign-shield.ability-chip--active { background: rgba(74,158,255,.18); border-color: rgba(74,158,255,.6); color: var(--blue); } .ability-chip.badge-assign-shield.ability-chip--active { background: rgba(74,158,255,.18); border-color: rgba(74,158,255,.6); color: var(--blue); }
.ability-chip.badge-assign-personal.ability-chip--active { background: rgba(177,112,255,.18); border-color: rgba(177,112,255,.6); color: #dbc7ff; }
.ability-chip--other-job { opacity: 0.45; } .ability-chip--other-job { opacity: 0.45; }
@ -526,9 +528,9 @@
/* ── Info Panel ──────────────────────────────────────────────────────────────── */ /* ── Info Panel ──────────────────────────────────────────────────────────────── */
.planner-info-panel { .planner-info-panel {
margin-top: 14px; padding-bottom: 16px;
padding-top: 14px; margin-bottom: 16px;
border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.info-section { margin-bottom: 12px; } .info-section { margin-bottom: 12px; }
@ -560,9 +562,10 @@
border-radius: 2px; border-radius: 2px;
flex-shrink: 0; flex-shrink: 0;
} }
.info-legend-dot--buff { background: rgba(200,168,75,.8); } .info-legend-dot--buff { background: rgba(200,168,75,.8); }
.info-legend-dot--debuff { background: var(--red); } .info-legend-dot--debuff { background: var(--red); }
.info-legend-dot--shield { background: var(--blue); } .info-legend-dot--shield { background: var(--blue); }
.info-legend-dot--personal { background: rgba(177,112,255,.9); }
.info-legend-label { .info-legend-label {
font-size: 12px; font-size: 12px;
@ -754,6 +757,8 @@
overflow-y: hidden; overflow-y: hidden;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--bg1); background: var(--bg1);
scrollbar-color: var(--border) var(--bg1);
scrollbar-width: thin;
max-width: 100%; max-width: 100%;
width: 100%; width: 100%;
cursor: grab; cursor: grab;
@ -764,6 +769,11 @@
cursor: grabbing; 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 { .timeline-grid {
width: calc(180px + var(--timeline-width)); width: calc(180px + var(--timeline-width));
display: grid; display: grid;
@ -1210,6 +1220,124 @@
font-size: 12px; 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) { @media (max-width: 980px) {
.planner-layout { .planner-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@ -605,10 +605,12 @@
].map(m => { ].map(m => {
const iconSrc = mitigationIcon(m); const iconSrc = mitigationIcon(m);
if (!iconSrc) return ''; if (!iconSrc) return '';
const dr = m.dr > 0 ? ` ${m.dr}%` : ''; const dr = m.dr > 0 ? ` ${m.dr}%` : '';
const jobAbbr = m.sourcePlayerType ? (JOB_ABBR[m.sourcePlayerType] ?? '') : '';
const label = jobAbbr ? `${jobAbbr} · ${m.name}` : m.name;
return m.missing return m.missing
? `<img class="aoe-target-buff-icon aoe-buff-missing" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr} fehlt (war im Referenz-Pull aktiv)">` ? `<img class="aoe-target-buff-icon aoe-buff-missing" src="${iconSrc}" alt="${m.name}" title="${label}${dr} fehlt (war im Referenz-Pull aktiv)">`
: `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`; : `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${label}${dr}">`;
}).join(''); }).join('');
// Current targets // Current targets

View File

@ -36,6 +36,7 @@
{ name: 'Bulwark', buffType: 'buff', extraAbilityGameID: 22, duration: 10 }, { name: 'Bulwark', buffType: 'buff', extraAbilityGameID: 22, duration: 10 },
{ name: 'Holy Sheltron', buffType: 'buff', extraAbilityGameID: 25746, duration: 8 }, { name: 'Holy Sheltron', buffType: 'buff', extraAbilityGameID: 25746, duration: 8 },
{ name: 'Intervention', buffType: 'buff', extraAbilityGameID: 7382, 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' }, { name: 'Reprisal', buffType: 'debuff' },
], ],
'WAR': [ 'WAR': [
@ -47,6 +48,9 @@
{ name: 'Thrill of Battle', buffType: 'buff', extraAbilityGameID: 40, duration: 10 }, { name: 'Thrill of Battle', buffType: 'buff', extraAbilityGameID: 40, duration: 10 },
{ name: 'Raw Intuition', buffType: 'buff', extraAbilityGameID: 3551, duration: 6 }, { name: 'Raw Intuition', buffType: 'buff', extraAbilityGameID: 3551, duration: 6 },
{ name: 'Bloodwhetting', buffType: 'shield', extraAbilityGameID: 25751, duration: 8 }, { 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' }, { name: 'Reprisal', buffType: 'debuff' },
], ],
'DRK': [ 'DRK': [
@ -69,10 +73,12 @@
{ name: 'Camouflage', buffType: 'buff', extraAbilityGameID: 16140, duration: 20 }, { name: 'Camouflage', buffType: 'buff', extraAbilityGameID: 16140, duration: 20 },
{ name: 'Heart of Stone', buffType: 'buff', extraAbilityGameID: 16161, duration: 7 }, { name: 'Heart of Stone', buffType: 'buff', extraAbilityGameID: 16161, duration: 7 },
{ name: 'Heart of Corundum', buffType: 'buff', extraAbilityGameID: 25758, duration: 8 }, { 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' }, { name: 'Reprisal', buffType: 'debuff' },
], ],
'WHM': [ 'WHM': [
{ name: 'Temperance', buffType: 'buff' }, { 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 Benison', buffType: 'shield', extraAbilityGameID: 7432, duration: 15 },
{ name: 'Divine Caress', buffType: 'shield' }, { name: 'Divine Caress', buffType: 'shield' },
], ],
@ -86,6 +92,7 @@
], ],
'AST': [ 'AST': [
{ name: 'Collective Unconscious', buffType: 'buff' }, { name: 'Collective Unconscious', buffType: 'buff' },
{ name: 'Exaltation', buffType: 'buff', extraAbilityGameID: 25873, duration: 8 }, // Personal, AST auf Ziel
{ name: 'Neutral Sect', buffType: 'shield' }, { name: 'Neutral Sect', buffType: 'shield' },
{ name: 'Intersection', buffType: 'shield', extraAbilityGameID: 16556, duration: 30 }, { name: 'Intersection', buffType: 'shield', extraAbilityGameID: 16556, duration: 30 },
{ name: 'the Spire', buffType: 'shield', extraAbilityGameID: 37025, duration: 30 }, { name: 'the Spire', buffType: 'shield', extraAbilityGameID: 37025, duration: 30 },
@ -148,19 +155,21 @@
'Passage of Arms': 'PLD', 'Divine Veil': 'PLD', 'Passage of Arms': 'PLD', 'Divine Veil': 'PLD',
'Hallowed Ground': 'PLD', 'Sentinel': 'PLD', 'Guardian': 'PLD', 'Hallowed Ground': 'PLD', 'Sentinel': 'PLD', 'Guardian': 'PLD',
'Bulwark': 'PLD', 'Holy Sheltron': 'PLD', 'Intervention': 'PLD', 'Bulwark': 'PLD', 'Holy Sheltron': 'PLD', 'Intervention': 'PLD',
'Knight\'s Resolve': 'PLD',
'Shake It Off': 'WAR', 'Holmgang': 'WAR', 'Vengeance': 'WAR', 'Shake It Off': 'WAR', 'Holmgang': 'WAR', 'Vengeance': 'WAR',
'Damnation': 'WAR', 'Thrill of Battle': 'WAR', 'Raw Intuition': 'WAR', 'Damnation': 'WAR', 'Thrill of Battle': 'WAR', 'Raw Intuition': 'WAR',
'Bloodwhetting': 'WAR', 'Bloodwhetting': 'WAR', 'Nascent Glint': 'WAR',
'Stem the Flow': 'WAR', 'Stem the Tide': 'WAR',
'Dark Missionary': 'DRK', 'Living Dead': 'DRK', 'Shadow Wall': 'DRK', 'Dark Missionary': 'DRK', 'Living Dead': 'DRK', 'Shadow Wall': 'DRK',
'Shadowed Vigil': 'DRK', 'Dark Mind': 'DRK', 'The Blackest Night': 'DRK', 'Shadowed Vigil': 'DRK', 'Dark Mind': 'DRK', 'The Blackest Night': 'DRK',
'Oblation': 'DRK', 'Oblation': 'DRK',
'Heart of Light': 'GNB', 'Superbolide': 'GNB', 'Nebula': 'GNB', 'Heart of Light': 'GNB', 'Superbolide': 'GNB', 'Nebula': 'GNB',
'Great Nebula': 'GNB', 'Camouflage': 'GNB', 'Heart of Stone': 'GNB', 'Great Nebula': 'GNB', 'Camouflage': 'GNB', 'Heart of Stone': 'GNB',
'Heart of Corundum': 'GNB', 'Heart of Corundum': 'GNB', 'Clarity of Corundum': 'GNB',
'Temperance': 'WHM', 'Divine Benison': 'WHM', 'Divine Caress': 'WHM', 'Temperance': 'WHM', 'Aquaveil': 'WHM', 'Divine Benison': 'WHM', 'Divine Caress': 'WHM',
'Sacred Soil': 'SCH', 'Expedient': 'SCH', 'Fey Illumination': 'SCH', 'Sacred Soil': 'SCH', 'Expedient': 'SCH', 'Fey Illumination': 'SCH',
'Galvanize': 'SCH', 'Seraphic Veil': 'SCH', 'Catalyze': 'SCH', 'Galvanize': 'SCH', 'Seraphic Veil': 'SCH', 'Catalyze': 'SCH',
'Collective Unconscious': 'AST', 'Neutral Sect': 'AST', 'Collective Unconscious': 'AST', 'Exaltation': 'AST', 'Neutral Sect': 'AST',
'Intersection': 'AST', 'the Spire': 'AST', 'Intersection': 'AST', 'the Spire': 'AST',
'Kerachole': 'SGE', 'Holos': 'SGE', 'Holosakos': 'SGE', 'Kerachole': 'SGE', 'Holos': 'SGE', 'Holosakos': 'SGE',
'Panhaima': 'SGE', 'Haima': 'SGE', 'Panhaima': 'SGE', 'Haima': 'SGE',
@ -219,6 +228,15 @@
'Tempera Coat': 'assets/icons/mitigation/tempera-coat.png', 'Tempera Coat': 'assets/icons/mitigation/tempera-coat.png',
'Tempera Grassa': 'assets/icons/mitigation/tempera-grassa.png', 'Tempera Grassa': 'assets/icons/mitigation/tempera-grassa.png',
'Improvised Finish': 'assets/icons/mitigation/improvised-finish.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 = { const ABILITY_DR = {
@ -245,10 +263,15 @@
'Guardian': 0.40, 'Guardian': 0.40,
'Holy Sheltron': 0.15, 'Holy Sheltron': 0.15,
'Intervention': 0.10, 'Intervention': 0.10,
'Knight\'s Resolve': 0.10,
'Aquaveil': 0.15,
'Exaltation': 0.10,
'Vengeance': 0.30, 'Vengeance': 0.30,
'Damnation': 0.40, 'Damnation': 0.40,
'Raw Intuition': 0.10, 'Raw Intuition': 0.10,
'Bloodwhetting': 0.10, 'Bloodwhetting': 0.10,
'Nascent Glint': 0.10,
'Stem the Flow': 0.10,
'Shadow Wall': 0.30, 'Shadow Wall': 0.30,
'Shadowed Vigil': 0.40, 'Shadowed Vigil': 0.40,
'Dark Mind': 0.20, 'Dark Mind': 0.20,
@ -259,6 +282,7 @@
'Camouflage': 0.10, 'Camouflage': 0.10,
'Heart of Stone': 0.15, 'Heart of Stone': 0.15,
'Heart of Corundum': 0.15, 'Heart of Corundum': 0.15,
'Clarity of Corundum': 0.15,
'Riddle of Earth': 0.20, 'Riddle of Earth': 0.20,
'Third Eye': 0.10, 'Third Eye': 0.10,
}; };

View File

@ -461,10 +461,26 @@ function renderPlanDetail(plan) {
<div class="card"> <div class="card">
<div class="card-title-row"> <div class="card-title-row">
<div class="card-title">Mechaniken</div> <div class="card-title">Mechaniken</div>
<div class="view-toggle-btns">
<button class="view-toggle-btn active" data-view="mechanics">Mechaniken</button>
<button class="view-toggle-btn" data-view="myspells"> Meine Spells</button>
</div>
</div> </div>
<div id="mechanic-list"> <div id="mechanic-list">
${renderMechanicListHtml(plan)} ${renderMechanicListHtml(plan)}
</div> </div>
<div id="myspells-panel" style="display:none">
<div class="myspells-controls">
<select id="myspells-job-select">
<option value=""> Job wählen </option>
${(plan.jobComposition ?? []).filter(Boolean).filter((j, i, a) => a.indexOf(j) === i).map(j =>
`<option value="${escHtml(j)}">${escHtml(j)}</option>`
).join('')}
</select>
<button id="myspells-copy-btn" class="btn btn-sm" title="Als Text kopieren"> Kopieren</button>
</div>
<div id="myspells-list"></div>
</div>
</div> </div>
`; `;
@ -478,10 +494,118 @@ function renderPlanDetail(plan) {
initTimelineOptions(plan.id); initTimelineOptions(plan.id);
initTimeline(plan.id); initTimeline(plan.id);
initMechanicClicks(plan.id); initMechanicClicks(plan.id);
initMySpells(plan.id);
renderInfoPanel(plan); renderInfoPanel(plan);
ensureActionMetaLoaded().then(() => refreshMechanicList(plan.id)); ensureActionMetaLoaded().then(() => refreshMechanicList(plan.id));
} }
// ── Meine Spells ─────────────────────────────────────────────────────────────
function renderMySpellsHtml(plan, job) {
if (!job) return '<div class="myspells-empty">Job auswählen um Assignments zu sehen.</div>';
const mechanics = visiblePlanMechanics(plan);
const rows = [];
for (const mechanic of mechanics) {
const mine = (mechanic.assignments ?? []).filter(a => a.job === job);
if (!mine.length) continue;
rows.push({ mechanic, assignments: mine });
}
if (!rows.length) {
return `<div class="myspells-empty">Keine Assignments für ${escHtml(job)} in diesem Plan.</div>`;
}
return rows.map(({ mechanic, assignments }) => {
const time = escHtml(fmtTimestamp(mechanic.timestamp));
const mechName = escHtml(mechanic.name);
const abilities = assignments.map(a => {
const iconSrc = abilityIcon(a.ability);
const icon = iconSrc
? `<img src="${escHtml(iconSrc)}" class="myspells-icon" alt="">`
: '';
const name = escHtml(assignmentAbilityName(a, plan));
const isPersonal = TIMELINE_PERSONAL_ABILITIES.has(a.ability);
const typeClass = a.buffType === 'debuff' ? 'myspells-type--debuff'
: a.buffType === 'shield' ? 'myspells-type--shield'
: isPersonal ? 'myspells-type--personal'
: 'myspells-type--buff';
return `<span class="myspells-ability ${typeClass}">${icon}${name}</span>`;
}).join('');
return `
<div class="myspells-row">
<span class="myspells-time">${time}</span>
<span class="myspells-mechanic">${mechName}</span>
<div class="myspells-abilities">${abilities}</div>
</div>`;
}).join('');
}
function mySpellsPlainText(plan, job) {
if (!job) return '';
const mechanics = visiblePlanMechanics(plan);
const lines = [`Meine Spells — ${job} (${plan.name})`, '─'.repeat(36)];
for (const mechanic of mechanics) {
const mine = (mechanic.assignments ?? []).filter(a => a.job === job);
if (!mine.length) continue;
const time = fmtTimestamp(mechanic.timestamp);
const names = mine.map(a => assignmentAbilityName(a, plan)).join(', ');
lines.push(`${time.padEnd(6)} ${mechanic.name.padEnd(22)}${names}`);
}
return lines.join('\n');
}
function initMySpells(planId) {
const viewBtns = document.querySelectorAll('.view-toggle-btn');
const mechList = document.getElementById('mechanic-list');
const myPanel = document.getElementById('myspells-panel');
const jobSelect = document.getElementById('myspells-job-select');
const myList = document.getElementById('myspells-list');
const copyBtn = document.getElementById('myspells-copy-btn');
if (!mechList || !myPanel || !jobSelect || !myList) return;
viewBtns.forEach(btn => {
btn.addEventListener('click', () => {
viewBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const view = btn.dataset.view;
mechList.style.display = view === 'mechanics' ? '' : 'none';
myPanel.style.display = view === 'myspells' ? '' : 'none';
if (view === 'myspells') {
const plan = loadPlans().find(p => p.id === planId);
if (plan) myList.innerHTML = renderMySpellsHtml(plan, jobSelect.value || '');
}
});
});
// Gespeicherten Job wiederherstellen
const savedJob = localStorage.getItem('ff14-planner-myspells-job') ?? '';
if (savedJob && [...jobSelect.options].some(o => o.value === savedJob)) {
jobSelect.value = savedJob;
const plan = loadPlans().find(p => p.id === planId);
if (plan) myList.innerHTML = renderMySpellsHtml(plan, savedJob);
}
jobSelect.addEventListener('change', () => {
localStorage.setItem('ff14-planner-myspells-job', jobSelect.value);
const plan = loadPlans().find(p => p.id === planId);
if (plan) myList.innerHTML = renderMySpellsHtml(plan, jobSelect.value);
});
copyBtn?.addEventListener('click', () => {
const plan = loadPlans().find(p => p.id === planId);
if (!plan) return;
const text = mySpellsPlainText(plan, jobSelect.value);
navigator.clipboard.writeText(text).then(() => {
copyBtn.textContent = '✓ Kopiert';
setTimeout(() => { copyBtn.innerHTML = '⎘ Kopieren'; }, 1800);
}).catch(() => {
copyBtn.textContent = '✗ Fehler';
setTimeout(() => { copyBtn.innerHTML = '⎘ Kopieren'; }, 1800);
});
});
}
function avgNonTankMaxHp(plan) { function avgNonTankMaxHp(plan) {
const roster = plan.playerRoster ?? []; const roster = plan.playerRoster ?? [];
const jobComp = plan.jobComposition ?? []; const jobComp = plan.jobComposition ?? [];
@ -493,10 +617,40 @@ function avgNonTankMaxHp(plan) {
return Math.round(hps.reduce((s, v) => s + v, 0) / hps.length); return Math.round(hps.reduce((s, v) => s + v, 0) / hps.length);
} }
function avgTankMaxHp(plan) {
const roster = plan.playerRoster ?? [];
const jobComp = plan.jobComposition ?? [];
const hps = jobComp
.map((job, i) => ({ job, maxHp: roster[i]?.maxHp ?? 0 }))
.filter(p => p.job && JOB_ROLE[p.job] === 'tank' && p.maxHp > 0)
.map(p => p.maxHp);
if (!hps.length) return 0;
// Tankbuster trifft einen Tank → Durchschnitt über alle gefundenen Tanks
// (1 Tank → sein MaxHP direkt; 2 Tanks → (hp1 + hp2) / 2)
return Math.round(hps.reduce((s, v) => s + v, 0) / hps.length);
}
// Ermittelt den maxHP-Wert direkt aus dem aoe_event (für präzise Tankbuster-Anzeige)
function tankMaxHpFromEvent(ev) {
if (!ev?.isHeavyTankbuster) return 0;
const tankTargets = (ev.targets ?? []).filter(t => t.role === 'tank' && (t.maxHp ?? 0) > 0);
if (tankTargets.length === 1) return tankTargets[0].maxHp;
if (tankTargets.length > 1) {
// Mehrere Tanks gleichzeitig → Durchschnitt
return Math.round(tankTargets.reduce((s, t) => s + t.maxHp, 0) / tankTargets.length);
}
// Fallback: erstes Target mit maxHp (z.B. wenn Role nicht gesetzt)
const any = (ev.targets ?? []).find(t => (t.maxHp ?? 0) > 0);
return any?.maxHp ?? 0;
}
function simulateDrMultiplier(mechanic, assignments = mechanic.assignments ?? []) { function simulateDrMultiplier(mechanic, assignments = mechanic.assignments ?? []) {
const isTankbuster = !!mechanic.isHeavyTankbuster;
let mult = 1; let mult = 1;
for (const a of assignments) { for (const a of assignments) {
if (a.buffType === 'shield') continue; if (a.buffType === 'shield') continue;
// Persönliche Mitigation nur bei Tankbustern einrechnen
if (!isTankbuster && TIMELINE_PERSONAL_ABILITIES.has(a.ability)) continue;
mult *= (1 - (ABILITY_DR[a.ability] ?? 0)); mult *= (1 - (ABILITY_DR[a.ability] ?? 0));
} }
return mult; return mult;
@ -510,6 +664,13 @@ function plannedAssignmentsForMechanic(plan, targetMechanic) {
for (const entry of canonicalAssignmentActivations(plan, { dedupeKey: canonicalMechanicKey })) { for (const entry of canonicalAssignmentActivations(plan, { dedupeKey: canonicalMechanicKey })) {
if (targetTime < entry.start - tolerance || targetTime > entry.end + tolerance) continue; if (targetTime < entry.start - tolerance || targetTime > entry.end + tolerance) continue;
// Persönliche Mitigation zeigt nur auf der Mechanik, der sie direkt zugewiesen ist
// (verhindert dass DRK-Cooldowns auf GNB-Tankbuster erscheinen und umgekehrt)
if (TIMELINE_PERSONAL_ABILITIES.has(entry.assignment.ability)
&& entry.mechanic.id !== targetMechanic.id) {
continue;
}
result.push({ result.push({
...entry.assignment, ...entry.assignment,
sourceMechanicId: entry.mechanic.id, sourceMechanicId: entry.mechanic.id,
@ -535,16 +696,22 @@ function renderMechanicListHtml(plan) {
} }
const activeJobSet = new Set(plan.jobComposition.filter(j => j)); const activeJobSet = new Set(plan.jobComposition.filter(j => j));
const avgHp = avgNonTankMaxHp(plan); const nonTankAvgHp = avgNonTankMaxHp(plan);
const tankAvgHp = avgTankMaxHp(plan);
return mechanics.map(m => { return mechanics.map(m => {
// Tankbuster: gespeicherter maxHP des getroffenen Tanks hat Vorrang (präziser als Roster-Durchschnitt)
const avgHp = m.isHeavyTankbuster
? ((m.tankMaxHp ?? 0) > 0 ? m.tankMaxHp : tankAvgHp)
: nonTankAvgHp;
const planned = plannedAssignmentsForMechanic(plan, m); const planned = plannedAssignmentsForMechanic(plan, m);
const sorted = sortedAssignments(planned); const sorted = sortedAssignments(planned);
const assignHtml = sorted.length === 0 const assignHtml = sorted.length === 0
? '<span class="mechanic-no-assign">Keine Zuweisung</span>' ? '<span class="mechanic-no-assign">Keine Zuweisung</span>'
: sorted.map(a => { : sorted.map(a => {
const cls = a.buffType === 'debuff' ? 'badge-assign-debuff' const cls = a.buffType === 'debuff' ? 'badge-assign-debuff'
: a.buffType === 'shield' ? 'badge-assign-shield' : a.buffType === 'shield' ? 'badge-assign-shield'
: TIMELINE_PERSONAL_ABILITIES.has(a.ability) ? 'badge-assign-personal'
: 'badge-assign-buff'; : 'badge-assign-buff';
const isMissing = !!a.job && !activeJobSet.has(a.job); const isMissing = !!a.job && !activeJobSet.has(a.job);
const icon = abilityIcon(a.ability); const icon = abilityIcon(a.ability);
@ -588,7 +755,7 @@ function renderMechanicListHtml(plan) {
: '' : ''
} }
${hasDrAssign || hasShield ? `<div class="mechanic-dmg mechanic-mitig-row"> ${hasDrAssign || hasShield ? `<div class="mechanic-dmg mechanic-mitig-row">
${hasDrAssign ? `<span class="mechanic-mitig-val${drOnlyCls ? ' ' + drOnlyCls : ''}">→ ${fmtNumber(drOnly)}</span> mitigiert` : ''} ${hasDrAssign ? `<span class="mechanic-mitig-val${drOnlyCls ? ' ' + drOnlyCls : ''}">→ ${fmtNumber(drOnly)}</span> nach DR` : ''}
${hasShield ? `<span class="mechanic-mitig-shield${fullCls ? ' ' + fullCls : ''}">Mitigation mit Schild ${fmtNumber(mitigFull)}</span>` : ''} ${hasShield ? `<span class="mechanic-mitig-shield${fullCls ? ' ' + fullCls : ''}">Mitigation mit Schild ${fmtNumber(mitigFull)}</span>` : ''}
</div>` : ''} </div>` : ''}
<div class="mechanic-assignments">${assignHtml}</div> <div class="mechanic-assignments">${assignHtml}</div>
@ -693,12 +860,18 @@ const TIMELINE_PERSONAL_ABILITIES = new Set([
'Bulwark', 'Bulwark',
'Holy Sheltron', 'Holy Sheltron',
'Intervention', 'Intervention',
'Knight\'s Resolve',
'Aquaveil',
'Exaltation',
'Holmgang', 'Holmgang',
'Vengeance', 'Vengeance',
'Damnation', 'Damnation',
'Thrill of Battle', 'Thrill of Battle',
'Raw Intuition', 'Raw Intuition',
'Bloodwhetting', 'Bloodwhetting',
'Nascent Glint',
'Stem the Flow',
'Stem the Tide',
'Living Dead', 'Living Dead',
'Shadow Wall', 'Shadow Wall',
'Shadowed Vigil', 'Shadowed Vigil',
@ -711,6 +884,7 @@ const TIMELINE_PERSONAL_ABILITIES = new Set([
'Camouflage', 'Camouflage',
'Heart of Stone', 'Heart of Stone',
'Heart of Corundum', 'Heart of Corundum',
'Clarity of Corundum',
'Divine Benison', 'Divine Benison',
'Intersection', 'Intersection',
'the Spire', 'the Spire',
@ -1861,6 +2035,7 @@ function renderInfoPanel(plan) {
<div class="info-legend-row"><span class="info-legend-dot info-legend-dot--buff"></span><span class="info-legend-label">Mitigation</span></div> <div class="info-legend-row"><span class="info-legend-dot info-legend-dot--buff"></span><span class="info-legend-label">Mitigation</span></div>
<div class="info-legend-row"><span class="info-legend-dot info-legend-dot--debuff"></span><span class="info-legend-label">Debuff</span></div> <div class="info-legend-row"><span class="info-legend-dot info-legend-dot--debuff"></span><span class="info-legend-label">Debuff</span></div>
<div class="info-legend-row"><span class="info-legend-dot info-legend-dot--shield"></span><span class="info-legend-label">Schild</span></div> <div class="info-legend-row"><span class="info-legend-dot info-legend-dot--shield"></span><span class="info-legend-label">Schild</span></div>
<div class="info-legend-row"><span class="info-legend-dot info-legend-dot--personal"></span><span class="info-legend-label">Personal</span></div>
</div> </div>
</div>`; </div>`;
@ -2195,7 +2370,11 @@ function sortedAssignments(assignments) {
); );
} }
function guessJob(abilityName, players) { function guessJob(abilityName, players, mitigation = null) {
// Direkte Zuordnung über sourcePlayerType aus dem applybuff-Event (zuverlässigste Quelle)
if (mitigation?.sourcePlayerType) {
return JOB_FROM_TYPE[mitigation.sourcePlayerType] ?? '';
}
if (ABILITY_JOB_MAP[abilityName]) return ABILITY_JOB_MAP[abilityName]; if (ABILITY_JOB_MAP[abilityName]) return ABILITY_JOB_MAP[abilityName];
const jobs = (players ?? []).map(p => JOB_FROM_TYPE[p.type] ?? ''); const jobs = (players ?? []).map(p => JOB_FROM_TYPE[p.type] ?? '');
if (abilityName === 'Reprisal') { if (abilityName === 'Reprisal') {
@ -2250,7 +2429,7 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga
ability: key, ability: key,
abilityName: mitigationDisplayName(m) || mitigationNames[key], abilityName: mitigationDisplayName(m) || mitigationNames[key],
actionId: m.extraAbilityGameID ?? null, actionId: m.extraAbilityGameID ?? null,
job: guessJob(key, players), job: guessJob(key, players, m),
buffType: m.buffType ?? '', buffType: m.buffType ?? '',
timestamp: Math.max(0, relTs + IMPORT_OFFSET_MS), timestamp: Math.max(0, relTs + IMPORT_OFFSET_MS),
}); });
@ -2267,6 +2446,7 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga
phase: phase?.name ?? '', phase: phase?.name ?? '',
unmitigatedDamage: avgUnmit, unmitigatedDamage: avgUnmit,
isHeavyTankbuster: !!ev.isHeavyTankbuster, isHeavyTankbuster: !!ev.isHeavyTankbuster,
tankMaxHp: tankMaxHpFromEvent(ev),
notes: '', notes: '',
assignments, assignments,
}; };
@ -2387,9 +2567,10 @@ async function refreshPlanLanguage(planId) {
})); }));
return { return {
...mechanic, ...mechanic,
name: match.abilityName ?? mechanic.name, name: match.abilityName ?? mechanic.name,
abilityId: match.abilityId ?? mechanic.abilityId, abilityId: match.abilityId ?? mechanic.abilityId,
isHeavyTankbuster: mechanic.mechanicTypeManual ? !!mechanic.isHeavyTankbuster : !!match.isHeavyTankbuster, isHeavyTankbuster: mechanic.mechanicTypeManual ? !!mechanic.isHeavyTankbuster : !!match.isHeavyTankbuster,
tankMaxHp: mechanic.mechanicTypeManual ? (mechanic.tankMaxHp ?? 0) : tankMaxHpFromEvent(match),
assignments, assignments,
}; };
}); });

View File

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

View File

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