507 lines
30 KiB
Markdown
507 lines
30 KiB
Markdown
# 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`)
|
||
- **Nachträge:**
|
||
- **2026-08-10 — §3 um vier Utility-Stats ergänzt.** Auslöser: Widerspruch 5 der
|
||
Vertical-Slice-Spec (`docs/spec-vertical-slice.md`) — der Utility-Tab des In-Run-Shops hatte mit
|
||
`CoinGain` genau **einen** Stat, und die Spec musste entweder den Tab oder diese ADR für
|
||
unvollständig erklären.
|
||
- **2026-08-10 — §1 korrigiert: dritter Anwendungsmodus `Fraction`.** Auslöser: Beim Einordnen des
|
||
neuen `FreeUpgradeChance` fiel auf, dass die `Direct`-Formel jeden Stat mit `base = 0` und
|
||
`points` als einziger Quelle auf **null** rechnet. Betroffen waren `CritChance` und
|
||
`DefensePercent` — **ein Defekt, kein Beobachtungsposten**: Crit-Chance hätte im Spiel nie über
|
||
0 % steigen können. §1 und §2 sind neu gefasst, in §3 hat sich für diese beiden Zeilen die
|
||
**Modus-Spalte** geändert (sonst nichts), und das durchgerechnete Beispiel hat einen
|
||
Prozent-Stat bekommen, damit der Fall belegt ist.
|
||
|
||
## 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
|
||
|
||
> **Korrigiert am 2026-08-10.** Die erste Fassung kannte zwei Modi und rechnete die Prozent-Stats
|
||
> auf null. Der Befund und die Korrektur stehen am Ende dieses Punktes.
|
||
|
||
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 Feld**, seinen Anwendungsmodus. Es hat drei Varianten, und die
|
||
Frage, die es beantwortet, ist immer dieselbe: **Was tun Prozentpunkte mit diesem Stat?**
|
||
|
||
- **`Direct`** — der Stat ist eine **Größe**; `points` skalieren sie nach oben:
|
||
`(base + flat) × (1 + clamp(points)) × mult`
|
||
- **`Interval`** — der Stat ist eine **Zeitspanne** in Sekunden; `points` skalieren sie nach unten:
|
||
`(base + flat) × (1 − clamp(points)) ÷ mult`
|
||
- **`Fraction`** — der Stat **ist selbst ein Anteil** (Chance, Prozentsatz); `points` skalieren
|
||
nichts, sie **sind** der Wert:
|
||
`clamp((base + flat + points) × mult)`
|
||
|
||
Der Modus ist eine Eigenschaft des Stats, kein Sonderfall des Modifikators. Es bleibt bei **einem**
|
||
Feld und drei Akkumulatoren; hinzugekommen ist eine dritte Variante desselben Feldes, nicht ein
|
||
zweites Bit und kein vierter Akkumulator.
|
||
|
||
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.
|
||
|
||
Bei einem `Fraction`-Stat tragen `base`, `flat` und `points` **dieselbe Einheit** — Prozentpunkte —,
|
||
weil der Wert des Stats aus nichts anderem besteht. Deshalb werden sie addiert und nicht
|
||
gegeneinander skaliert, und deshalb greift der Clamp auf das Ergebnis: Bei einem `Fraction`-Stat
|
||
*ist* das Ergebnis die Prozentpunkt-Summe. `mult` steht innerhalb des Clamps, damit die harte Wand
|
||
aus Punkt 2 nicht durch einen Faktor umgangen werden kann.
|
||
|
||
#### Warum es einen dritten Modus gibt und keine billigere Lösung
|
||
|
||
Die erste Fassung dieser ADR kannte nur `Direct` und `Interval`. Damit rechneten `CritChance` und
|
||
`DefensePercent` **auf null**: Beide haben `base = 0` und beziehen ihre Wirkung ausschließlich aus
|
||
`points`, und `(0 + 0) × (1 + 0,30) × 1 = 0`. Drei Quellen mit je 10 pp Crit-Chance hätten
|
||
zusammen 0 % ergeben. Es ist dieselbe Basis-null-Falle, wegen der es den `flat`-Akkumulator
|
||
überhaupt gibt (siehe Punkt 3) — sie wurde dort für `mult` gezogen und für `points` übersehen,
|
||
obwohl `(1 + points)` genauso ein Faktor auf die Basis ist.
|
||
|
||
Die Ursache ist nicht die Basis, sondern eine Doppelbedeutung: Bei `Damage` und `MaxHealth` ist der
|
||
Stat eine Größe und Prozentpunkte vergrößern sie. Bei `CritChance` ist der Stat **selbst** ein
|
||
Prozentsatz; die Prozentpunkte sind der Wert und skalieren nichts. Zwei verschiedene Dinge trugen
|
||
denselben Akkumulatornamen und wurden mit derselben Formel bedient.
|
||
|
||
Drei billigere Wege wurden geprüft und verworfen:
|
||
|
||
1. **Prozent-Stats nehmen `flat` statt `points`.** Rechnet richtig (`(0 + 0,30) × 1 × 1 = 0,30`) und
|
||
käme ohne dritten Modus aus — aber der Clamp hängt in Punkt 2 an `points`. Die Caps für Crit
|
||
Chance und Defense % würden ersatzlos ausfallen, und sie sind der Grund, warum diese Stats
|
||
überhaupt einen Cap-Eintrag haben.
|
||
2. **Den Clamp generell auf das Ergebnis legen** statt auf die Prozentpunkt-Summe. Löst die
|
||
Prozent-Stats, ändert aber die Bedeutung der Cooldown-Caps: Aus „höchstens 60 pp Reduktion" würde
|
||
„mindestens 24 s Dauer". Der Cap-Wert wechselte die Einheit, würde von `base` abhängig, und eine
|
||
Basisänderung im Balancing verschöbe still eine Obergrenze.
|
||
3. **Eine Ausnahme je Zeile in der Stat-Tabelle.** Genau das, was der Modus verhindern soll — der
|
||
Modus ist eine Eigenschaft des Stats, und eine Eigenschaft gehört in ein Feld, nicht in eine
|
||
Fußnote.
|
||
|
||
Bleibt der dritte Modus. Er kostet eine `enum`-Variante, keinen Akkumulator und keine Regel, die
|
||
irgendwo außerhalb dieser drei Formeln steht; der Compiler erzwingt bei jedem neuen Stat eine
|
||
Entscheidung, welcher der drei es ist.
|
||
|
||
**Prüfregel für jede neue Zeile der Stat-Tabelle**, in dieser Reihenfolge:
|
||
|
||
1. Ist der Stat selbst ein Anteil oder eine Wahrscheinlichkeit → **`Fraction`**.
|
||
2. Ist er eine Zeitspanne in Sekunden → **`Interval`**.
|
||
3. Sonst → **`Direct`**. Dann muss `base + flat` an der nach Punkt 11.1 genannten Fortschritts-Stelle
|
||
**größer als 0** sein — sonst ist der Stat entweder falsch eingeordnet oder er braucht eine
|
||
`flat`-Quelle. Ein `Direct`-Stat mit `base = 0` und `points` als einziger Quelle ist genau der
|
||
Fehler, den diese Korrektur behebt.
|
||
|
||
### 2. Clamp: summieren, dann einmal begrenzen
|
||
|
||
`clamp(x)` ist `x.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.
|
||
|
||
**Worauf `clamp` angewandt wird, sagt die Formel des Modus** — und es ist in allen drei Fällen
|
||
dieselbe Größe: die **Prozentpunkt-Summe**. Bei `Direct` und `Interval` ist das der
|
||
`points`-Akkumulator, weil nur er Prozentpunkte trägt. Bei `Fraction` ist es der ganze Wert, weil
|
||
der Wert dort aus nichts anderem als Prozentpunkten besteht. Der Cap behält damit in jedem Fall die
|
||
Einheit „Prozentpunkte" und bleibt vom `base` des Stats unabhängig.
|
||
|
||
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 | **Fraction** | `points` | `caps.crit_chance` |
|
||
| `DefensePercent` | 0 | **Fraction** | `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.
|
||
|
||
**Nachtrag 2026-08-10:** `CritChance` und `DefensePercent` standen in der ersten Fassung als
|
||
`Direct` in dieser Tabelle und rechneten deshalb auf null — dieselbe Basis-null-Falle, hier nur über
|
||
`points` statt über `mult`. Sie stehen jetzt als `Fraction`; `base`, „Erwartet" und `Clamp` sind
|
||
unverändert, geändert hat sich allein die Modus-Spalte. Die Begründung steht in Punkt 1.
|
||
|
||
#### Nachweis: rechnet jede Zeile richtig?
|
||
|
||
Einmal komplett durchgegangen, mit den Formeln aus Punkt 1. „Richtig" heißt: Der Stat kann seinen
|
||
erwarteten Quellen folgen, statt an einer Basis von 0 hängen zu bleiben.
|
||
|
||
| Stat | Modus | Prüfung | Richtig? |
|
||
|---|---|---|---|
|
||
| `Damage` | Direct | `base > 0`, `flat`+`mult` tragen | ja |
|
||
| `AttackInterval` | Interval | `base > 0`, `mult` teilt das Intervall | ja |
|
||
| `Range` | Direct | `base > 0`, `mult` trägt | ja |
|
||
| `MaxHealth` | Direct | `base > 0`, `flat`+`mult` tragen | ja |
|
||
| `CritFactor` | Direct | `base > 0`; ein Faktor wie 2,5× ist eine **Größe**, kein Anteil — `Direct` bleibt richtig | ja |
|
||
| `CoinGain` | Direct | `base = 1,0 > 0`, `mult` trägt | ja |
|
||
| `CritChance` | **Fraction** | war `Direct`: `(0+0)×(1+p) = 0`. Jetzt `clamp(0 + 0 + p)` | **korrigiert** |
|
||
| `DefensePercent` | **Fraction** | war `Direct`: dasselbe. Jetzt `clamp(0 + 0 + p)` | **korrigiert** |
|
||
| `HealthRegen` | Direct | `base = 0`, aber `flat` ist erwartete Quelle → `base + flat > 0`; Prüfregel 3 erfüllt | ja |
|
||
| `DefenseFlat` | Direct | wie `HealthRegen` | ja |
|
||
| `UltimateCooldown` | Interval | `base > 0`; `points` skalieren eine Dauer, also eine Größe | ja |
|
||
| `WaveCooldown` | Interval | `base > 0`; wie `UltimateCooldown` | ja |
|
||
| `CoinsPerWave` | Direct | `base = 0`, aber `flat` ist erwartete Quelle → wie `HealthRegen` | ja |
|
||
| `CritCoinGain` | Direct | `base = 1,0 > 0`, `mult` trägt | ja |
|
||
| `FreeUpgradeChance` | **Fraction** | eine Wahrscheinlichkeit → Prüfregel 1 | **korrigiert** |
|
||
| `MetaGain` | Direct | `base = 1,0 > 0`, `mult` trägt | ja |
|
||
|
||
**`Interval` ist von der Korrektur nicht betroffen.** Beide `Interval`-Stats haben eine Basis über
|
||
null, und ihre `points` skalieren eine Dauer — also eine Größe, nicht einen Anteil. Der inverse Term
|
||
`(1 − clamp(points))` bleibt wörtlich stehen, ebenso ihre Caps und das durchgerechnete
|
||
Ultimate-Cooldown-Beispiel weiter unten. Es gibt keinen Zeitspannen-Stat mit Basis 0; ein solcher
|
||
wäre auch sinnlos, weil ein Intervall von 0 s keine Reduktion braucht.
|
||
|
||
#### Nachtrag 2026-08-10: vier Utility-Stats
|
||
|
||
> **Diese vier Zeilen sind später hinzugekommen als die zwölf oben.** Auslöser war Widerspruch 5 der
|
||
> Vertical-Slice-Spec: Der Utility-Tab des In-Run-Shops stand mit `CoinGain` bei genau einer Zeile,
|
||
> während Attack fünf und Defence vier hat. Ein Tab mit einer Zeile ist kein Tab. Die Kalibrierung
|
||
> aus #3 sagt, was Utility im Referenzspiel ist — **kein Kampf-Stat, sondern ein
|
||
> Ökonomie-Multiplikator** —, und `docs/WikiData.md` listet dort Cash Bonus, Cash/Wave, Coins/Kill
|
||
> und Free Upgrades. Diese vier Zeilen übernehmen das Muster, nicht die Werte. An den zwölf Zeilen
|
||
> oben ist nichts geändert.
|
||
|
||
| Stat | `base` | Modus | Erwartet | Clamp |
|
||
|---|---|---|---|---|
|
||
| `CoinsPerWave` | **0** | Direct | `flat` (Workshop, In-Run), `mult` (Cards) | — |
|
||
| `CritCoinGain` | 1,0 | Direct | `mult` (Workshop, In-Run, Cards) | — |
|
||
| `FreeUpgradeChance` | 0 | **Fraction** | `points` (Workshop, In-Run) | `caps.free_upgrade_chance` |
|
||
| `MetaGain` | 1,0 | Direct | `mult` (Workshop, Cards) — **nicht** In-Run | — |
|
||
|
||
Damit hat die Tabelle **16** Stats. Drei der vier laufen im Modus `Direct`, `FreeUpgradeChance` als
|
||
Wahrscheinlichkeit im Modus `Fraction`; keiner ist eine Zeitspanne, also braucht keiner `Interval`.
|
||
|
||
`CoinsPerWave` hat Basis **0** und steht damit in derselben Gruppe wie `HealthRegen` und
|
||
`DefenseFlat`: Ein rein multiplikativer Bonus auf 0 bleibt 0, der flache Akkumulator ist der Grund,
|
||
dass es die Zeile überhaupt gibt.
|
||
|
||
**`MetaGain` ist die einzige Zeile mit einer Quellen-Einschränkung, und die ist keine
|
||
Balancing-Frage.** Ein aus `Coins` gekaufter Multiplikator auf die Meta-Währung wäre ein
|
||
Konvertierungspfad zwischen den beiden Währungen — und Nicht-Konvertierbarkeit ist eine
|
||
Kern-Entscheidung des Slice (Issue #1, #11). Die Einschränkung gehört deshalb in diese Tabelle und
|
||
nicht in die UI: Wer `MetaGain` in den In-Run-Shop legt, bricht eine Entscheidung, nicht ein Layout.
|
||
|
||
**`CritCoinGain` erbt eine Vorbedingung.** Es wirkt nur, solange `CritChance > 0`, und `CritChance`
|
||
hat Basis 0. Das ist gewollt — es koppelt den Utility-Tab an den Attack-Tab, damit die
|
||
Kaufreihenfolge eine Entscheidung ist und keine Sortierung. Es heißt aber auch, dass die
|
||
Sichtbarkeitsanforderung aus Punkt 11 hier zwingend greift: Die in Punkt 11.1 zu nennende
|
||
Fortschritts-Stelle muss **hinter** dem ersten Crit-Kauf liegen, sonst ist der Modifikator an der
|
||
genannten Stelle wirkungslos und nach Punkt 11.4 abzulehnen.
|
||
|
||
**`FreeUpgradeChance` ist als einziger der vier eine Wahrscheinlichkeit** und folgt deshalb der Form
|
||
von `CritChance` und `DefensePercent`: Basis 0, `points`, ein eigenes `cap` in `Balance`, Modus
|
||
**`Fraction`**. Beim Schreiben dieser Zeile ist aufgefallen, dass diese Form mit der damaligen
|
||
`Direct`-Formel auf null rechnete — der Fund hat die Korrektur in Punkt 1 ausgelöst. Nach Prüfregel 1
|
||
dort ist `Fraction` die richtige Einordnung, und der Cap greift auf die Prozentpunkt-Summe.
|
||
|
||
**`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.
|
||
|
||
**Crit Chance** (Modus **`Fraction`**, Basis **0**, `caps.crit_chance = 0,80`) — der Fall, an dem
|
||
die erste Fassung dieser ADR gescheitert ist. Drei Quellen liefern Prozentpunkte:
|
||
|
||
| Schritt | Rechnung | Wert |
|
||
|---|---|---|
|
||
| Basis aus `Balance` | — | 0,00 |
|
||
| Workshop Crit Chance Lv 20 zu +1 pp je Level | `points += 0,20` | 0,20 |
|
||
| Card „Präzision" Lv 4 | `points += 0,12` | 0,32 |
|
||
| In-Run-Upgrade Crit Chance | `points += 0,05` | 0,37 |
|
||
| `clamp((0 + 0 + 0,37) × 1)` bei `cap = 0,80` | — | **0,37 → 37 % Crit-Chance** |
|
||
|
||
**Mit der alten `Direct`-Formel wäre dasselbe `(0 + 0) × (1 + 0,37) × 1 = 0` gewesen** — zwanzig
|
||
Workshop-Level, eine Karte auf Stufe 4 und ein gekauftes In-Run-Upgrade zusammen wirkungslos, ohne
|
||
dass ein Test oder der Compiler es angezeigt hätte.
|
||
|
||
Der Clamp greift unverändert: Kommt eine vierte Quelle mit +50 pp dazu, ist die Summe 0,87 und der
|
||
geclampte Wert 0,80 → **80 %**. Die **ungeclampte** Summe 0,87 bleibt für die Anzeige erhalten
|
||
(Punkt 2), und die vierte Quelle wäre nach der Sichtbarkeitsanforderung (Punkt 11.4) abzulehnen oder
|
||
umzuskalieren, weil 7 der 50 pp ersatzlos verfallen.
|
||
|
||
**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
|
||
|
||
Was diese Entscheidung einbringt, steht in den Punkten 1 bis 11 und wird hier nicht wiederholt.
|
||
Was sie kostet, steht nur hier:
|
||
|
||
- 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.
|
||
5. **Vierter Anwendungsmodus:** Ein Stat, der weder eine Größe noch eine Zeitspanne noch ein Anteil
|
||
ist. Ein Fall reicht. Die Prüfregel in Punkt 1 ist so gebaut, dass sie ihn sichtbar macht statt
|
||
ihn in `Direct` zu drängen — und genau dieses Hineindrängen war der Defekt, den der Nachtrag vom
|
||
2026-08-10 behoben hat.
|