forked from xziino/ff14-mitigator
Compare commits
No commits in common. "d0f54049e685e05a6ba7b16835d30016f7ca1458" and "61fecbc576eda5ca57633ddc921545c994060783" have entirely different histories.
d0f54049e6
...
61fecbc576
69
CLAUDE.md
69
CLAUDE.md
@ -69,7 +69,6 @@ api/
|
||||
analysis.php — POST-Endpunkt: Spieler + AoE-Events + Mitigations → JSON
|
||||
abilities.php — POST-Endpunkt: Ability- + Spielerliste für Event Explorer Dropdowns
|
||||
debug-events.php — POST-Endpunkt: Raw Events für Event Explorer (mit Filterung)
|
||||
players.php — POST-Endpunkt: Spieler + maxHp aus einem beliebigen Fight (für Namen+Job-Import im Planer)
|
||||
assets/
|
||||
icons/mitigation/ — Lokal gespeicherte Ability-Icons (PNG, von XIVAPI)
|
||||
data/
|
||||
@ -109,7 +108,7 @@ Getrackte party-wide Buffs + Schilde + Boss-Debuffs (definiert in `MITIGATION_AB
|
||||
Drei `buffType`-Kategorien:
|
||||
- **`buff`**: Damage Reduction — Passage of Arms (15%), Troubadour/Tactician/Shield Samba (15%), Dark Missionary/Heart of Light/Temperance/Sacred Soil/Expedient/Collective Unconscious/Holos/Kerachole/Magick Barrier (10%), Fey Illumination (5%)
|
||||
- **`shield`**: Barrieren — Divine Veil, Guardian, Shake It Off, Bloodwhetting, Divine Benison, Divine Caress, Intersection, Neutral Sect, the Spire, Panhaima, Holosakos, Eukrasian Prognosis, Eukrasian Diagnosis, Differential Diagnosis, Haima, Galvanize, Seraphic Veil, Catalyze, Radiant Aegis, Tempera Coat, Tempera Grassa, Improvised Finish
|
||||
- **`debuff`**: Boss-Debuffs — Reprisal (10%), Feint (10% phys / 5% magic), Addle (10%)
|
||||
- **`debuff`**: Boss-Debuffs — Reprisal, Feint, Addle (je 10%)
|
||||
|
||||
**Statische Status-IDs (`statusId`-Feld):** Jeder Eintrag in `MITIGATION_ABILITIES` trägt ein `statusId`-Feld (FFLogs Status-ID = XIVAPI Status row_id + 1.000.000). Diese IDs werden als Fallback in `$mitigIdMap` eingetragen wenn masterData den Eintrag nicht enthält. Löst das Pre-Pull-Problem und Name-Mismatches (z.B. FFLogs "Guardian's Will" vs. Key 'Guardian', "Desperate Measures" vs. 'Expedient').
|
||||
|
||||
@ -161,14 +160,6 @@ Konsistentes Healer → DPS → Tank-Ordering überall: im Spieler-Grid, in jede
|
||||
- Schaden-Delta pro Spieler: grün wenn besser (`aoe-delta-better`), rot wenn schlechter (`aoe-delta-worse`)
|
||||
- Gesamt-Delta + Ref-Debuff-Icons in der REF-Headerzeile (`aoe-ref-label`)
|
||||
|
||||
### Plan als Referenz (Analyse-Tab)
|
||||
- Button "+ Plan als Referenz" (`#ref-plan-toggle`) öffnet Dropdown mit gespeicherten Plänen
|
||||
- Bei Auswahl: Plan-Mechaniken werden zu `refEvents[]` konvertiert (`isPlanRef: true` Flag)
|
||||
- `planToRefEvents(plan)`: wandelt Plan-Assignments in `refEvents` um — nur Abilities mit bekanntem `buffType`
|
||||
- In `renderTimeline()`: `isPlanRef` steuert Darstellung — kein Schaden-Delta, kein Dead-State, "Schild"-Text statt absorbed-Wert, Label "PLAN" statt "REF"
|
||||
- Spieler des Plans werden im Spieler-Grid angezeigt wenn sie sich vom aktuellen Fight unterscheiden (via `refPlayers`)
|
||||
- Same-Report, Cross-Report und Plan-Ref schließen sich gegenseitig aus
|
||||
|
||||
### Cross-Report-Vergleich
|
||||
- Button "+ Anderer Report" (`#ref-ext-toggle`) öffnet Panel mit Eingabefeld + Laden-Button
|
||||
- `api/fight.php` wird mit dem externen Report-Code aufgerufen → Fight-Dropdown befüllt
|
||||
@ -252,11 +243,6 @@ Raid-Cooldown-Planer: Welche Mitigation-Ability wird für welche Mechanik einges
|
||||
"mitigationNames": { "Reprisal": "Vergeltung" },
|
||||
"folderId": null,
|
||||
"jobComposition": ["PLD", "WAR", "WHM", "SCH", "MNK", "DRG", "BRD", "SMN"],
|
||||
"playerRoster": [
|
||||
{ "name": "Xziino", "maxHp": 82340 },
|
||||
{ "name": "Healer1", "maxHp": 75000 }
|
||||
],
|
||||
"shieldK": 30,
|
||||
"mechanics": [
|
||||
{
|
||||
"id": "uuid",
|
||||
@ -275,9 +261,6 @@ Raid-Cooldown-Planer: Welche Mitigation-Ability wird für welche Mechanik einges
|
||||
}
|
||||
```
|
||||
|
||||
- `playerRoster`: Array[8] mit `{name, maxHp}` — wird via "Namen + Job Import" befüllt (aus `api/players.php`)
|
||||
- `shieldK`: SGE/SCH-Schildwert in k (×1000) — wird in der DR-Simulation als Flat-Abzug verwendet; im Info-Panel editierbar
|
||||
|
||||
Mehrere Pläne gespeichert in `localStorage` unter `ff14-planner-plans` als Array.
|
||||
Ordner gespeichert unter `ff14-planner-folders`. Pläne referenzieren Ordner per `folderId`.
|
||||
Aktiver Plan per `ff14-planner-active-plan` (ID) — wird beim Tab-Öffnen wiederhergestellt.
|
||||
@ -306,35 +289,37 @@ Der Haupteinstieg ist der Analyse-Tab — der Nutzer hat die Daten bereits gelad
|
||||
| 1 | ✅ | **Datenfundament** — Plan-Datenmodell + localStorage CRUD |
|
||||
| 2 | ✅ | **Tab-Grundgerüst** — Plan-Liste, Ordner, Mechanik-Timeline |
|
||||
| 3 | ✅ | **Import aus Analyse-Tab** — Export-Button + Dialog + Merge |
|
||||
| 4 | ✅ | **Jobaufstellung** — 8 Slots mit Job-Dropdown + Rollenfärbung + Namen+Job-Import |
|
||||
| 4 | ✅ | **Jobaufstellung** — 8 Slots mit Job-Dropdown + Rollenfärbung |
|
||||
| 5 | ✅ | **Ability-Zuweisung** — Modal-Picker + Rechtsklick-Remove + Äquivalenz-Hints |
|
||||
| 6 | ✅ | **DR-Simulation + Gantt-Chart** — mitigierter Schaden in Mechanik-Cards, Gantt mit Ability-Zeilen |
|
||||
| 6 | 🔜 | **Gantt-Chart** — Recast-Konflikte + DR%-Anzeige (s. unten) |
|
||||
| 7 | 🔜 | **Analyse-Overlay** — geplante vs. tatsächlich genutzte CDs |
|
||||
|
||||
### DR-Simulation (implementiert)
|
||||
### Gantt-Chart — Design-Entscheidungen (Schritt 6)
|
||||
|
||||
- `ABILITY_DR` in `planner.js`: Map Ability-Name → DR-Wert (0–1). Feint = 0.05 (5% magical, konservativ).
|
||||
- `simulateDrMultiplier(mechanic)`: multipliziert `∏(1 − dr_i)` über alle Buff/Debuff-Assignments (keine Schilde)
|
||||
- In `renderMechanicListHtml`:
|
||||
- `→ XX mitigiert` (DR-only, farbig gegen ∅ HP): grün wenn ≤ avgHp, rot wenn darüber
|
||||
- `Mitigation mit Schild XX` (DR + shieldK×1000, daneben): nur wenn `plan.shieldK > 0`
|
||||
- `∅ XX HP` (Durchschnitt maxHp ohne Tanks): aus `avgNonTankMaxHp(plan)`, neben unmitigiertem Wert
|
||||
- Quell-DR-Werte spiegeln `MITIGATION_ABILITIES[*]['dr']` aus `api/analysis.php` — beide Stellen müssen bei Änderungen synchron gehalten werden
|
||||
**Konzept:** Ergänzende Ansicht zur Mechaniken-Übersicht, nicht Ersatz. Umschaltbar per Toggle/Tab.
|
||||
|
||||
### Gantt-Chart (implementiert)
|
||||
**Layout:**
|
||||
- X-Achse: Kampfzeit (0 bis Kampfende aus `source.fightEnd - source.fightStart`)
|
||||
- Linke Spalte: Ability-Icons (eine Zeile pro eingesetzter Ability im Plan) — Zeilengranularität TBD
|
||||
- Vertikale Linien: Mechaniken-Timestamps
|
||||
- Farbige Balken: Ability aktiv (Dauer) + Cooldown-Bereich danach (gedimmt)
|
||||
- Konflikte: wenn ein Balken eine Mechaniken-Linie überlappt = visuell hervorgehoben
|
||||
|
||||
**Aktueller Stand:**
|
||||
- X-Achse: Kampfzeit aus `source.fightEnd - source.fightStart`
|
||||
- **Eine Zeile pro (Job, Ability)** — nicht pro Job. `timelinePlayerRows(plan)` expandiert `JOB_ABILITIES` pro Job.
|
||||
- Schilde in Gantt ausgeblendet, außer **Panhaima** (SGE)
|
||||
- Erste Zeile jedes Jobs hat eine visuelle Trennlinie (`.timeline-player-row--job-start`)
|
||||
- Balken: linker Teil (aktive Dauer, opak) + rechter Teil (Cooldown-Rest, transparent) via CSS `linear-gradient` auf `--active-width` / `--cd-width`
|
||||
- Klick auf leere Zeile → fügt diese Ability direkt der nächsten Mechanik hinzu (kein Menü)
|
||||
- Drag & Drop: nur zwischen Zeilen derselben Ability
|
||||
- Bestehende Balken per Drag verschiebbar; Rechtsklick zum Entfernen
|
||||
**Drag & Drop:**
|
||||
- Ability-Icons aus Palette auf Timeline ziehen → Icon am Startpunkt, Balken zeigt Dauer
|
||||
- Freies Ziehen ohne Snapping — Timestamp wird aus X-Position berechnet
|
||||
- Bestehende Balken sind ebenfalls verschiebbar
|
||||
|
||||
**Noch offen:**
|
||||
- Konflikte visuell hervorheben wenn Balken eine Mechaniken-Linie überlappt
|
||||
**Update-Button:**
|
||||
- Gleicht Gantt-Positionen mit Mechaniken ab
|
||||
- Matching-Logik: TBD (Overlap oder nächste Mechanik) — noch zu klären
|
||||
- Aktualisiert Plan-Assignments entsprechend
|
||||
|
||||
**DR-Simulation:**
|
||||
- Pro Mechanik: `simulierter Schaden = unmitigiert × ∏(1 − dr_i)` für alle zugewiesenen Buffs/Debuffs
|
||||
- Wird in der **Mechaniken-Übersicht** unter dem unmitigierten Schadenswert angezeigt
|
||||
- Schilde werden **nicht** simuliert (Schildwerte nicht verlässlich aus Log ableitbar)
|
||||
- Kein Pass/Fail-Urteil — nur der errechnete Zahlenwert, User entscheidet selbst
|
||||
|
||||
**Recast-Daten:** `data/recast-times.json` — noch zu befüllen. Enthält Cooldown-Dauer (s) und Aktiv-Dauer (s) pro Ability:
|
||||
```json
|
||||
@ -360,7 +345,7 @@ Jobaufstellung → verfügbare Abilities (Subset von `MITIGATION_ABILITIES`):
|
||||
| WHM | Temperance, Divine Benison, Divine Caress |
|
||||
| SCH | Sacred Soil, Expedient, Fey Illumination, Galvanize, Seraphic Veil, Catalyze, Addle |
|
||||
| AST | Collective Unconscious, Neutral Sect, Intersection, the Spire |
|
||||
| SGE | Kerachole, Holos, Holosakos, Panhaima, Eukrasian Prognosis, Eukrasian Diagnosis, Differential Diagnosis, Haima |
|
||||
| SGE | Kerachole, Holos, Holosakos, Panhaima, Eukrasian Prognosis, Eukrasian Diagnosis, Haima, Addle |
|
||||
| BRD | Troubadour |
|
||||
| MCH | Tactician |
|
||||
| DNC | Shield Samba, Improvised Finish |
|
||||
@ -406,7 +391,7 @@ Bekannte Werte (Beispiele):
|
||||
- **Persistenz:** `localStorage` — kein Backend nötig
|
||||
- **IDs:** `crypto.randomUUID()` für Plan-, Mechanik- und Ordner-IDs
|
||||
- **Eindeutige Namen:** `uniquePlanName()` verhindert Duplikate beim Erstellen und Importieren
|
||||
- **Spielernamen in `playerRoster`:** Assignments bleiben Job-basiert (`{ ability, job }`). Namen + maxHp werden separat in `playerRoster[8]` gespeichert — für ∅-HP-Anzeige und Analyse-Tab-Vergleich, nicht für Assignments
|
||||
- **Keine Spielernamen:** Assignments sind Job-basiert (`{ ability, job }`), damit Pläne übertragbar sind
|
||||
- **Kein Ability-Stacking:** FFXIV erlaubt keine doppelte Anwendung derselben Ability pro Mechanik
|
||||
- **Shield-Attribution:** Nicht simulierbar — `absorbed` ist Gesamtwert ohne Aufschlüsselung. Bewusst weggelassen.
|
||||
- **DR-Simulation:** Nur Buffs/Debuffs mit bekanntem `dr`-Wert aus `MITIGATION_ABILITIES`. Ergebnis als Zahlenwert, kein Pass/Fail.
|
||||
|
||||
@ -285,16 +285,6 @@
|
||||
font-size: 13px;
|
||||
color: var(--t2);
|
||||
}
|
||||
.mechanic-avg-hp {
|
||||
font-size: 12px;
|
||||
color: var(--t3);
|
||||
margin-left: 6px;
|
||||
}
|
||||
.mechanic-mitig-row { margin-top: -2px; display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; }
|
||||
.mechanic-mitig-shield { font-size: 12px; color: var(--t3); }
|
||||
.mechanic-mitig-val { font-weight: 500; }
|
||||
.mechanic-mitig--ok { color: var(--green); }
|
||||
.mechanic-mitig--risk { color: var(--red); }
|
||||
|
||||
.mechanic-assignments {
|
||||
display: flex;
|
||||
@ -578,38 +568,6 @@
|
||||
.info-warning--job { color: var(--t2); }
|
||||
.info-warning--missing { color: var(--red); }
|
||||
|
||||
.info-section--extra { margin-top: 12px; }
|
||||
|
||||
.info-extra-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-extra-label {
|
||||
font-size: 12px;
|
||||
color: var(--t2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-extra-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info-extra-input {
|
||||
width: 52px;
|
||||
font-size: 13px;
|
||||
padding: 3px 6px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.info-extra-unit {
|
||||
font-size: 12px;
|
||||
color: var(--t3);
|
||||
}
|
||||
|
||||
/* ── Folder Sidebar ──────────────────────────────────────────────────────────── */
|
||||
.folder-section { margin-bottom: 2px; }
|
||||
|
||||
@ -805,33 +763,6 @@
|
||||
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;
|
||||
@ -875,7 +806,16 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-mitigation-active { display: none; }
|
||||
.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 { cursor: grabbing; }
|
||||
.timeline-mitigation.selected {
|
||||
@ -909,21 +849,9 @@
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.timeline-mitigation--buff {
|
||||
border-color: rgba(200,168,75,.5);
|
||||
color: var(--gold);
|
||||
background: linear-gradient(to right, rgba(200,168,75,.30) 0%, rgba(200,168,75,.30) var(--active-width), rgba(200,168,75,.06) var(--active-width), rgba(200,168,75,.06) 100%);
|
||||
}
|
||||
.timeline-mitigation--debuff {
|
||||
border-color: rgba(224,92,92,.5);
|
||||
color: var(--red);
|
||||
background: linear-gradient(to right, rgba(224,92,92,.30) 0%, rgba(224,92,92,.30) var(--active-width), rgba(224,92,92,.06) var(--active-width), rgba(224,92,92,.06) 100%);
|
||||
}
|
||||
.timeline-mitigation--shield {
|
||||
border-color: rgba(74,158,255,.5);
|
||||
color: var(--blue);
|
||||
background: linear-gradient(to right, rgba(74,158,255,.30) 0%, rgba(74,158,255,.30) var(--active-width), rgba(74,158,255,.06) var(--active-width), rgba(74,158,255,.06) 100%);
|
||||
}
|
||||
.timeline-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-axis {
|
||||
min-height: 28px;
|
||||
|
||||
222
js/planner.js
222
js/planner.js
@ -424,46 +424,6 @@ 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) {
|
||||
@ -479,11 +439,9 @@ function renderMechanicListHtml(plan) {
|
||||
}
|
||||
|
||||
const activeJobSet = new Set(plan.jobComposition.filter(j => j));
|
||||
const avgHp = avgNonTankMaxHp(plan);
|
||||
|
||||
return mechanics.map(m => {
|
||||
const effective = effectiveAssignmentsForMechanic(plan, m);
|
||||
const sorted = sortedAssignments(effective);
|
||||
const sorted = sortedAssignments(m.assignments);
|
||||
const assignHtml = sorted.length === 0
|
||||
? '<span class="mechanic-no-assign">Keine Zuweisung</span>'
|
||||
: sorted.map(a => {
|
||||
@ -499,7 +457,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(a.sourceMechanicId ?? m.id)}" data-ability="${escHtml(a.ability)}" data-job="${escHtml(a.job ?? '')}" title="Entfernen">×</button>
|
||||
<button class="badge-remove" data-mechanic-id="${escHtml(m.id)}" data-ability="${escHtml(a.ability)}" title="Entfernen">×</button>
|
||||
</span>`;
|
||||
const hintHtml = suggestions.map(s =>
|
||||
`<span class="badge-equiv-hint">→ ${escHtml(s.ability)} (${escHtml(s.job)})?</span>`
|
||||
@ -513,14 +471,6 @@ 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>
|
||||
@ -528,13 +478,9 @@ 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${avgHp ? ` <span class="mechanic-avg-hp">∅ ${fmtNumber(avgHp)} HP</span>` : ''}</div>`
|
||||
? `<div class="mechanic-dmg">${fmtNumber(m.unmitigatedDamage)} unmitigiert</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>
|
||||
@ -603,18 +549,12 @@ function timelineScale(plan) {
|
||||
|
||||
function timelinePlayerRows(plan) {
|
||||
const roster = plan.playerRoster ?? [];
|
||||
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;
|
||||
return (plan.jobComposition ?? []).map((job, idx) => ({
|
||||
idx,
|
||||
job,
|
||||
name: roster[idx]?.name ?? '',
|
||||
role: JOB_ROLE[job] ?? '',
|
||||
})).filter(row => row.job);
|
||||
}
|
||||
|
||||
function selectedAssignmentMatches(mechanicId, assignment) {
|
||||
@ -630,44 +570,14 @@ function jobCanUseAbility(job, ability) {
|
||||
|
||||
function timelineRowsForAssignment(rows, assignment) {
|
||||
const assignedJob = assignment.job ?? '';
|
||||
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
|
||||
});
|
||||
if (assignedJob) return rows.filter(row => row.job === assignedJob);
|
||||
return rows.filter(row => jobCanUseAbility(row.job, assignment.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;
|
||||
@ -762,17 +672,11 @@ function renderTimelineHtml(plan) {
|
||||
|
||||
const playerRows = rows.map(row => {
|
||||
const blocks = [];
|
||||
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;
|
||||
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 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));
|
||||
@ -784,7 +688,7 @@ function renderTimelineHtml(plan) {
|
||||
: a.buffType === 'shield' ? 'timeline-mitigation--shield'
|
||||
: 'timeline-mitigation--buff';
|
||||
const icon = MITIG_ICONS[a.ability] ?? '';
|
||||
const abilityLabel = assignmentAbilityName(a, plan);
|
||||
const ability = assignmentAbilityName(a, plan);
|
||||
blocks.push(`
|
||||
<button class="timeline-mitigation ${cls}${selected}${unresolved}"
|
||||
draggable="true"
|
||||
@ -792,24 +696,19 @@ function renderTimelineHtml(plan) {
|
||||
data-mechanic-id="${escHtml(m.id)}"
|
||||
data-ability="${escHtml(a.ability)}"
|
||||
data-job="${escHtml(a.job ?? '')}"
|
||||
title="${escHtml(abilityLabel)} · aktiv ${durationSec}s · CD ${cooldownSec}s${a.job ? '' : ' · mögliche Zuordnung'}">
|
||||
title="${escHtml(ability)} · 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(abilityLabel)}</span>
|
||||
<span>${escHtml(ability)}</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${jobStartCls}" data-row-idx="${row.idx}" data-job="${escHtml(row.job)}" data-ability="${escHtml(row.ability)}">
|
||||
<div class="timeline-row timeline-player-row" data-row-idx="${row.idx}" data-job="${escHtml(row.job)}">
|
||||
<div class="timeline-row-label">
|
||||
<span class="timeline-job role-${escHtml(row.role)}">${escHtml(row.job)}</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>
|
||||
<span class="timeline-player-name">${escHtml(row.name || `Slot ${row.idx + 1}`)}</span>
|
||||
</div>
|
||||
<div class="timeline-track" style="width:${width}px">${hitLines}${blocks.join('')}</div>
|
||||
</div>`;
|
||||
@ -889,7 +788,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 });
|
||||
refreshMechanicList(planId);
|
||||
refreshTimeline(planId);
|
||||
}
|
||||
|
||||
function setTimelineAssignmentJob(planId, mechanicId, ability, job, nextJob) {
|
||||
@ -1045,11 +944,14 @@ function initTimeline(planId) {
|
||||
const timestamp = timelineTimestampFromEvent(plan, track, e);
|
||||
const mechanic = findNearestMechanic(plan, timestamp);
|
||||
if (!mechanic) return;
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
timeline.addEventListener('contextmenu', e => {
|
||||
@ -1088,7 +990,6 @@ 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);
|
||||
});
|
||||
|
||||
@ -1159,39 +1060,15 @@ function renderInfoPanel(plan) {
|
||||
warningsHtml += '</div>';
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
el.innerHTML = legendHtml + warningsHtml;
|
||||
}
|
||||
|
||||
function removeAssignment(planId, mechanicId, abilityName, job = null) {
|
||||
function removeAssignment(planId, mechanicId, abilityName) {
|
||||
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 || (job !== null && (a.job ?? '') !== job)
|
||||
);
|
||||
mechanic.assignments = mechanic.assignments.filter(a => a.ability !== abilityName);
|
||||
updatePlan(planId, { mechanics: plan.mechanics });
|
||||
refreshMechanicList(planId);
|
||||
if (abilityModalMechanicId === mechanicId) renderAbilityModalContent();
|
||||
@ -1213,7 +1090,7 @@ function initMechanicClicks(planId) {
|
||||
const removeBtn = e.target.closest('.badge-remove');
|
||||
if (removeBtn) {
|
||||
e.stopPropagation();
|
||||
removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability, removeBtn.dataset.job ?? null);
|
||||
removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability);
|
||||
return;
|
||||
}
|
||||
const deleteBtn = e.target.closest('.mechanic-delete-btn');
|
||||
@ -1232,7 +1109,7 @@ function initMechanicClicks(planId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const removeBtn = badge.querySelector('.badge-remove');
|
||||
if (removeBtn) removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability, removeBtn.dataset.job ?? null);
|
||||
if (removeBtn) removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability);
|
||||
});
|
||||
}
|
||||
|
||||
@ -1418,7 +1295,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': 'SGE', 'Eukrasian Prognosis II': 'SGE',
|
||||
'Eukrasian Diagnosis': 'SGE', 'Differential Diagnosis': 'SGE',
|
||||
'Troubadour': 'BRD',
|
||||
'Tactician': 'MCH',
|
||||
@ -1498,14 +1375,16 @@ const JOB_ABILITIES = {
|
||||
{ name: 'the Spire', buffType: 'shield' },
|
||||
],
|
||||
'SGE': [
|
||||
{ name: 'Kerachole', buffType: 'buff' },
|
||||
{ name: 'Kerachole', buffType: 'buff' },
|
||||
{ name: 'Holos', buffType: 'buff' },
|
||||
{ name: 'Holosakos', buffType: 'shield' },
|
||||
{ name: 'Panhaima', buffType: 'shield' },
|
||||
{ name: 'Eukrasian Prognosis', buffType: 'shield' },
|
||||
{ name: 'Eukrasian Prognosis II', buffType: 'shield' },
|
||||
{ name: 'Eukrasian Diagnosis', buffType: 'shield' },
|
||||
{ name: 'Differential Diagnosis', buffType: 'shield' },
|
||||
{ name: 'Haima', buffType: 'shield' },
|
||||
{ name: 'Addle', buffType: 'debuff' },
|
||||
],
|
||||
'BRD': [{ name: 'Troubadour', buffType: 'buff' }],
|
||||
'MCH': [{ name: 'Tactician', buffType: 'buff' }],
|
||||
@ -1577,27 +1456,6 @@ const MITIG_ICONS = {
|
||||
'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png',
|
||||
};
|
||||
|
||||
// DR values (0–1) 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 = [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user