22 KiB
ff14-mitigator — FFLogs Mitigation Analyzer
Projekt
PHP/HTML/JS-Tool zum Analysieren von FFXIV-Raidlogs via FFLogs OAuth2 PKCE + GraphQL API. Kein Framework, kein Composer, kein npm — Plain PHP für Shared Hosting.
Drei Tabs:
- Report-Tab: Report-Code eingeben, Fight auswählen → Fight-JSON-Ausgabe + Event Explorer
- Analyse-Tab: Spielerübersicht + AoE-Timeline mit Mitigation-Tracking, Pull-Vergleich, Phase-Filter
- Planer-Tab (in Entwicklung): Cooldown-Planer für Raid-Mitigation — Log-Import, manuelle Bearbeitung, Job-basierte Spell-Verfügbarkeit, DR-Simulation
Architektur & Konventionen
Trennung von PHP, HTML und JS
- PHP-Logik gehört ausschließlich in
index.php(und API/Auth-Endpunkte). Keine Geschäftslogik in Templates. - HTML gehört in
templates/. Jede logisch in sich geschlossene Komponente ist eine eigene Datei. - CSS gehört in
css/. Jede CSS-Datei hat einen klar abgegrenzten Scope (base, layout, components, analysis). - JavaScript gehört in
js/. Keine Inline-Scripts in Templates außer dem<script src="...">Tag inpage.php.
Template-System
index.php setzt alle Variablen und ruft dann require templates/page.php auf. Templates sind reine Ausgabe — sie lesen Variablen aus dem Scope, setzen aber keine.
Geteilter JS-State
window.App in app.js hält den gemeinsamen State für alle Tabs:
window.App = { reportCode, fightId, fightStart, fightEnd, phases: [], fights: [] }
window.analysisTab (definiert in analysis.js) stellt Hooks bereit:
onFightSelected()— wird vonapp.jsaufgerufen wenn ein Fight gewählt wirdonTabOpen()— wird vontabs.jsaufgerufen wenn der Analyse-Tab geöffnet wirdonFightsLoaded(fights)— wird vonapp.jsaufgerufen nach Report-Load (befüllt Vergleichs-Dropdown)reset()— wird vonapp.jsaufgerufen wenn ein neuer Report geladen wird
window.plannerTab (definiert in planner.js) stellt Hooks bereit:
onTabOpen()— wird vontabs.jsaufgerufen wenn der Planer-Tab geöffnet wirdimportFromAnalysis(aoeEvents, refEvents, options)— wird vom Analyse-Tab aufgerufen beim Export
Dateistruktur
index.php — PHP-Logik: Auth-Check, Variablen, require page.php
config.php — Konstanten (CLIENT_ID, URIs) + session_start_safe()
templates/
page.php — HTML-Skeleton (head, body), routet zu login oder app
login.php — Login-Overlay (nicht authentifiziert / Token abgelaufen)
topbar.php — Topbar mit Logo + Tab-Navigation + Token-Ablaufzeit
tab-report.php — Report-Tab: includes report-form, fight-select, event-explorer, output-card
tab-analysis.php — Analyse-Tab: Spieler-Grid + Pull-Vergleich + AoE-Timeline HTML
tab-planner.php — Planer-Tab: Plan-Liste, Mechanik-Timeline, Job-Aufstellung (in Entwicklung)
report-form.php — Report-Code-Eingabe Card
fight-select.php — Fight-Auswahl Dropdown Card
event-explorer.php — Event Explorer Card (Ability/DataType/EventType/Spieler-Filter)
output-card.php — Terminal-Ausgabe Card + Initial-Hint
css/
base.css — CSS-Variablen, Reset, Basis-Styles, Feedback-Klassen
layout.css — App-Shell, Topbar, Tabs, Login-Overlay, Form-Helpers
components.css — Cards, Inputs, Buttons, Badges, Terminal
analysis.css — Spieler-Grid, AoE-Timeline, Mitigation-Icons, HP-Bar, Ref-Row
planner.css — Planer-Tab: Plan-Liste, Mechanik-Cards, Job-Slots, Ability-Modal (in Entwicklung)
js/
app.js — Formular, Fight-Dropdown, Fetch, window.App State, Event Explorer
tabs.js — Tab-Switching, ruft window.analysisTab.onTabOpen() auf
analysis.js — Analyse-Tab: Daten laden, Spieler rendern, Timeline rendern, Pull-Vergleich
planner.js — Planer-Tab: localStorage CRUD, Plan-Rendering, Ability-Zuweisung (in Entwicklung)
auth/
start.php — PKCE generieren, Session speichern, Redirect zu FFLogs
callback.php — Code gegen Token tauschen, Token in Session speichern
api/
fight.php — POST-Endpunkt: Fight-Liste via GraphQL → JSON
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)
assets/
icons/mitigation/ — Lokal gespeicherte Ability-Icons (PNG, von XIVAPI)
data/
recast-times.json — Recast-Zeiten pro Ability (in Entwicklung)
ability-equivalents.json — Funktionale Äquivalente über Jobs hinweg (in Entwicklung)
debug/
schema.php — Einmaliges Schema-Explorer Tool (nicht produktiv deployen)
Design-System
CSS-Variablen in css/base.css:
- Hintergründe:
--bg0(#08090d) bis--bg3(#1c2130),--bgcard(#121620) - Akzentfarbe:
--gold(#c8a84b) - Text:
--t1(hell) /--t2(gedimmt) /--t3(sehr gedimmt) - Farben:
--blue,--green,--red,--orange - Fonts:
--font-dCinzel (Titel/Logo),--font-bInter (Body) - Border-Radius:
--r(klein),--rl(groß)
Analyse-Tab — Konzepte & Entscheidungen
AoE-Erkennung
- Verwendet
damageEvents mitincludeResources: true(enthältbuffs-Feld für Mitigations) - Tick-Schaden (
ev['tick'] = true) undabilityGameID ≤ 7(Auto-Attacks) werden gefiltert - Proximity Clustering statt fixer Zeitfenster: Events werden pro
abilityGameIDgesammelt, dann in Cluster eingeteilt — ein neuer Cluster beginnt wenn der Abstand zum ersten Event im aktuellen Cluster> CLUSTER_WINDOW_MS(1000ms) beträgt - AoE = Cluster mit ≥ 3 unterschiedlichen
targetIDs - Pro Target werden
amount,absorbed,overkill,hp(nach Treffer),maxHpundbuffsgespeichert; bei mehreren Hits desselben Targets pro Cluster werdenamount/absorbed/overkillsummiert
Spielernamen statt IDs
masterData.abilities(gameID → Name) wird zusammen mitplayerDetailsin einem einzigen Query abgerufen$mitigIdMap(gameID → Mitigation-Meta) wird aus zwei Quellen befüllt:masterData.abilities— für Abilities die als Events im Report auftauchen (Name-basiertes Matching)- Statische
statusId-Felder inMITIGATION_ABILITIES— Fallback für Abilities die nicht in masterData stehen (z.B. Pre-Pull-Buffs)
Mitigation-Tracking
Getrackte party-wide Buffs + Schilde + Boss-Debuffs (definiert in MITIGATION_ABILITIES in api/analysis.php):
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 Finishdebuff: 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').
Primär: buffs-Feld im damage-Event: Das Feld enthält einen .-separierten String aktiver Status-IDs. resolveMitigations() mappt diese via $mitigIdMap. Doppelte Namen werden dedupliziert.
Fallback: Shield-Timeline via applybuff/removebuff: Das buffs-Feld des damage-Events spiegelt den Zustand nach dem Hit — Schilde die durch den Hit konsumiert wurden fehlen darin. Daher wird in Step 2b ein separater Buffs-Query (dataType: Buffs) für alle Shield-StatusIds gefetcht und eine Timeline aufgebaut: $shieldTimeline[targetId][statusId][] = ['apply' => ts, 'remove' => ts|null]. Bei absorbed > 0 wird shieldsActiveAt() konsultiert und alle dort gefundenen Schilde die noch nicht in der Mitigation-Liste stehen werden hinzugefügt (Name-Deduplication). Buffer: 200ms (remove >= damageTs - 200) um consumed-at-impact von natural-expiry zu unterscheiden.
Mitigation-Icons
- Icons lokal gespeichert in
assets/icons/mitigation/als PNG - Quelle: XIVAPI v2 (
https://v2.xivapi.com/api/asset?path=...&format=png) - Icon-Pfade per Action-Row-ID abgerufen:
https://v2.xivapi.com/api/sheet/Action/{id}?fields=Name,Icon - Dateinamen: kebab-case des Ability-Namens (z.B.
passage-of-arms.png) - Mapping in
analysis.js:MITIG_ICONSObjekt (Ability-Name → lokaler Pfad)
Darstellungsort nach buffType:
- Buffs (
buffType: 'buff'): erscheinen per Spieler unter der Spieler-Box (.aoe-target-buffs) - Schilde (
buffType: 'shield'): erscheinen als Tooltip auf dem+absorbed-Wert (.aoe-target-absorbed) — Liste der aktiven Shield-Namen, consumed Schilde werden über die Timeline-Fallback ergänzt - Debuffs (
buffType: 'debuff'): erscheinen einmal pro Event im Ability-Header neben "X total" bzw. in der REF-Zeile neben "REF X total ±delta" - Fehlende Mitigations aus dem Ref-Pull: Buffs → ausgegraut per Spieler; Debuffs → ausgegraut im jeweiligen Header
.aoe-abilitynutztdisplay: flex; align-items: center; gap: 6px— identisches Spacing wie.aoe-ref-label
Implementierte Features (Übersicht neuerer Commits)
HP-Balken pro Spieler
2-Segment-Balken direkt unter Name+Schaden in der Spieler-Box:
- Grün (links): HP nach dem Treffer — Farbton dynamisch: >50% grün, 25–50% amber, <25% rot
- Rot (Mitte): erlittener Schaden (als % von MaxHP)
- Nur gerendert wenn
t.maxHp > 0; Daten kommen austargetResources.hitPoints/maxHitPointsviaincludeResources: true
Death-Highlight + Absorbed/Overkill
- Spieler-Box bekommt Klasse
.aoe-target--deadwennhp === 0 && maxHp > 0 - Overkill-Betrag wird links neben dem Job-Badge angezeigt (
.aoe-target-overkill) - Absorbed-Betrag erscheint als gedimmter Zusatz neben dem Schaden (
.aoe-target-absorbed)
Phase-Filter
- Liest
phaseTransitionsaus dem gewählten Fight-Objekt (window.App.phases) - Dropdown
#phase-selecterscheint nur wenn Phasen vorhanden sind - Option "Ganzer Fight" (id=0) ist immer dabei; individuelle Phasen ab id=1
phaseFilter = { startTime, endTime }filtert die Timeline inrenderTimeline()
Spieler-Sortierung
Konsistentes Healer → DPS → Tank-Ordering überall: im Spieler-Grid, in jedem AoE-Event, und in der Referenz-Zeile. Innerhalb gleicher Rolle alphabetisch nach Name.
Pull-Vergleich (innerhalb desselben Reports)
- Dropdown
#ref-fight-selectwird nach Report-Load mit allen Fights befüllt (viaonFightsLoaded) - Bei Auswahl: separater
api/analysis.php-Call für den Ref-Fight →refEvents[] - In
renderTimeline(): perabilityNameund Occurrence-Index gematchter Ref-Event wird alsREF-Zeile unterhalb der aktuellen Targets gerendert - Fehlende Mitigations (vorhanden im Ref, nicht im aktuellen Pull) werden als ausgegrautem Icon mit Klasse
.aoe-buff-missinggezeigt — Buffs per Spieler, Debuffs im Ability-Header - 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)
Cross-Report-Vergleich
- Button "+ Anderer Report" (
#ref-ext-toggle) öffnet Panel mit Eingabefeld + Laden-Button api/fight.phpwird mit dem externen Report-Code aufgerufen → Fight-Dropdown befüllt- Auswahl eines externen Fights →
api/analysis.php-Call mit externem Code →refEvents[] - Same-Report- und Cross-Report-Selektion schließen sich gegenseitig aus (jeweils Reset des anderen Dropdowns)
Event Explorer (im Report-Tab)
Erscheint nach Fight-Auswahl als Card über dem Output-Terminal. Filteroptionen:
- Ability: Dropdown mit allen NPC-Fähigkeiten des Fights (via
api/abilities.php) - DataType: DamageTaken / DamageDone / Healing / Casts / Buffs / Deaths
- Event-Typ (raw): damage, calculateddamage, applybuff, removebuff, etc.
- Spieler: Dropdown mit allen Spielern des Fights (via
api/abilities.php) - Limit: 1–500 Events
- Von/Bis: Zeitoffsets in Sekunden vom Fight-Start
- Output landet im gleichen
#output-Terminal wie der Fight-JSON - Backend
api/debug-events.php: filtert Events nach Typ, Ability-ID und Spieler (Source oder Target); gibt Metadaten + gefilterte Events zurück
Konfiguration
config.php:CLIENT_ID,REDIRECT_URI,DEV_MODEanpassenDEV_MODE = truedeaktiviert SSL-Verifizierung (nur lokal, nie in Produktion)session.cookie_secureist beiDEV_MODEautomatischfalse
FFLogs API
- OAuth2 PKCE (kein Client Secret, öffentliche App)
- App registrieren: https://www.fflogs.com/api/clients/
- GraphQL Endpoint (user-scoped):
https://www.fflogs.com/api/v2/user - Token Endpoint:
https://www.fflogs.com/oauth/token - Kein Refresh Token für öffentliche Clients — abgelaufene Sessions starten PKCE neu
- Event-Typen:
damage(post-Mitigation Snapshot, enthältbuffs-Feld) vs.calculateddamage(Snapshot beim Cast-Start — buffs sind zu früh, abertargetResources.absorbzeigt den Schildwert zu diesem Zeitpunkt) buffs-Feld imdamage-Event:.-separierter String aktiver Status-IDs. Achtung: Schilde die durch diesen Hit konsumiert wurden sind bereits entfernt → Shield-Timeline als Fallback nötig- FFLogs Status-IDs = XIVAPI Status-Sheet
row_id+ 1.000.000 (z.B. Galvanize: row_id 297 → FFLogs 1000297) dataType: Buffsliefertapplybuff/removebuff-Events (u.a. für die Shield-Timeline)includeResources: trueimevents()-Query lieferttargetResources.hitPoints/maxHitPointspro Event
XIVAPI
- Basis-URL:
https://v2.xivapi.com - Action-Lookup per Row-ID:
/api/sheet/Action/{id}?fields=Name,Icon - Asset-Download:
/api/asset?path={tex_path}&format=png - Icons nicht hotlinken — lokal speichern (Community-Service, kein SLA)
- XIVAPI-Suche (
/api/search) gibt bei manchen Abilities ClassJob-Daten statt Action-Daten zurück → direkt per Row-ID abrufen
Repository
- Remote:
https://git.epow0.org/xziino/ff14-mitigator - Platform: Gitea (git.epow0.org)
- Branch:
main
Lokale Entwicklung
php -S localhost:8080
Dann http://localhost:8080 im Browser öffnen.
Redirect URI in FFLogs App und config.php: http://localhost:8080/auth/callback.php
Bekannte Schema-Infos (ReportFight)
Verfügbare aber noch nicht genutzte Felder: friendlyPlayers, enemyNPCs,
lastPhase, standardComposition, hasEcho, combatTime
Genutzt: phaseTransitions (für Phase-Filter), fightPercentage, kill, startTime, endTime
Vollständiges Schema: siehe debug/schema.php oder fflogs-schema.json
Deployment
DEV_MODEauffalsesetzenREDIRECT_URIauf produktive HTTPS-URL anpassendebug/Ordner nicht deployenassets/Ordner deployen (enthält lokale Icons)
Planer-Tab — Konzept & Roadmap
Ziel
Raid-Cooldown-Planer: Welche Mitigation-Ability wird für welche Mechanik eingesetzt? Basierend auf Log-Daten oder manuell aufgebaut. Überlebt Browser-Neustarts via localStorage. Kein Server-State — alles im Browser.
Datenmodell (Plan)
{
"id": "uuid",
"name": "M8S – Prog Week 1",
"createdAt": 1234567890,
"updatedAt": 1234567890,
"source": { "reportCode": "abc123", "fightId": 6 },
"jobComposition": ["PLD", "WAR", "WHM", "SCH", "MNK", "DRG", "BRD", "SMN"],
"mechanics": [
{
"id": "uuid",
"name": "Fourth-Wall Fusion",
"timestamp": 83000,
"phase": "Phase 1",
"unmitigatedDamage": 280000,
"notes": "",
"assignments": [
{ "ability": "Reprisal", "job": "PLD" },
{ "ability": "Shield Samba", "job": "BRD" }
]
}
]
}
Mehrere Pläne gespeichert in localStorage unter ff14-planner-plans als Array.
Primärer Import-Flow: Export aus dem Analyse-Tab
Der Haupteinstieg ist der Analyse-Tab — der Nutzer hat die Daten bereits geladen und sieht die Timeline.
- Button "In Planer exportieren" erscheint im Analyse-Tab sobald Daten geladen sind (auch für den Ref-Log separat)
- Dialog mit zwei Entscheidungen:
- Was importieren? "Nur Mechaniken" vs. "Mechaniken + erkannte Mitigations als Startpunkt"
- Wohin? Neuen Plan anlegen (Name eingeben) vs. bestehenden Plan überschreiben/mergen (Dropdown)
- Bei Merge: explizite Bestätigung — niemals implizit überschreiben
- AoE-Events werden zu Mechaniken; Phase-Information aus
phaseTransitionswird mitübernommen - Weiterarbeiten im Planer-Tab
Merge-Logik: Mechaniken gelten als identisch wenn abilityName gleich und |timestamp_a - timestamp_b| < 5000ms. Nur neue Mechaniken werden hinzugefügt, bestehende Assignments bleiben erhalten. Neue Mechaniken werden timestamp-sortiert eingefügt.
Warum Merge statt Überschreiben: Progress-Szenario — erster Import enthält Phase 1, späterer Import fügt Phase 2 hinzu ohne Phase-1-Planung zu verlieren.
Implementierungs-Reihenfolge
| Schritt | Feature | Beschreibung |
|---|---|---|
| 1 | Datenfundament | Plan-Datenmodell + localStorage CRUD (create, read, update, delete, copy) |
| 2 | Tab-Grundgerüst | Leere Tab-Hülle wie Analyse-Tab; Plan-Liste; Mechanik-Timeline (read-only) |
| 3 | Import aus Analyse-Tab | Export-Button + Dialog (s. oben); window.plannerTab.importFromAnalysis() |
| 4 | Jobaufstellung | 8 Slots mit Job-Dropdown; bestimmt verfügbare Abilities in Schritt 5 |
| 5 | Ability-Zuweisung | Pro Mechanik Abilities per Modal-Picker hinzufügen/entfernen |
| 6 | Recast-Konflikt | data/recast-times.json; rote Warnung wenn CD zwischen zwei Mechaniken noch läuft |
| 7 | DR-Simulation | simuliert = unmitigated × ∏(1 − dr_i); grün/rot ob Spieler laut Simulation überlebt |
| 8 | Job-Äquivalente | data/ability-equivalents.json; auto-substituieren beim Job-Wechsel |
| 9 | Analyse-Overlay | Vergleich geplanter vs. tatsächlich genutzter CDs im Analyse-Tab |
Schritte 1–3 = nutzbarer MVP. Schritte 4–6 = praktisch einsetzbar. 7–9 = Power-Features.
UI-Paradigma
- Visuell dem Analyse-Tab ähneln (Cards, gleiche CSS-Variablen, einheitliches Look & Feel)
- Mechaniken als vertikale Timeline-Cards
- Ability-Picker als Modal (kein Inline-Dropdown)
- Nicht für mobile Geräte ausgelegt
Spell-Verfügbarkeit nach Job
Jobaufstellung → verfügbare Abilities (Subset von MITIGATION_ABILITIES):
| Job | Abilities |
|---|---|
| PLD | Passage of Arms, Divine Veil, Guardian, Reprisal |
| WAR | Shake It Off, Bloodwhetting, Reprisal |
| DRK | Dark Missionary, Reprisal |
| GNB | Heart of Stone (noch nicht getrackt), Reprisal |
| 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 |
| BRD | Troubadour |
| MCH | Tactician |
| DNC | Shield Samba, Improvised Finish |
| MNK | Feint |
| DRG | Feint |
| NIN | Feint |
| SAM | Feint |
| RPR | Feint |
| VPR | Feint |
| BLM | Addle |
| SMN | Addle, Radiant Aegis |
| RDM | Addle, Magick Barrier |
| PCT | Addle, Tempera Coat, Tempera Grassa |
Job-Äquivalente (data/ability-equivalents.json)
Abilities die funktional gleich sind aber unterschiedliche Namen haben — relevant beim Job-Wechsel im Slot:
| Gruppe | Abilities |
|---|---|
| 15% Party-Mitigation | Troubadour, Tactician, Shield Samba |
| 10% Ground-Barrier | Sacred Soil, Kerachole |
Reprisal, Feint und Addle sind identische Ability-Namen über Jobs hinweg — kein Mapping nötig, die gleiche Ability bleibt einfach bestehen.
Verhalten beim Job-Wechsel: Assignment wird auto-substituiert wenn Äquivalent für neuen Job existiert (mit Hinweis "automatisch gemappt"). Kein Äquivalent → Assignment ausgegraut (nicht gelöscht, Nutzer entscheidet).
Recast-Zeiten (data/recast-times.json)
Wird für Konflikt-Erkennung benötigt (Schritt 6). Vollständige Liste wird beim Implementieren vervollständigt, Beispiele:
- Reprisal: 60s
- Feint / Addle: 90s
- Dark Missionary / Heart of Light: 90s
- Troubadour / Tactician / Shield Samba: 120s
- Temperance: 120s
Technische Entscheidungen
- Persistenz:
localStorageunterff14-planner-plans— kein Backend nötig - IDs:
crypto.randomUUID()für Plan- und Mechanik-IDs - Keine Spielernamen: Assignments sind Job-basiert (
{ ability, job }), damit Pläne übertragbar sind - Kein Ability-Stacking: FFXIV erlaubt keine doppelte Anwendung derselben Ability — jede Ability kommt pro Mechanik maximal einmal vor, doppelte Instanzen desselben Jobs im Static sind daher kein Sonderfall
- Analyse-Overlay (Schritt 9): Job aus tatsächlichem Pull → lookup welche Ability dieser Job im Plan hatte → Soll/Ist-Vergleich (kein Spielername-Matching nötig)
- Shield-Attribution: Aktuell nicht lösbar —
absorbedimdamage-Event ist ein Gesamtwert ohne Aufschlüsselung per Shield. Zu untersuchen: obcalculatedheal-Events die Shield-Kapazität beim Auftragen mitliefern. Vorerst zurückgestellt. - Plan kopieren: Duplicate-Funktion für Plan-Varianten ("Week 3 v2") ohne Original zu verlieren