Compare commits

..

8 Commits

Author SHA1 Message Date
Akurosia Kamo
d0f54049e6 adjustments to skill behaviour 2026-05-23 21:00:19 +02:00
xziino
4ec929ebb7 CLAUDE.md: Dokumentation auf aktuellen Stand gebracht
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:00:42 +02:00
xziino
da226d54a2 Planer: Gantt-Zeilen pro Ability + aktive Dauer im Balken sichtbar
- Eine Timeline-Zeile pro Ability statt pro Job
- Schilde ausgeblendet (außer Panhaima SGE)
- Balken zeigt aktive Dauer vs. Cooldown via Gradient
- Klick auf leere Zeile fügt Ability direkt hinzu
- Drop nur auf passende Ability-Zeile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:15:58 +02:00
xziino
cdd594e43e Planer: SGE-Abilities bereinigt (Eukrasian Prognosis II + Addle entfernt)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 16:50:43 +02:00
xziino
b27986c0f4 Planer: Schild-Input auf change-Event umgestellt (kein Fokus-Verlust beim Tippen)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 16:46:34 +02:00
xziino
e358a4e709 Planer: DR-Simulation + Schild-Eingabe in Mechanik-Cards
- ABILITY_DR-Map mit DR-Werten pro Ability (Feint = 5% magic)
- simulateDrMultiplier() berechnet multiplikativen DR-Faktor
- Mechanik-Cards zeigen mitigierten Wert (DR-only) + optional mit Schild
- SGE/SCH Schild-Feld (in k) im Info-Panel, wird pro Plan gespeichert
- Farbige Anzeige: grün = unter ∅ HP, rot = darüber

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 16:43:37 +02:00
xziino
8b00d1d2a8 Merge remote-tracking branch 'Akurosia/akus_schabernack4' 2026-05-23 12:59:51 +02:00
xziino
19922d79aa Planer: Durchschnittliche MaxHP (ohne Tanks) neben unmitigiertem Schaden anzeigen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 12:59:16 +02:00
3 changed files with 309 additions and 80 deletions

View File

@ -69,6 +69,7 @@ api/
analysis.php — POST-Endpunkt: Spieler + AoE-Events + Mitigations → JSON analysis.php — POST-Endpunkt: Spieler + AoE-Events + Mitigations → JSON
abilities.php — POST-Endpunkt: Ability- + Spielerliste für Event Explorer Dropdowns abilities.php — POST-Endpunkt: Ability- + Spielerliste für Event Explorer Dropdowns
debug-events.php — POST-Endpunkt: Raw Events für Event Explorer (mit Filterung) debug-events.php — POST-Endpunkt: Raw Events für Event Explorer (mit Filterung)
players.php — POST-Endpunkt: Spieler + maxHp aus einem beliebigen Fight (für Namen+Job-Import im Planer)
assets/ assets/
icons/mitigation/ — Lokal gespeicherte Ability-Icons (PNG, von XIVAPI) icons/mitigation/ — Lokal gespeicherte Ability-Icons (PNG, von XIVAPI)
data/ data/
@ -108,7 +109,7 @@ Getrackte party-wide Buffs + Schilde + Boss-Debuffs (definiert in `MITIGATION_AB
Drei `buffType`-Kategorien: Drei `buffType`-Kategorien:
- **`buff`**: Damage Reduction — Passage of Arms (15%), Troubadour/Tactician/Shield Samba (15%), Dark Missionary/Heart of Light/Temperance/Sacred Soil/Expedient/Collective Unconscious/Holos/Kerachole/Magick Barrier (10%), Fey Illumination (5%) - **`buff`**: Damage Reduction — Passage of Arms (15%), Troubadour/Tactician/Shield Samba (15%), Dark Missionary/Heart of Light/Temperance/Sacred Soil/Expedient/Collective Unconscious/Holos/Kerachole/Magick Barrier (10%), Fey Illumination (5%)
- **`shield`**: Barrieren — Divine Veil, Guardian, Shake It Off, Bloodwhetting, Divine Benison, Divine Caress, Intersection, Neutral Sect, the Spire, Panhaima, Holosakos, Eukrasian Prognosis, Eukrasian Diagnosis, Differential Diagnosis, Haima, Galvanize, Seraphic Veil, Catalyze, Radiant Aegis, Tempera Coat, Tempera Grassa, Improvised Finish - **`shield`**: Barrieren — Divine Veil, Guardian, Shake It Off, Bloodwhetting, Divine Benison, Divine Caress, Intersection, Neutral Sect, the Spire, Panhaima, Holosakos, Eukrasian Prognosis, Eukrasian Diagnosis, Differential Diagnosis, Haima, Galvanize, Seraphic Veil, Catalyze, Radiant Aegis, Tempera Coat, Tempera Grassa, Improvised Finish
- **`debuff`**: Boss-Debuffs — Reprisal, Feint, Addle (je 10%) - **`debuff`**: Boss-Debuffs — Reprisal (10%), Feint (10% phys / 5% magic), Addle (10%)
**Statische Status-IDs (`statusId`-Feld):** Jeder Eintrag in `MITIGATION_ABILITIES` trägt ein `statusId`-Feld (FFLogs Status-ID = XIVAPI Status row_id + 1.000.000). Diese IDs werden als Fallback in `$mitigIdMap` eingetragen wenn masterData den Eintrag nicht enthält. Löst das Pre-Pull-Problem und Name-Mismatches (z.B. FFLogs "Guardian's Will" vs. Key 'Guardian', "Desperate Measures" vs. 'Expedient'). **Statische Status-IDs (`statusId`-Feld):** Jeder Eintrag in `MITIGATION_ABILITIES` trägt ein `statusId`-Feld (FFLogs Status-ID = XIVAPI Status row_id + 1.000.000). Diese IDs werden als Fallback in `$mitigIdMap` eingetragen wenn masterData den Eintrag nicht enthält. Löst das Pre-Pull-Problem und Name-Mismatches (z.B. FFLogs "Guardian's Will" vs. Key 'Guardian', "Desperate Measures" vs. 'Expedient').
@ -160,6 +161,14 @@ Konsistentes Healer → DPS → Tank-Ordering überall: im Spieler-Grid, in jede
- Schaden-Delta pro Spieler: grün wenn besser (`aoe-delta-better`), rot wenn schlechter (`aoe-delta-worse`) - Schaden-Delta pro Spieler: grün wenn besser (`aoe-delta-better`), rot wenn schlechter (`aoe-delta-worse`)
- Gesamt-Delta + Ref-Debuff-Icons in der REF-Headerzeile (`aoe-ref-label`) - Gesamt-Delta + Ref-Debuff-Icons in der REF-Headerzeile (`aoe-ref-label`)
### Plan als Referenz (Analyse-Tab)
- Button "+ Plan als Referenz" (`#ref-plan-toggle`) öffnet Dropdown mit gespeicherten Plänen
- Bei Auswahl: Plan-Mechaniken werden zu `refEvents[]` konvertiert (`isPlanRef: true` Flag)
- `planToRefEvents(plan)`: wandelt Plan-Assignments in `refEvents` um — nur Abilities mit bekanntem `buffType`
- In `renderTimeline()`: `isPlanRef` steuert Darstellung — kein Schaden-Delta, kein Dead-State, "Schild"-Text statt absorbed-Wert, Label "PLAN" statt "REF"
- Spieler des Plans werden im Spieler-Grid angezeigt wenn sie sich vom aktuellen Fight unterscheiden (via `refPlayers`)
- Same-Report, Cross-Report und Plan-Ref schließen sich gegenseitig aus
### Cross-Report-Vergleich ### Cross-Report-Vergleich
- Button "+ Anderer Report" (`#ref-ext-toggle`) öffnet Panel mit Eingabefeld + Laden-Button - Button "+ Anderer Report" (`#ref-ext-toggle`) öffnet Panel mit Eingabefeld + Laden-Button
- `api/fight.php` wird mit dem externen Report-Code aufgerufen → Fight-Dropdown befüllt - `api/fight.php` wird mit dem externen Report-Code aufgerufen → Fight-Dropdown befüllt
@ -243,6 +252,11 @@ Raid-Cooldown-Planer: Welche Mitigation-Ability wird für welche Mechanik einges
"mitigationNames": { "Reprisal": "Vergeltung" }, "mitigationNames": { "Reprisal": "Vergeltung" },
"folderId": null, "folderId": null,
"jobComposition": ["PLD", "WAR", "WHM", "SCH", "MNK", "DRG", "BRD", "SMN"], "jobComposition": ["PLD", "WAR", "WHM", "SCH", "MNK", "DRG", "BRD", "SMN"],
"playerRoster": [
{ "name": "Xziino", "maxHp": 82340 },
{ "name": "Healer1", "maxHp": 75000 }
],
"shieldK": 30,
"mechanics": [ "mechanics": [
{ {
"id": "uuid", "id": "uuid",
@ -261,6 +275,9 @@ Raid-Cooldown-Planer: Welche Mitigation-Ability wird für welche Mechanik einges
} }
``` ```
- `playerRoster`: Array[8] mit `{name, maxHp}` — wird via "Namen + Job Import" befüllt (aus `api/players.php`)
- `shieldK`: SGE/SCH-Schildwert in k (×1000) — wird in der DR-Simulation als Flat-Abzug verwendet; im Info-Panel editierbar
Mehrere Pläne gespeichert in `localStorage` unter `ff14-planner-plans` als Array. Mehrere Pläne gespeichert in `localStorage` unter `ff14-planner-plans` als Array.
Ordner gespeichert unter `ff14-planner-folders`. Pläne referenzieren Ordner per `folderId`. Ordner gespeichert unter `ff14-planner-folders`. Pläne referenzieren Ordner per `folderId`.
Aktiver Plan per `ff14-planner-active-plan` (ID) — wird beim Tab-Öffnen wiederhergestellt. Aktiver Plan per `ff14-planner-active-plan` (ID) — wird beim Tab-Öffnen wiederhergestellt.
@ -289,37 +306,35 @@ Der Haupteinstieg ist der Analyse-Tab — der Nutzer hat die Daten bereits gelad
| 1 | ✅ | **Datenfundament** — Plan-Datenmodell + localStorage CRUD | | 1 | ✅ | **Datenfundament** — Plan-Datenmodell + localStorage CRUD |
| 2 | ✅ | **Tab-Grundgerüst** — Plan-Liste, Ordner, Mechanik-Timeline | | 2 | ✅ | **Tab-Grundgerüst** — Plan-Liste, Ordner, Mechanik-Timeline |
| 3 | ✅ | **Import aus Analyse-Tab** — Export-Button + Dialog + Merge | | 3 | ✅ | **Import aus Analyse-Tab** — Export-Button + Dialog + Merge |
| 4 | ✅ | **Jobaufstellung** — 8 Slots mit Job-Dropdown + Rollenfärbung | | 4 | ✅ | **Jobaufstellung** — 8 Slots mit Job-Dropdown + Rollenfärbung + Namen+Job-Import |
| 5 | ✅ | **Ability-Zuweisung** — Modal-Picker + Rechtsklick-Remove + Äquivalenz-Hints | | 5 | ✅ | **Ability-Zuweisung** — Modal-Picker + Rechtsklick-Remove + Äquivalenz-Hints |
| 6 | 🔜 | **Gantt-Chart** — Recast-Konflikte + DR%-Anzeige (s. unten) | | 6 | ✅ | **DR-Simulation + Gantt-Chart** — mitigierter Schaden in Mechanik-Cards, Gantt mit Ability-Zeilen |
| 7 | 🔜 | **Analyse-Overlay** — geplante vs. tatsächlich genutzte CDs | | 7 | 🔜 | **Analyse-Overlay** — geplante vs. tatsächlich genutzte CDs |
### Gantt-Chart — Design-Entscheidungen (Schritt 6) ### DR-Simulation (implementiert)
**Konzept:** Ergänzende Ansicht zur Mechaniken-Übersicht, nicht Ersatz. Umschaltbar per Toggle/Tab. - `ABILITY_DR` in `planner.js`: Map Ability-Name → DR-Wert (01). Feint = 0.05 (5% magical, konservativ).
- `simulateDrMultiplier(mechanic)`: multipliziert `∏(1 dr_i)` über alle Buff/Debuff-Assignments (keine Schilde)
- In `renderMechanicListHtml`:
- `→ XX mitigiert` (DR-only, farbig gegen ∅ HP): grün wenn ≤ avgHp, rot wenn darüber
- `Mitigation mit Schild XX` (DR + shieldK×1000, daneben): nur wenn `plan.shieldK > 0`
- `∅ XX HP` (Durchschnitt maxHp ohne Tanks): aus `avgNonTankMaxHp(plan)`, neben unmitigiertem Wert
- Quell-DR-Werte spiegeln `MITIGATION_ABILITIES[*]['dr']` aus `api/analysis.php` — beide Stellen müssen bei Änderungen synchron gehalten werden
**Layout:** ### Gantt-Chart (implementiert)
- 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
**Drag & Drop:** **Aktueller Stand:**
- Ability-Icons aus Palette auf Timeline ziehen → Icon am Startpunkt, Balken zeigt Dauer - X-Achse: Kampfzeit aus `source.fightEnd - source.fightStart`
- Freies Ziehen ohne Snapping — Timestamp wird aus X-Position berechnet - **Eine Zeile pro (Job, Ability)** — nicht pro Job. `timelinePlayerRows(plan)` expandiert `JOB_ABILITIES` pro Job.
- Bestehende Balken sind ebenfalls verschiebbar - 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
**Update-Button:** **Noch offen:**
- Gleicht Gantt-Positionen mit Mechaniken ab - Konflikte visuell hervorheben wenn Balken eine Mechaniken-Linie überlappt
- Matching-Logik: TBD (Overlap oder nächste Mechanik) — noch zu klären
- Aktualisiert Plan-Assignments entsprechend
**DR-Simulation:**
- Pro Mechanik: `simulierter Schaden = unmitigiert × ∏(1 dr_i)` für alle zugewiesenen Buffs/Debuffs
- Wird in der **Mechaniken-Übersicht** unter dem unmitigierten Schadenswert angezeigt
- Schilde werden **nicht** simuliert (Schildwerte nicht verlässlich aus Log ableitbar)
- Kein Pass/Fail-Urteil — nur der errechnete Zahlenwert, User entscheidet selbst
**Recast-Daten:** `data/recast-times.json` — noch zu befüllen. Enthält Cooldown-Dauer (s) und Aktiv-Dauer (s) pro Ability: **Recast-Daten:** `data/recast-times.json` — noch zu befüllen. Enthält Cooldown-Dauer (s) und Aktiv-Dauer (s) pro Ability:
```json ```json
@ -345,7 +360,7 @@ Jobaufstellung → verfügbare Abilities (Subset von `MITIGATION_ABILITIES`):
| WHM | Temperance, Divine Benison, Divine Caress | | WHM | Temperance, Divine Benison, Divine Caress |
| SCH | Sacred Soil, Expedient, Fey Illumination, Galvanize, Seraphic Veil, Catalyze, Addle | | SCH | Sacred Soil, Expedient, Fey Illumination, Galvanize, Seraphic Veil, Catalyze, Addle |
| AST | Collective Unconscious, Neutral Sect, Intersection, the Spire | | AST | Collective Unconscious, Neutral Sect, Intersection, the Spire |
| SGE | Kerachole, Holos, Holosakos, Panhaima, Eukrasian Prognosis, Eukrasian Diagnosis, Haima, Addle | | SGE | Kerachole, Holos, Holosakos, Panhaima, Eukrasian Prognosis, Eukrasian Diagnosis, Differential Diagnosis, Haima |
| BRD | Troubadour | | BRD | Troubadour |
| MCH | Tactician | | MCH | Tactician |
| DNC | Shield Samba, Improvised Finish | | DNC | Shield Samba, Improvised Finish |
@ -391,7 +406,7 @@ Bekannte Werte (Beispiele):
- **Persistenz:** `localStorage` — kein Backend nötig - **Persistenz:** `localStorage` — kein Backend nötig
- **IDs:** `crypto.randomUUID()` für Plan-, Mechanik- und Ordner-IDs - **IDs:** `crypto.randomUUID()` für Plan-, Mechanik- und Ordner-IDs
- **Eindeutige Namen:** `uniquePlanName()` verhindert Duplikate beim Erstellen und Importieren - **Eindeutige Namen:** `uniquePlanName()` verhindert Duplikate beim Erstellen und Importieren
- **Keine Spielernamen:** Assignments sind Job-basiert (`{ ability, job }`), damit Pläne übertragbar sind - **Spielernamen in `playerRoster`:** Assignments bleiben Job-basiert (`{ ability, job }`). Namen + maxHp werden separat in `playerRoster[8]` gespeichert — für ∅-HP-Anzeige und Analyse-Tab-Vergleich, nicht für Assignments
- **Kein Ability-Stacking:** FFXIV erlaubt keine doppelte Anwendung derselben Ability pro Mechanik - **Kein Ability-Stacking:** FFXIV erlaubt keine doppelte Anwendung derselben Ability pro Mechanik
- **Shield-Attribution:** Nicht simulierbar — `absorbed` ist Gesamtwert ohne Aufschlüsselung. Bewusst weggelassen. - **Shield-Attribution:** Nicht simulierbar — `absorbed` ist Gesamtwert ohne Aufschlüsselung. Bewusst weggelassen.
- **DR-Simulation:** Nur Buffs/Debuffs mit bekanntem `dr`-Wert aus `MITIGATION_ABILITIES`. Ergebnis als Zahlenwert, kein Pass/Fail. - **DR-Simulation:** Nur Buffs/Debuffs mit bekanntem `dr`-Wert aus `MITIGATION_ABILITIES`. Ergebnis als Zahlenwert, kein Pass/Fail.

View File

@ -285,6 +285,16 @@
font-size: 13px; font-size: 13px;
color: var(--t2); color: var(--t2);
} }
.mechanic-avg-hp {
font-size: 12px;
color: var(--t3);
margin-left: 6px;
}
.mechanic-mitig-row { margin-top: -2px; display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; }
.mechanic-mitig-shield { font-size: 12px; color: var(--t3); }
.mechanic-mitig-val { font-weight: 500; }
.mechanic-mitig--ok { color: var(--green); }
.mechanic-mitig--risk { color: var(--red); }
.mechanic-assignments { .mechanic-assignments {
display: flex; display: flex;
@ -568,6 +578,38 @@
.info-warning--job { color: var(--t2); } .info-warning--job { color: var(--t2); }
.info-warning--missing { color: var(--red); } .info-warning--missing { color: var(--red); }
.info-section--extra { margin-top: 12px; }
.info-extra-row {
display: flex;
align-items: center;
gap: 8px;
}
.info-extra-label {
font-size: 12px;
color: var(--t2);
flex: 1;
}
.info-extra-input-wrap {
display: flex;
align-items: center;
gap: 4px;
}
.info-extra-input {
width: 52px;
font-size: 13px;
padding: 3px 6px;
text-align: right;
}
.info-extra-unit {
font-size: 12px;
color: var(--t3);
}
/* ── Folder Sidebar ──────────────────────────────────────────────────────────── */ /* ── Folder Sidebar ──────────────────────────────────────────────────────────── */
.folder-section { margin-bottom: 2px; } .folder-section { margin-bottom: 2px; }
@ -763,6 +805,33 @@
white-space: nowrap; 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-boss-action { .timeline-boss-action {
position: absolute; position: absolute;
top: 8px; top: 8px;
@ -806,16 +875,7 @@
overflow: hidden; overflow: hidden;
} }
.timeline-mitigation-active { .timeline-mitigation-active { display: none; }
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: var(--active-width);
background: currentColor;
opacity: 0.22;
pointer-events: none;
}
.timeline-mitigation:active { cursor: grabbing; } .timeline-mitigation:active { cursor: grabbing; }
.timeline-mitigation.selected { .timeline-mitigation.selected {
@ -849,9 +909,21 @@
z-index: 0; z-index: 0;
} }
.timeline-mitigation--buff { border-color: rgba(200,168,75,.5); color: var(--gold); background: rgba(200,168,75,.12); } .timeline-mitigation--buff {
.timeline-mitigation--debuff { border-color: rgba(224,92,92,.5); color: var(--red); background: rgba(224,92,92,.12); } border-color: rgba(200,168,75,.5);
.timeline-mitigation--shield { border-color: rgba(74,158,255,.5); color: var(--blue); background: rgba(74,158,255,.12); } 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-axis { .timeline-axis {
min-height: 28px; min-height: 28px;

View File

@ -424,6 +424,46 @@ function renderPlanDetail(plan) {
ensureActionMetaLoaded().then(() => refreshTimeline(plan.id)); ensureActionMetaLoaded().then(() => refreshTimeline(plan.id));
} }
function avgNonTankMaxHp(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;
return Math.round(hps.reduce((s, v) => s + v, 0) / hps.length);
}
function simulateDrMultiplier(mechanic, assignments = mechanic.assignments ?? []) {
let mult = 1;
for (const a of assignments) {
if (a.buffType === 'shield') continue;
mult *= (1 - (ABILITY_DR[a.ability] ?? 0));
}
return mult;
}
function effectiveAssignmentsForMechanic(plan, targetMechanic) {
const result = [];
const seen = new Set();
for (const entry of canonicalAssignmentActivations(plan)) {
if (targetMechanic.timestamp < entry.start || targetMechanic.timestamp > entry.end) continue;
const assignment = entry.assignment;
const key = `${assignment.ability}::${assignment.job ?? ''}`;
if (seen.has(key)) continue;
seen.add(key);
result.push({
...assignment,
sourceMechanicId: entry.mechanic.id,
sourceStart: entry.start,
});
}
return result;
}
function renderMechanicListHtml(plan) { function renderMechanicListHtml(plan) {
const mechanics = visiblePlanMechanics(plan); const mechanics = visiblePlanMechanics(plan);
if (mechanics.length === 0) { if (mechanics.length === 0) {
@ -439,9 +479,11 @@ 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);
return mechanics.map(m => { return mechanics.map(m => {
const sorted = sortedAssignments(m.assignments); const effective = effectiveAssignmentsForMechanic(plan, m);
const sorted = sortedAssignments(effective);
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 => {
@ -457,7 +499,7 @@ function renderMechanicListHtml(plan) {
const badgeHtml = `<span class="badge badge-assign ${cls}${isMissing ? ' badge-assign--missing-job' : ''}"${title ? ` title="${title}"` : ''}> const badgeHtml = `<span class="badge badge-assign ${cls}${isMissing ? ' badge-assign--missing-job' : ''}"${title ? ` title="${title}"` : ''}>
${icon ? `<img class="badge-icon" src="${escHtml(icon)}" alt="">` : ''} ${icon ? `<img class="badge-icon" src="${escHtml(icon)}" alt="">` : ''}
${label} ${label}
<button class="badge-remove" data-mechanic-id="${escHtml(m.id)}" data-ability="${escHtml(a.ability)}" title="Entfernen">×</button> <button class="badge-remove" data-mechanic-id="${escHtml(a.sourceMechanicId ?? m.id)}" data-ability="${escHtml(a.ability)}" data-job="${escHtml(a.job ?? '')}" title="Entfernen">×</button>
</span>`; </span>`;
const hintHtml = suggestions.map(s => const hintHtml = suggestions.map(s =>
`<span class="badge-equiv-hint">→ ${escHtml(s.ability)} (${escHtml(s.job)})?</span>` `<span class="badge-equiv-hint">→ ${escHtml(s.ability)} (${escHtml(s.job)})?</span>`
@ -471,6 +513,14 @@ function renderMechanicListHtml(plan) {
: badgeHtml; : badgeHtml;
}).join(''); }).join('');
const drOnly = m.unmitigatedDamage ? Math.round(m.unmitigatedDamage * simulateDrMultiplier(m, effective)) : 0;
const shieldVal = (plan.shieldK ?? 0) * 1000;
const mitigFull = Math.max(0, drOnly - shieldVal);
const hasDrAssign = effective.some(a => a.buffType !== 'shield' && (ABILITY_DR[a.ability] ?? 0) > 0);
const hasShield = shieldVal > 0;
const drOnlyCls = avgHp ? (drOnly <= avgHp ? 'mechanic-mitig--ok' : 'mechanic-mitig--risk') : '';
const fullCls = avgHp ? (mitigFull <= avgHp ? 'mechanic-mitig--ok' : 'mechanic-mitig--risk') : '';
return ` return `
<div class="mechanic-card" data-mechanic-id="${escHtml(m.id)}"> <div class="mechanic-card" data-mechanic-id="${escHtml(m.id)}">
<div class="mechanic-time">${escHtml(fmtTimestamp(m.timestamp))}</div> <div class="mechanic-time">${escHtml(fmtTimestamp(m.timestamp))}</div>
@ -478,9 +528,13 @@ function renderMechanicListHtml(plan) {
${m.phase ? `<div class="mechanic-phase">${escHtml(m.phase)}</div>` : ''} ${m.phase ? `<div class="mechanic-phase">${escHtml(m.phase)}</div>` : ''}
<div class="mechanic-name">${escHtml(m.name)}</div> <div class="mechanic-name">${escHtml(m.name)}</div>
${m.unmitigatedDamage ${m.unmitigatedDamage
? `<div class="mechanic-dmg">${fmtNumber(m.unmitigatedDamage)} unmitigiert</div>` ? `<div class="mechanic-dmg">${fmtNumber(m.unmitigatedDamage)} unmitigiert${avgHp ? ` <span class="mechanic-avg-hp">∅ ${fmtNumber(avgHp)} HP</span>` : ''}</div>`
: '' : ''
} }
${hasDrAssign || hasShield ? `<div class="mechanic-dmg mechanic-mitig-row">
${hasDrAssign ? `<span class="mechanic-mitig-val${drOnlyCls ? ' ' + drOnlyCls : ''}">→ ${fmtNumber(drOnly)}</span> mitigiert` : ''}
${hasShield ? `<span class="mechanic-mitig-shield${fullCls ? ' ' + fullCls : ''}">Mitigation mit Schild ${fmtNumber(mitigFull)}</span>` : ''}
</div>` : ''}
<div class="mechanic-assignments">${assignHtml}</div> <div class="mechanic-assignments">${assignHtml}</div>
${m.notes ? `<div class="mechanic-notes">${escHtml(m.notes)}</div>` : ''} ${m.notes ? `<div class="mechanic-notes">${escHtml(m.notes)}</div>` : ''}
<div class="mechanic-edit-hint">Klicken zum Bearbeiten</div> <div class="mechanic-edit-hint">Klicken zum Bearbeiten</div>
@ -549,12 +603,18 @@ function timelineScale(plan) {
function timelinePlayerRows(plan) { function timelinePlayerRows(plan) {
const roster = plan.playerRoster ?? []; const roster = plan.playerRoster ?? [];
return (plan.jobComposition ?? []).map((job, idx) => ({ const rows = [];
idx, (plan.jobComposition ?? []).forEach((job, idx) => {
job, if (!job) return;
name: roster[idx]?.name ?? '', const name = roster[idx]?.name ?? '';
role: JOB_ROLE[job] ?? '', const role = JOB_ROLE[job] ?? '';
})).filter(row => row.job); const abilities = (JOB_ABILITIES[job] ?? [])
.filter(ab => ab.buffType !== 'shield' || ab.name === 'Panhaima');
abilities.forEach((ab, abilityIdx) => {
rows.push({ idx, job, ability: ab.name, buffType: ab.buffType, name, role, firstForJob: abilityIdx === 0 });
});
});
return rows;
} }
function selectedAssignmentMatches(mechanicId, assignment) { function selectedAssignmentMatches(mechanicId, assignment) {
@ -570,14 +630,44 @@ function jobCanUseAbility(job, ability) {
function timelineRowsForAssignment(rows, assignment) { function timelineRowsForAssignment(rows, assignment) {
const assignedJob = assignment.job ?? ''; const assignedJob = assignment.job ?? '';
if (assignedJob) return rows.filter(row => row.job === assignedJob); return rows.filter(row => {
return rows.filter(row => jobCanUseAbility(row.job, assignment.ability)); if (row.ability !== assignment.ability) return false;
if (assignedJob) return row.job === assignedJob;
return true; // unresolved: show in all rows for this ability
});
} }
function assignmentStartMs(mechanic, assignment) { function assignmentStartMs(mechanic, assignment) {
return Number.isFinite(Number(assignment?.timestamp)) ? Number(assignment.timestamp) : mechanic.timestamp; return Number.isFinite(Number(assignment?.timestamp)) ? Number(assignment.timestamp) : mechanic.timestamp;
} }
function canonicalAssignmentActivations(plan) {
const entries = [];
for (const mechanic of visiblePlanMechanics(plan)) {
for (const assignment of mechanic.assignments ?? []) {
const start = assignmentStartMs(mechanic, assignment);
const durationSec = assignmentDurationSeconds(assignment);
entries.push({
mechanic,
assignment,
start,
durationSec,
end: start + durationSec * 1000,
});
}
}
entries.sort((a, b) => a.start - b.start);
const activeUntilBySkill = new Map();
return entries.filter(entry => {
const key = `${entry.assignment.ability}::${entry.assignment.job ?? ''}`;
const activeUntil = activeUntilBySkill.get(key) ?? -Infinity;
if (entry.start < activeUntil) return false;
activeUntilBySkill.set(key, entry.end);
return true;
});
}
function findNearestMechanic(plan, timestamp) { function findNearestMechanic(plan, timestamp) {
const mechanics = visiblePlanMechanics(plan); const mechanics = visiblePlanMechanics(plan);
if (!mechanics.length) return null; if (!mechanics.length) return null;
@ -672,11 +762,17 @@ function renderTimelineHtml(plan) {
const playerRows = rows.map(row => { const playerRows = rows.map(row => {
const blocks = []; const blocks = [];
for (const m of mechanics) { const rowAssignments = canonicalAssignmentActivations(plan).filter(entry => {
for (const a of sortedAssignments(m.assignments ?? [])) { const assignment = entry.assignment;
if (!timelineRowsForAssignment(rows, a).some(target => target.job === row.job)) continue; if (assignment.ability !== row.ability) return false;
const start = Number.isFinite(Number(a.timestamp)) ? Number(a.timestamp) : m.timestamp; const assignedJob = assignment.job ?? '';
const durationSec = assignmentDurationSeconds(a); return !assignedJob || assignedJob === row.job;
});
for (const item of rowAssignments) {
const m = item.mechanic;
const a = item.assignment;
const start = item.start;
const durationSec = item.durationSec;
const cooldownSec = assignmentCooldownSeconds(a); const cooldownSec = assignmentCooldownSeconds(a);
const left = Math.max(0, Math.min(100, (start / duration) * 100)); const left = Math.max(0, Math.min(100, (start / duration) * 100));
const widthPct = Math.max(1.2, Math.min(100 - left, (durationSec * 1000 / duration) * 100)); const widthPct = Math.max(1.2, Math.min(100 - left, (durationSec * 1000 / duration) * 100));
@ -688,7 +784,7 @@ function renderTimelineHtml(plan) {
: a.buffType === 'shield' ? 'timeline-mitigation--shield' : a.buffType === 'shield' ? 'timeline-mitigation--shield'
: 'timeline-mitigation--buff'; : 'timeline-mitigation--buff';
const icon = MITIG_ICONS[a.ability] ?? ''; const icon = MITIG_ICONS[a.ability] ?? '';
const ability = assignmentAbilityName(a, plan); const abilityLabel = assignmentAbilityName(a, plan);
blocks.push(` blocks.push(`
<button class="timeline-mitigation ${cls}${selected}${unresolved}" <button class="timeline-mitigation ${cls}${selected}${unresolved}"
draggable="true" draggable="true"
@ -696,19 +792,24 @@ function renderTimelineHtml(plan) {
data-mechanic-id="${escHtml(m.id)}" data-mechanic-id="${escHtml(m.id)}"
data-ability="${escHtml(a.ability)}" data-ability="${escHtml(a.ability)}"
data-job="${escHtml(a.job ?? '')}" data-job="${escHtml(a.job ?? '')}"
title="${escHtml(ability)} · aktiv ${durationSec}s · CD ${cooldownSec}s${a.job ? '' : ' · mögliche Zuordnung'}"> title="${escHtml(abilityLabel)} · aktiv ${durationSec}s · CD ${cooldownSec}s${a.job ? '' : ' · mögliche Zuordnung'}">
<span class="timeline-mitigation-active"></span> <span class="timeline-mitigation-active"></span>
${icon ? `<img src="${escHtml(icon)}" alt="">` : ''} ${icon ? `<img src="${escHtml(icon)}" alt="">` : ''}
<span>${escHtml(ability)}</span> <span>${escHtml(abilityLabel)}</span>
</button> </button>
`); `);
} }
} const icon = MITIG_ICONS[row.ability] ?? '';
const abilityDisplayName = plan.mitigationNames?.[row.ability] ?? row.ability;
const jobStartCls = row.firstForJob ? ' timeline-player-row--job-start' : '';
return ` return `
<div class="timeline-row timeline-player-row" data-row-idx="${row.idx}" data-job="${escHtml(row.job)}"> <div class="timeline-row timeline-player-row${jobStartCls}" data-row-idx="${row.idx}" data-job="${escHtml(row.job)}" data-ability="${escHtml(row.ability)}">
<div class="timeline-row-label"> <div class="timeline-row-label">
<span class="timeline-job role-${escHtml(row.role)}">${escHtml(row.job)}</span> <span class="timeline-job role-${escHtml(row.role)}">${escHtml(row.job)}</span>
<span class="timeline-player-name">${escHtml(row.name || `Slot ${row.idx + 1}`)}</span> <span class="timeline-row-ability">
${icon ? `<img src="${escHtml(icon)}" alt="" class="timeline-row-ability-icon">` : ''}
<span class="timeline-row-ability-name">${escHtml(abilityDisplayName)}</span>
</span>
</div> </div>
<div class="timeline-track" style="width:${width}px">${hitLines}${blocks.join('')}</div> <div class="timeline-track" style="width:${width}px">${hitLines}${blocks.join('')}</div>
</div>`; </div>`;
@ -788,7 +889,7 @@ function setTimelineAssignmentField(planId, mechanicId, ability, job, field, val
if (field === 'durationSeconds') assignment.durationSeconds = Math.max(1, Math.round(Number(value))); if (field === 'durationSeconds') assignment.durationSeconds = Math.max(1, Math.round(Number(value)));
if (field === 'cooldownSeconds') assignment.cooldownSeconds = Math.max(0, Math.round(Number(value))); if (field === 'cooldownSeconds') assignment.cooldownSeconds = Math.max(0, Math.round(Number(value)));
updatePlan(planId, { mechanics: plan.mechanics }); updatePlan(planId, { mechanics: plan.mechanics });
refreshTimeline(planId); refreshMechanicList(planId);
} }
function setTimelineAssignmentJob(planId, mechanicId, ability, job, nextJob) { function setTimelineAssignmentJob(planId, mechanicId, ability, job, nextJob) {
@ -944,14 +1045,11 @@ function initTimeline(planId) {
const timestamp = timelineTimestampFromEvent(plan, track, e); const timestamp = timelineTimestampFromEvent(plan, track, e);
const mechanic = findNearestMechanic(plan, timestamp); const mechanic = findNearestMechanic(plan, timestamp);
if (!mechanic) return; if (!mechanic) return;
const items = (JOB_ABILITIES[row.dataset.job] ?? []) const rowAbility = row.dataset.ability;
.filter(ab => !assignmentOverlapsJob(plan, row.dataset.job, ab.name, timestamp)) const rowJob = row.dataset.job;
.map(ab => ({ const ab = (JOB_ABILITIES[rowJob] ?? []).find(a => a.name === rowAbility);
label: `${plan.mitigationNames?.[ab.name] ?? ab.name} · ${fmtTimestamp(timestamp)}`, if (!ab || assignmentOverlapsJob(plan, rowJob, rowAbility, timestamp)) return;
icon: MITIG_ICONS[ab.name] ?? '', addTimelineAssignment(planId, mechanic.id, rowAbility, rowJob, ab.buffType, timestamp);
onClick: () => addTimelineAssignment(planId, mechanic.id, ab.name, row.dataset.job, ab.buffType, timestamp),
}));
showTimelineMenu(e.clientX, e.clientY, items);
}); });
timeline.addEventListener('contextmenu', e => { timeline.addEventListener('contextmenu', e => {
@ -990,6 +1088,7 @@ function initTimeline(planId) {
const rect = track.getBoundingClientRect(); const rect = track.getBoundingClientRect();
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
const timestamp = (x / rect.width) * planDurationMs(plan); const timestamp = (x / rect.width) * planDurationMs(plan);
if (row.dataset.ability && data.ability !== row.dataset.ability) return;
updateTimelineAssignmentPosition(planId, data.mechanicId, data.ability, data.job, row.dataset.job, timestamp); updateTimelineAssignmentPosition(planId, data.mechanicId, data.ability, data.job, row.dataset.job, timestamp);
}); });
@ -1060,15 +1159,39 @@ function renderInfoPanel(plan) {
warningsHtml += '</div>'; warningsHtml += '</div>';
} }
el.innerHTML = legendHtml + warningsHtml; const extraInfoHtml = `
<div class="info-section info-section--extra">
<div class="info-section-title">Zusätzliche Informationen</div>
<div class="info-extra-row">
<label class="info-extra-label">SGE/SCH Schild</label>
<div class="info-extra-input-wrap">
<input type="text" inputmode="numeric" id="plan-shield-k-input" class="info-extra-input"
value="${plan.shieldK ?? ''}" placeholder="0">
<span class="info-extra-unit">k</span>
</div>
</div>
</div>`;
el.innerHTML = legendHtml + warningsHtml + extraInfoHtml;
const shieldInput = el.querySelector('#plan-shield-k-input');
if (shieldInput) {
shieldInput.addEventListener('change', () => {
const val = parseFloat(shieldInput.value) || 0;
updatePlan(plan.id, { shieldK: val > 0 ? val : null });
refreshMechanicList(plan.id);
});
}
} }
function removeAssignment(planId, mechanicId, abilityName) { function removeAssignment(planId, mechanicId, abilityName, job = null) {
const plan = getPlan(planId); const plan = getPlan(planId);
if (!plan) return; if (!plan) return;
const mechanic = plan.mechanics.find(m => m.id === mechanicId); const mechanic = plan.mechanics.find(m => m.id === mechanicId);
if (!mechanic) return; if (!mechanic) return;
mechanic.assignments = mechanic.assignments.filter(a => a.ability !== abilityName); mechanic.assignments = mechanic.assignments.filter(a =>
a.ability !== abilityName || (job !== null && (a.job ?? '') !== job)
);
updatePlan(planId, { mechanics: plan.mechanics }); updatePlan(planId, { mechanics: plan.mechanics });
refreshMechanicList(planId); refreshMechanicList(planId);
if (abilityModalMechanicId === mechanicId) renderAbilityModalContent(); if (abilityModalMechanicId === mechanicId) renderAbilityModalContent();
@ -1090,7 +1213,7 @@ function initMechanicClicks(planId) {
const removeBtn = e.target.closest('.badge-remove'); const removeBtn = e.target.closest('.badge-remove');
if (removeBtn) { if (removeBtn) {
e.stopPropagation(); e.stopPropagation();
removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability); removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability, removeBtn.dataset.job ?? null);
return; return;
} }
const deleteBtn = e.target.closest('.mechanic-delete-btn'); const deleteBtn = e.target.closest('.mechanic-delete-btn');
@ -1109,7 +1232,7 @@ function initMechanicClicks(planId) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const removeBtn = badge.querySelector('.badge-remove'); const removeBtn = badge.querySelector('.badge-remove');
if (removeBtn) removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability); if (removeBtn) removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability, removeBtn.dataset.job ?? null);
}); });
} }
@ -1295,7 +1418,7 @@ const ABILITY_JOB_MAP = {
'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',
'Eukrasian Prognosis': 'SGE', 'Eukrasian Prognosis II': 'SGE', 'Eukrasian Prognosis': 'SGE',
'Eukrasian Diagnosis': 'SGE', 'Differential Diagnosis': 'SGE', 'Eukrasian Diagnosis': 'SGE', 'Differential Diagnosis': 'SGE',
'Troubadour': 'BRD', 'Troubadour': 'BRD',
'Tactician': 'MCH', 'Tactician': 'MCH',
@ -1380,11 +1503,9 @@ const JOB_ABILITIES = {
{ name: 'Holosakos', buffType: 'shield' }, { name: 'Holosakos', buffType: 'shield' },
{ name: 'Panhaima', buffType: 'shield' }, { name: 'Panhaima', buffType: 'shield' },
{ name: 'Eukrasian Prognosis', buffType: 'shield' }, { name: 'Eukrasian Prognosis', buffType: 'shield' },
{ name: 'Eukrasian Prognosis II', buffType: 'shield' },
{ name: 'Eukrasian Diagnosis', buffType: 'shield' }, { name: 'Eukrasian Diagnosis', buffType: 'shield' },
{ name: 'Differential Diagnosis', buffType: 'shield' }, { name: 'Differential Diagnosis', buffType: 'shield' },
{ name: 'Haima', buffType: 'shield' }, { name: 'Haima', buffType: 'shield' },
{ name: 'Addle', buffType: 'debuff' },
], ],
'BRD': [{ name: 'Troubadour', buffType: 'buff' }], 'BRD': [{ name: 'Troubadour', buffType: 'buff' }],
'MCH': [{ name: 'Tactician', buffType: 'buff' }], 'MCH': [{ name: 'Tactician', buffType: 'buff' }],
@ -1456,6 +1577,27 @@ const MITIG_ICONS = {
'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png', 'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png',
}; };
// DR values (01) for buff/debuff mitigations — shields excluded (no reliable sim).
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, // 10% phys / 5% magic — use magic (conservative)
'Addle': 0.10,
};
// Groups of abilities that are functionally equivalent across different jobs. // Groups of abilities that are functionally equivalent across different jobs.
// Used to suggest replacements when a job is missing from the composition. // Used to suggest replacements when a job is missing from the composition.
const ABILITY_EQUIVALENTS = [ const ABILITY_EQUIVALENTS = [