Files
InfiniTower/docs/adr/0002-stat-und-modifier-pipeline.md

340 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<Balance>`. 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::<T>()` an- und abheften; `RemovedComponents<T>`
und die Lifecycle-Hooks `on_add`/`on_insert`/`on_discard`/`on_remove` existieren, werden hier
aber **nicht** gebraucht (siehe Entfernungspfad). Change Detection über `Res<T>::is_changed()`
und `Changed<T>` 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<T>` an der
Turm-Entity) verglichen, fehlende Components werden per `insert` ergänzt, überzählige per
`remove::<T>()` 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::<T>()` 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.