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
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/
@ -108,7 +109,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, 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').
@ -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`)
- 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
@ -243,6 +252,11 @@ 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",
@ -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.
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.
@ -289,37 +306,35 @@ 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 |
| 4 | ✅ | **Jobaufstellung** — 8 Slots mit Job-Dropdown + Rollenfärbung + Namen+Job-Import |
| 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 |
### 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:**
- 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
### Gantt-Chart (implementiert)
**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
**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
**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
**Noch offen:**
- Konflikte visuell hervorheben wenn Balken eine Mechaniken-Linie überlappt
**Recast-Daten:** `data/recast-times.json` — noch zu befüllen. Enthält Cooldown-Dauer (s) und Aktiv-Dauer (s) pro Ability:
```json
@ -345,7 +360,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, Haima, Addle |
| SGE | Kerachole, Holos, Holosakos, Panhaima, Eukrasian Prognosis, Eukrasian Diagnosis, Differential Diagnosis, Haima |
| BRD | Troubadour |
| MCH | Tactician |
| DNC | Shield Samba, Improvised Finish |
@ -391,7 +406,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
- **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
- **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.

View File

@ -285,6 +285,16 @@
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;
@ -568,6 +578,38 @@
.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; }
@ -763,6 +805,33 @@
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 {
position: absolute;
top: 8px;
@ -806,16 +875,7 @@
overflow: hidden;
}
.timeline-mitigation-active {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: var(--active-width);
background: currentColor;
opacity: 0.22;
pointer-events: none;
}
.timeline-mitigation-active { display: none; }
.timeline-mitigation:active { cursor: grabbing; }
.timeline-mitigation.selected {
@ -849,9 +909,21 @@
z-index: 0;
}
.timeline-mitigation--buff { border-color: rgba(200,168,75,.5); color: var(--gold); background: rgba(200,168,75,.12); }
.timeline-mitigation--debuff { border-color: rgba(224,92,92,.5); color: var(--red); background: rgba(224,92,92,.12); }
.timeline-mitigation--shield { border-color: rgba(74,158,255,.5); color: var(--blue); background: rgba(74,158,255,.12); }
.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-axis {
min-height: 28px;

View File

@ -424,6 +424,46 @@ function renderPlanDetail(plan) {
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) {
const mechanics = visiblePlanMechanics(plan);
if (mechanics.length === 0) {
@ -439,9 +479,11 @@ function renderMechanicListHtml(plan) {
}
const activeJobSet = new Set(plan.jobComposition.filter(j => j));
const avgHp = avgNonTankMaxHp(plan);
return mechanics.map(m => {
const sorted = sortedAssignments(m.assignments);
const effective = effectiveAssignmentsForMechanic(plan, m);
const sorted = sortedAssignments(effective);
const assignHtml = sorted.length === 0
? '<span class="mechanic-no-assign">Keine Zuweisung</span>'
: 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}"` : ''}>
${icon ? `<img class="badge-icon" src="${escHtml(icon)}" alt="">` : ''}
${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>`;
const hintHtml = suggestions.map(s =>
`<span class="badge-equiv-hint">→ ${escHtml(s.ability)} (${escHtml(s.job)})?</span>`
@ -471,6 +513,14 @@ function renderMechanicListHtml(plan) {
: badgeHtml;
}).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 `
<div class="mechanic-card" data-mechanic-id="${escHtml(m.id)}">
<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>` : ''}
<div class="mechanic-name">${escHtml(m.name)}</div>
${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>
${m.notes ? `<div class="mechanic-notes">${escHtml(m.notes)}</div>` : ''}
<div class="mechanic-edit-hint">Klicken zum Bearbeiten</div>
@ -549,12 +603,18 @@ function timelineScale(plan) {
function timelinePlayerRows(plan) {
const roster = plan.playerRoster ?? [];
return (plan.jobComposition ?? []).map((job, idx) => ({
idx,
job,
name: roster[idx]?.name ?? '',
role: JOB_ROLE[job] ?? '',
})).filter(row => row.job);
const rows = [];
(plan.jobComposition ?? []).forEach((job, idx) => {
if (!job) return;
const name = roster[idx]?.name ?? '';
const role = JOB_ROLE[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) {
@ -570,14 +630,44 @@ function jobCanUseAbility(job, ability) {
function timelineRowsForAssignment(rows, assignment) {
const assignedJob = assignment.job ?? '';
if (assignedJob) return rows.filter(row => row.job === assignedJob);
return rows.filter(row => jobCanUseAbility(row.job, assignment.ability));
return rows.filter(row => {
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) {
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) {
const mechanics = visiblePlanMechanics(plan);
if (!mechanics.length) return null;
@ -672,11 +762,17 @@ function renderTimelineHtml(plan) {
const playerRows = rows.map(row => {
const blocks = [];
for (const m of mechanics) {
for (const a of sortedAssignments(m.assignments ?? [])) {
if (!timelineRowsForAssignment(rows, a).some(target => target.job === row.job)) continue;
const start = Number.isFinite(Number(a.timestamp)) ? Number(a.timestamp) : m.timestamp;
const durationSec = assignmentDurationSeconds(a);
const rowAssignments = canonicalAssignmentActivations(plan).filter(entry => {
const assignment = entry.assignment;
if (assignment.ability !== row.ability) return false;
const assignedJob = assignment.job ?? '';
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 left = Math.max(0, Math.min(100, (start / 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'
: 'timeline-mitigation--buff';
const icon = MITIG_ICONS[a.ability] ?? '';
const ability = assignmentAbilityName(a, plan);
const abilityLabel = assignmentAbilityName(a, plan);
blocks.push(`
<button class="timeline-mitigation ${cls}${selected}${unresolved}"
draggable="true"
@ -696,19 +792,24 @@ function renderTimelineHtml(plan) {
data-mechanic-id="${escHtml(m.id)}"
data-ability="${escHtml(a.ability)}"
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>
${icon ? `<img src="${escHtml(icon)}" alt="">` : ''}
<span>${escHtml(ability)}</span>
<span>${escHtml(abilityLabel)}</span>
</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 `
<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">
<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 class="timeline-track" style="width:${width}px">${hitLines}${blocks.join('')}</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 === 'cooldownSeconds') assignment.cooldownSeconds = Math.max(0, Math.round(Number(value)));
updatePlan(planId, { mechanics: plan.mechanics });
refreshTimeline(planId);
refreshMechanicList(planId);
}
function setTimelineAssignmentJob(planId, mechanicId, ability, job, nextJob) {
@ -944,14 +1045,11 @@ function initTimeline(planId) {
const timestamp = timelineTimestampFromEvent(plan, track, e);
const mechanic = findNearestMechanic(plan, timestamp);
if (!mechanic) return;
const items = (JOB_ABILITIES[row.dataset.job] ?? [])
.filter(ab => !assignmentOverlapsJob(plan, row.dataset.job, ab.name, timestamp))
.map(ab => ({
label: `${plan.mitigationNames?.[ab.name] ?? ab.name} · ${fmtTimestamp(timestamp)}`,
icon: MITIG_ICONS[ab.name] ?? '',
onClick: () => addTimelineAssignment(planId, mechanic.id, ab.name, row.dataset.job, ab.buffType, timestamp),
}));
showTimelineMenu(e.clientX, e.clientY, items);
const rowAbility = row.dataset.ability;
const rowJob = row.dataset.job;
const ab = (JOB_ABILITIES[rowJob] ?? []).find(a => a.name === rowAbility);
if (!ab || assignmentOverlapsJob(plan, rowJob, rowAbility, timestamp)) return;
addTimelineAssignment(planId, mechanic.id, rowAbility, rowJob, ab.buffType, timestamp);
});
timeline.addEventListener('contextmenu', e => {
@ -990,6 +1088,7 @@ function initTimeline(planId) {
const rect = track.getBoundingClientRect();
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
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);
});
@ -1060,15 +1159,39 @@ function renderInfoPanel(plan) {
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);
if (!plan) return;
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
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 });
refreshMechanicList(planId);
if (abilityModalMechanicId === mechanicId) renderAbilityModalContent();
@ -1090,7 +1213,7 @@ function initMechanicClicks(planId) {
const removeBtn = e.target.closest('.badge-remove');
if (removeBtn) {
e.stopPropagation();
removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability);
removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability, removeBtn.dataset.job ?? null);
return;
}
const deleteBtn = e.target.closest('.mechanic-delete-btn');
@ -1109,7 +1232,7 @@ function initMechanicClicks(planId) {
e.preventDefault();
e.stopPropagation();
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',
'Kerachole': 'SGE', 'Holos': 'SGE', 'Holosakos': 'SGE',
'Panhaima': 'SGE', 'Haima': 'SGE',
'Eukrasian Prognosis': 'SGE', 'Eukrasian Prognosis II': 'SGE',
'Eukrasian Prognosis': 'SGE',
'Eukrasian Diagnosis': 'SGE', 'Differential Diagnosis': 'SGE',
'Troubadour': 'BRD',
'Tactician': 'MCH',
@ -1380,11 +1503,9 @@ const JOB_ABILITIES = {
{ name: 'Holosakos', buffType: 'shield' },
{ name: 'Panhaima', buffType: 'shield' },
{ name: 'Eukrasian Prognosis', buffType: 'shield' },
{ name: 'Eukrasian Prognosis II', buffType: 'shield' },
{ name: 'Eukrasian Diagnosis', buffType: 'shield' },
{ name: 'Differential Diagnosis', buffType: 'shield' },
{ name: 'Haima', buffType: 'shield' },
{ name: 'Addle', buffType: 'debuff' },
],
'BRD': [{ name: 'Troubadour', buffType: 'buff' }],
'MCH': [{ name: 'Tactician', buffType: 'buff' }],
@ -1456,6 +1577,27 @@ const MITIG_ICONS = {
'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.
// Used to suggest replacements when a job is missing from the composition.
const ABILITY_EQUIVALENTS = [