diff --git a/docs/adr/0002-stat-und-modifier-pipeline.md b/docs/adr/0002-stat-und-modifier-pipeline.md new file mode 100644 index 0000000..d0477df --- /dev/null +++ b/docs/adr/0002-stat-und-modifier-pipeline.md @@ -0,0 +1,339 @@ +# ADR-0002: Stat- und Modifier-Pipeline + +- **Status:** Angenommen +- **Datum:** 2026-08-10 +- **Betrifft:** Turm-Stats, Workshop, Cards, In-Run-Upgrades, Ultimate; Datentyp aller Stat-Werte +- **Baut auf:** ADR-0001 (Balancing-Daten als Rust-Konstanten hinter `Balance`) + +## Kontext + +Vier Quellen wirken auf dieselben Turm-Stats: **In-Run-Upgrades** (verfallen am Run-Ende), +**Workshop** (permanent), **Cards** (1–5 Slots im Slice) und die **Ultimate** (Death Wave, +zeitlich begrenzt). Ohne feste Stapelregel wird jede neue Quelle zu einer eigenen Sonderregel, +und ein Save, der einen berechneten Wert speichert, friert eine Formel ein, die sich noch ändert. + +Zwei Randbedingungen aus dem Repo: + +- Es gibt genau **einen** Turm. Ticket #4 hat gemessen: bei 10 000 Gegnern kostet die gesamte + Gameplay-Logik **0,114 ms von 16,67 ms**; 96–98 % gehen ins Rendering. Eine Stat-Berechnung + pro Frame wäre nicht messbar. Performance ist hier also **kein** Argument. +- Alle Zahlen kommen laut ADR-0001 aus `Res`. Diese ADR fügt keine Zahlen hinzu, + sondern nur die Struktur, in der sie stehen. Auch die Clamp-Grenzen sind Balancing-Werte und + leben in `Balance`. + +**Geprüft an der Bevy-0.19-Doku** (docs.rs, über context7): Components lassen sich zur Laufzeit +über `commands.entity(e).insert(..)` / `.remove::()` an- und abheften; `RemovedComponents` +und die Lifecycle-Hooks `on_add`/`on_insert`/`on_discard`/`on_remove` existieren, werden hier +aber **nicht** gebraucht (siehe Entfernungspfad). Change Detection über `Res::is_changed()` +und `Changed` ist der Auslöser der Neuberechnung. `Resource` = weltweit genau einmal, +`Component` = an eine Entity gebunden und stirbt mit ihr — dieser Unterschied trägt unten die +Run-Grenze. + +## Entscheidung + +### 1. Drei Akkumulatoren, ein Anwendungsmodus pro Stat + +Jeder Stat sammelt drei Akkumulatoren: + +| Akkumulator | Einheit | Verknüpfung | +|---|---|---| +| `flat` | absolute Einheiten des Stats | additiv | +| `points` | Prozentpunkte (0,15 = +15 pp) | additiv, **einmal am Ende** geclamped | +| `mult` | Faktor | multiplikativ | + +Jeder Stat trägt zusätzlich **ein Bit**, seinen Anwendungsmodus: + +- **`Direct`** — der Wert wächst mit dem Bonus: + `(base + flat) × (1 + clamp(points)) × mult` +- **`Interval`** — der Wert ist eine Zeitspanne in Sekunden und **schrumpft** mit dem Bonus: + `(base + flat) × (1 − clamp(points)) ÷ mult` + +Der Modus ist eine Eigenschaft des Stats, kein Sonderfall des Modifikators. Damit gibt es drei +Akkumulatoren und vier Anwendungsregeln, ohne dass irgendwo eine fünfte Regel entsteht. + +Zeitbasierte Stats bleiben **Intervalle, keine Raten**: Der Spieler liest „12 s Cooldown", nicht +„0,083 Auslösungen/s". Eine Cooldown-Reduktion ist deshalb kein eigener Akkumulator, sondern +`points` im `Interval`-Modus. + +### 2. Clamp: summieren, dann einmal begrenzen + +`clamp(points)` ist `points.clamp(0.0, cap)`. Das `cap` ist ein Balancing-Wert und steht in +`Balance.caps`, pro Stat einer (Crit Chance, Defense %, jeder Cooldown). Es gibt keinen globalen +Clamp und keinen Default im Code. + +Geclamped wird **die Summe, genau einmal, nach der Summierung** — nie pro Quelle. Folgen, die +ausdrücklich gewollt sind: + +- Die Reihenfolge der Quellen ist egal; das Ergebnis ist assoziativ und kommutativ. +- Es gibt **keine** abnehmenden Erträge, sondern eine harte Wand. Liefern drei Quellen je 30 pp + Cooldown-Reduktion bei `cap = 0,60`, wirken 60 pp und 30 pp verfallen ersatzlos. +- Die **ungeclampte** Summe bleibt für die Anzeige erhalten (siehe Sichtbarkeitsanforderung). + Sie wird nicht persistiert, sondern bei der Neuberechnung nebenher ermittelt. + +### 3. Stat-Tabelle + +`base` kommt immer aus `Balance`. „Erwartet" nennt die Akkumulatoren, die im Slice tatsächlich +bespielt werden — technisch stehen jedem Stat alle drei offen (die übrigen sind 0 bzw. 1). + +| Stat | `base` | Modus | Erwartet | Clamp | +|---|---|---|---|---| +| `Damage` | > 0 | Direct | `flat` (Workshop, In-Run), `mult` (Cards) | — | +| `AttackInterval` | > 0 | **Interval** | `mult` (Workshop, Cards, In-Run) | — | +| `Range` | > 0 | Direct | `mult` | — | +| `MaxHealth` | > 0 | Direct | `flat` (Workshop, In-Run), `mult` (Cards) | — | +| `CritFactor` | > 0 | Direct | `mult` | — | +| `CoinGain` | 1,0 | Direct | `mult` | — | +| `CritChance` | 0 | Direct | `points` | `caps.crit_chance` | +| `DefensePercent` | 0 | Direct | `points` | `caps.defense_percent` | +| `HealthRegen` | **0** | Direct | `flat` (Workshop), `mult` (Cards) | — | +| `DefenseFlat` | 0 | Direct | `flat`, `mult` | — | +| `UltimateCooldown` | > 0 | **Interval** | `points` | `caps.cooldown_reduction` | +| `WaveCooldown` | > 0 | **Interval** | `points` | `caps.wave_cooldown_reduction` | + +`HealthRegen` und `DefenseFlat` sind der Grund für den flachen Akkumulator: Ihre Basis ist 0, +und ein rein multiplikativer Bonus auf 0 bleibt 0. Dasselbe gilt später für Lifesteal und Thorns. + +**`AttackInterval` ist der Stat, der die Zuordnung fast gesprengt hätte.** „Attack Speed ×1,25" +ist genrenüblich eine Rate, und eine Rate widerspricht der Intervall-Regel. Der +`Interval`-Modus löst das ohne Sonderregel: Die Karte liefert `mult = 1,25`, das Intervall wird +geteilt, der Spieler sieht weiterhin „×1,25 Angriffsgeschwindigkeit", und der Schuss-Timer +bekommt direkt eine Dauer. Deshalb heißt der Stat `AttackInterval` und nicht `AttackSpeed`. + +### 4. `type Stat = f32` + +Alias, kein Newtype. f32 trägt sieben signifikante Stellen — genug für den Slice. +Revisions-Auslöser unten. + +### 5. Nur Quellen werden persistiert + +Ein Save enthält **niemals** einen berechneten Wert: gespeichert werden Workshop-Level, besessene +und ausgerüstete Cards mit ihrem Level, die Slot-Zahl und (falls ein Run fortgesetzt wird) die +In-Run-Upgrade-Level. Alles andere wird beim Laden neu gerechnet. + +Das entkoppelt #7 (Save/Load) vollständig von dieser ADR: Jede Formeländerung wirkt rückwirkend +auf bestehende Saves, ohne Migration. Es ist die einzige Festlegung hier, deren spätere Rücknahme +wirklich teuer wäre. + +### 6. Herkunft steckt im Ablageort, nicht im Datenmodell + +Die Akkumulatoren tragen **keine** Herkunftsmarkierung. Sie sind ein reiner Cache und werden bei +jeder Änderung **vollständig aus den Quellen neu gerechnet** — nie inkrementell addiert oder +subtrahiert. Bei einem Turm und ≤ 5 Slots ist das eine Handvoll Multiplikationen; die einzige +Alternative wäre Buchführung über das Rückgängigmachen einzelner Beiträge, und genau die geht +schief. + +Die Herkunft, die man wirklich braucht, ist die Run-Grenze. Sie wird nicht als Feld ausgedrückt, +sondern als Ablageort: + +| Lebensdauer | Ablage | Beispiele | +|---|---|---| +| permanent | `Resource` | `Balance`, `Workshop`, `OwnedCards`, `EquippedCards` | +| Run-gebunden | `Component` **an der Turm-Entity** | `RunUpgrades`, `EffectiveStats`, Ultimate-Buff, alle Fähigkeits-Components | + +Die Turm-Entity wird **beim Run-Start gespawnt und beim Run-Ende despawnt**. Damit verschwindet +am Run-Ende genau das, was verschwinden muss, ohne dass irgendein Code es aufräumt. Kein +`clear()`, keine Reset-Liste, die man beim nächsten Feature zu erweitern vergisst. + +Für die UI-Frage „woher kommen meine 340 % Damage?" gibt es eine Funktion, die die +Neuberechnung mit Zwischenwerten pro Quelle ausführt und das Ergebnis zurückgibt. Sie wird auf +Anforderung aufgerufen, nicht im Gameplay-Pfad, und speichert nichts. + +### 7. `EffectiveStats` wird bei Änderung berechnet — aus Nachvollziehbarkeit + +`EffectiveStats` ist ein `Component` an der Turm-Entity, kein `Resource`. Genau ein System +schreibt es, und es läuft, wenn eine Quelle sich geändert hat (`is_changed()` auf den +Quell-Resources, `Changed<..>` auf den Quell-Components) — nicht jeden Frame. + +Der Grund ist **nicht** Performance. Jeden Frame zu rechnen wäre bei 0,114 ms Gesamtbudget für +die Gameplay-Logik nicht messbar. Der Grund ist, dass es dann genau einen Zeitpunkt gibt, an dem +ein Stat sich ändert: ein Breakpoint, eine Log-Zeile, ein Wert, den man ansehen kann. Bei +Neuberechnung pro Frame wäre jeder falsche Wert ein Beobachtungsproblem statt eines Fehlers an +einer Stelle. + +### 8. Card-Effekte sind ein `enum` + +```rust +type Stat = f32; + +enum CardEffect { + Modifier { stat: StatKind, kind: ModifierKind, value: Stat }, + Ability(AbilityKind), +} + +enum ModifierKind { Flat, Points, Mult } +``` + +Kein Trait-Object, keine Registry, kein Plugin-Mechanismus. Null externe Autoren, kein +Modding-Ziel (ADR-0001, Slice-Scope). Ein neuer Stat-Modifikator ist eine neue `StatKind`-Variante +plus eine Zeile in `Balance`; der Compiler zeigt jede Stelle, die nachziehen muss. Genau das ist +der Nutzen und nicht der Preis. + +### 9. Fähigkeiten sind Components, keine Zahlen + +Ein Card-Effekt der Gruppe `Ability` heftet ein eigenes Component an die Turm-Entity — +`DeathRay { duration }`, `SlowAura { factor }` — mit einem eigenen System. Death Ray ist kein +Stat und wird nicht in die Zahlen-Pipeline gepresst. Fähigkeits-Parameter (Dauer, Radius) leben +im Component, nicht in `EffectiveStats`. + +Das fängt auch die Modifikatoren ab, die gar nicht am Turm ansetzen: `SlowAura` verändert die +Geschwindigkeit von Gegnern. Als Stat wäre das ein Fremdkörper, als Component am Turm, das +das Gegner-Bewegungssystem liest, ist es die naheliegende Form. + +**Fähigkeits-Components dürfen keinen Zustand tragen, der einen Run überdauern soll.** Cooldowns +und Timer im Component sind in Ordnung — sie sollen beim Neuaufbau zurückgesetzt werden. +„Einmal pro Run"-Zustand gehört in eine Run-gebundene Ablage, die nicht bei jedem +Karten-Abgleich neu geschrieben wird. + +### 10. Entfernungspfad für Fähigkeits-Components + +Eine nicht entfernte Fähigkeits-Component ist der wahrscheinlichste Bug dieser Architektur: Sie +zeigt sich Wochen später als „der Turm hat manchmal noch den Death Ray", und dann ist die +Ursache nicht mehr rückverfolgbar. Der Schutz ist strukturell, nicht disziplinarisch: + +**Ein einziges System `sync_tower_abilities` ist der einzige Schreiber von +Fähigkeits-Components.** Es fügt nirgends an der Stelle ein, an der eine Karte gewechselt wird. +Es läuft mit derselben Änderungserkennung wie die `EffectiveStats`-Neuberechnung und gleicht ab: +Für jede `AbilityKind`-Variante wird das Soll (aus den Quellen) mit dem Ist (`Has` an der +Turm-Entity) verglichen, fehlende Components werden per `insert` ergänzt, überzählige per +`remove::()` entfernt. Weil abgeglichen und nicht ereignisweise gepflegt wird, fallen die drei +geforderten Fälle auf denselben Pfad: + +1. **Card-Wechsel im laufenden Run** — `EquippedCards` ändert sich → Abgleich läuft → die + Component der abgelegten Karte wird per `remove::()` entfernt, die der neuen eingefügt. + Das ist der einzige Fall, in dem überhaupt jemand entfernt. +2. **Run-Ende** — die Turm-Entity wird despawnt. Es gibt nichts zu entfernen; die Components + verschwinden mit der Entity. Deshalb steht in Punkt 6, dass alles Run-gebundene an der + Turm-Entity hängt. +3. **Laden eines Saves** — der Save enthält nur Quellen (Punkt 5). Laden heißt: Quellen setzen, + Turm frisch spawnen, Abgleich einmal vor dem ersten Gameplay-Frame laufen lassen. Eine + übriggebliebene Component kann es nicht geben, weil die Entity gerade erst entstanden ist. + +Dazu eine `debug_assert`-Prüfung am Ende des Abgleichs: Die Menge der Fähigkeits-Components an +der Turm-Entity ist gleich der aus den Quellen abgeleiteten Menge. Bei einem Turm kostet das +nichts und verwandelt den späten, unauffälligen Bug in einen sofortigen Abbruch. + +Bevys Lifecycle-Hooks und `RemovedComponents` werden dafür ausdrücklich **nicht** verwendet: Ein +Abgleich, der das Soll kennt, braucht keine Benachrichtigung darüber, was gerade passiert ist. + +### 11. Sichtbarkeitsanforderung an jeden neuen Modifikator + +Ein Modifikator, dessen Wirkung der Spieler nicht spürt, hat versagt — und keine technische +Prüfung zeigt das an. Der Compiler akzeptiert einen Bonus, der gegen einen Clamp läuft, und ein +Test, der die Formel prüft, ist grün. + +**Verbindlich für jeden neuen Modifikator, vor dem Merge:** + +1. Der Autor nennt eine Fortschritts-Stelle, an der der Modifikator wirken soll (Welle, Workshop- + Stand, Card-Level). +2. Er notiert den betroffenen `EffectiveStats`-Wert **vor und nach** dem Modifikator an dieser + Stelle — mit der ungeclampten Summe, falls ein Clamp beteiligt ist. +3. Er nennt die **beobachtbare** Folge in einer Größe, die die Run-Telemetrie aus ADR-0001 + ohnehin erfasst: Zeit bis Welle N, DPS-Verlauf, Coins pro Minute, überlebte Welle. Die + Änderung muss dort über der Streuung zwischen zwei Läufen liegen. +4. **Läuft der Modifikator an der genannten Stelle in einen Clamp, ist er abgelehnt** — nicht + dokumentiert, nicht mit einem Hinweis versehen: abgelehnt oder umskaliert. Ein Bonus, der + ersatzlos verfällt, ist im Spiel nicht von einem fehlenden Bonus zu unterscheiden. + +Punkt 3 ist die einzige Anforderung, die sich nicht am Schreibtisch erfüllen lässt, und deshalb +die einzige, die wirkt. + +## Durchgerechnetes Beispiel + +> **Alle Zahlen sind Platzhalter.** Die echten Werte entscheidet Ticket #8. Hier zählt der +> Rechenweg, nicht das Ergebnis. + +**Damage** (Modus `Direct`), Basis 10, Workshop-Level 12 zu +1,20 je Level, zwei Cards: + +| Schritt | Rechnung | Wert | +|---|---|---| +| Basis aus `Balance` | — | 10,00 | +| `flat` = Workshop Damage Lv 12 × 1,20 | 10,00 + 14,40 | 24,40 | +| `points` = 0 (keine Quelle liefert pp auf Damage) | 24,40 × (1 + 0,00) | 24,40 | +| `mult` = Card „Damage" Lv 3 (×2,40) × Card „Berserker" Lv 5 (×1,30) = 3,12 | 24,40 × 3,12 | **76,13** | + +Ein In-Run-Upgrade „+15 pp Damage" (hypothetisch, ohne Clamp auf diesem Stat) ergäbe +`24,40 × 1,15 × 3,12 = 87,55` — die Prozentpunkte greifen **vor** den Multiplikatoren, nicht +danach. + +**Health Regen** (Basis **0**), zeigt, wozu der flache Akkumulator da ist: +`(0 + 4,00 Workshop) × 1,60 (Card Lv 2) = 6,40 HP/s`. +Ohne flachen Akkumulator: `0 × 1,60 = 0`. Die Karte wäre wirkungslos. + +**Ultimate-Cooldown** (Modus `Interval`), Basis 60 s, `caps.cooldown_reduction = 0,60`: +Workshop −8 pp und Card −30 pp ergeben 38 pp → `60 × (1 − 0,38) = 37,2 s`. +Kommt eine dritte Quelle mit −30 pp dazu, ist die Summe 68 pp, geclamped auf 60 pp → +`60 × 0,40 = 24,0 s`. Von den 30 pp der dritten Quelle wirken nur 22, die restlichen 8 verfallen +ersatzlos — eine vierte Quelle wäre komplett wirkungslos. Genau das lehnt die +Sichtbarkeitsanforderung ab. + +## Verworfene Alternativen + +- **Nur additiv oder nur multiplikativ.** Rein additiv verpufft im Endgame, rein multiplikativ + lässt Stats mit Basis 0 (Health Regen, später Lifesteal, Thorns) unerreichbar. Beide Formen + scheitern an je einer Hälfte der Stat-Tabelle. +- **Additiv innerhalb einer Quelle, multiplikativ zwischen Quellen** (die Frage aus dem Ticket). + Klingt ordentlich, koppelt aber die Rechenregel an die Herkunft: Dieselbe Karte wirkt anders, + je nachdem, ob ein Workshop-Upgrade denselben Stat trifft. Die drei Akkumulatoren erreichen + dasselbe Verhalten, ohne dass ein Modifikator wissen muss, wer sonst noch am Stat hängt. +- **Modifikatoren als Liste mit Herkunft, Entfernen durch Subtraktion.** Braucht eine stabile + Identität pro Modifikator, Buchführung beim Run-Ende und ist gegen Rundungsdrift bei f32 nicht + robust. Vollständige Neuberechnung kostet bei einem Turm nichts und hat keinen dieser Fehler. +- **Card-Effekte als Trait-Objects oder Registry.** Kauft Erweiterbarkeit ohne externe Autoren. + Der Preis ist der Verlust der Vollständigkeitsprüfung durch den Compiler — genau die Prüfung, + die einen vergessenen Stat sichtbar macht. +- **Fähigkeiten als Stat-Werte** (z. B. „Death-Ray-Stärke" als Zahl in `EffectiveStats`). Zwingt + jede Fähigkeit in eine Skalarform, die sie nicht hat, und lässt `EffectiveStats` um Felder + wachsen, die kein Gameplay-System liest. +- **`EffectiveStats` als `Resource`.** Funktioniert bei einem Turm, verliert aber die Kopplung + an die Entity-Lebensdauer — und damit den kostenlosen Reset am Run-Ende, der in Punkt 10 die + Bug-Klasse verhindert. + +## Offene Punkte, die diese ADR nicht entscheidet + +- **`MaxHealth` ist der einzige Stat mit einem veränderlichen Ist-Wert daneben.** Steigt + `MaxHealth` mitten im Run, muss entschieden werden, ob die aktuelle HP mitwächst, den Anteil + behält oder unverändert bleibt. Das ist keine Frage der Pipeline, sondern des Health-Systems; + es ist hier nur festgehalten, damit es nicht zwischen den Tickets verschwindet. +- **Bedingte Modifikatoren** („nur gegen Bosse", „skaliert mit erlittenem Schaden" wie + Berserker). Im Slice gibt es keinen; taucht einer auf, ist er entweder eine `Ability` oder er + bricht die Zwei-Gruppen-Teilung des `enum` (siehe Revisions-Auslöser 3). + +## Konsequenzen + +**Positiv** + +- Ein Save enthält keine Formel. Balancing- und Formeländerungen wirken rückwirkend, ohne + Migration. +- Das Run-Ende braucht keinen Aufräum-Code, den man beim nächsten Feature erweitern müsste. +- Ein neuer Stat oder Modifikator erzeugt Compile-Fehler an genau den Stellen, die nachziehen + müssen. +- Die Stapelregel ist reihenfolgeunabhängig; es gibt keine „richtige" Sortierung von Quellen. + +**Negativ** + +- Der Clamp ist eine harte Wand. Ohne die Sichtbarkeitsanforderung entstehen wirkungslose + Modifikatoren, die niemandem auffallen. Die Prüfung ist manuell und damit die schwächste + Stelle dieser ADR. +- Vollständige Neuberechnung skaliert nicht auf viele Turm-artige Entities mit vielen Quellen. + Bei einem Turm ist das irrelevant, und die Alternative wäre die Buchführung, die oben + verworfen wurde. +- Ein neuer Effekt, der weder Stat-Modifikator noch Fähigkeit ist, erzwingt eine Änderung am + `enum` und an jedem `match`. Das ist beabsichtigt, aber es ist Arbeit. +- Die Ablageregel „Run-gebunden = Component an der Turm-Entity" ist eine Konvention. Sie hält + nur, solange sie eingehalten wird; ein Run-Zustand, der versehentlich als `Resource` landet, + überlebt den Run und bringt genau den Bug zurück, den Punkt 10 verhindern soll. + +## Revisions-Auslöser + +1. **Zahlentyp:** Ein Stat-Wert im Spiel überschreitet **10⁶**, oder eine Anzeige rundet + sichtbar (zwei Läufe mit gleichem Stand zeigen unterschiedliche letzte Stellen). Dann wird + `type Stat` neu entschieden — f64 zuerst, Big-Number-Semantik nur, wenn f64 auch reißt. +2. **Mehrere Turm-artige Entities:** Sobald mehr als eine Entity `EffectiveStats` trägt, ist die + Änderungserkennung pro Entity zu prüfen — global geänderte Quellen müssten dann alle + betreffen, entity-eigene nur eine. +3. **Dritte Effektgruppe:** Ein Card-Effekt, der weder als `Modifier` noch als `Ability` + ausdrückbar ist, kippt die Zwei-Gruppen-Teilung. Ein Fall reicht; es ist keine Frage der + Häufigkeit. +4. **Clamp-Druck:** Wenn an der vorgesehenen Fortschritts-Stelle mehr als eine Quelle je Stat + regelmäßig am Clamp anschlägt, ist nicht der Modifikator falsch, sondern das + Prozent-Budget — dann wird die harte Wand gegen abnehmende Erträge neu bewertet.