30 KiB
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 mitCoinGaingenau 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 neuenFreeUpgradeChancefiel auf, dass dieDirect-Formel jeden Stat mitbase = 0undpointsals einziger Quelle auf null rechnet. Betroffen warenCritChanceundDefensePercent— 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.
- 2026-08-10 — §3 um vier Utility-Stats ergänzt. Auslöser: Widerspruch 5 der
Vertical-Slice-Spec (
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 inBalance.
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;pointsskalieren sie nach oben:(base + flat) × (1 + clamp(points)) × multInterval— der Stat ist eine Zeitspanne in Sekunden;pointsskalieren sie nach unten:(base + flat) × (1 − clamp(points)) ÷ multFraction— der Stat ist selbst ein Anteil (Chance, Prozentsatz);pointsskalieren 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:
- Prozent-Stats nehmen
flatstattpoints. Rechnet richtig ((0 + 0,30) × 1 × 1 = 0,30) und käme ohne dritten Modus aus — aber der Clamp hängt in Punkt 2 anpoints. 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. - 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
baseabhängig, und eine Basisänderung im Balancing verschöbe still eine Obergrenze. - 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:
- Ist der Stat selbst ein Anteil oder eine Wahrscheinlichkeit →
Fraction. - Ist er eine Zeitspanne in Sekunden →
Interval. - Sonst →
Direct. Dann mussbase + flatan der nach Punkt 11.1 genannten Fortschritts-Stelle größer als 0 sein — sonst ist der Stat entweder falsch eingeordnet oder er braucht eineflat-Quelle. EinDirect-Stat mitbase = 0undpointsals 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
CoinGainbei 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 —, unddocs/WikiData.mdlistet 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
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:
- Card-Wechsel im laufenden Run —
EquippedCardsändert sich → Abgleich läuft → die Component der abgelegten Karte wird perremove::<T>()entfernt, die der neuen eingefügt. Das ist der einzige Fall, in dem überhaupt jemand entfernt. - 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.
- 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:
- Der Autor nennt eine Fortschritts-Stelle, an der der Modifikator wirken soll (Welle, Workshop- Stand, Card-Level).
- Er notiert den betroffenen
EffectiveStats-Wert vor und nach dem Modifikator an dieser Stelle — mit der ungeclampten Summe, falls ein Clamp beteiligt ist. - 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.
- 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ässtEffectiveStatsum Felder wachsen, die kein Gameplay-System liest. EffectiveStatsalsResource. 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
MaxHealthist der einzige Stat mit einem veränderlichen Ist-Wert daneben. SteigtMaxHealthmitten 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
Abilityoder er bricht die Zwei-Gruppen-Teilung desenum(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
enumund an jedemmatch. 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
Resourcelandet, überlebt den Run und bringt genau den Bug zurück, den Punkt 10 verhindern soll.
Revisions-Auslöser
- 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 Statneu entschieden — f64 zuerst, Big-Number-Semantik nur, wenn f64 auch reißt. - Mehrere Turm-artige Entities: Sobald mehr als eine Entity
EffectiveStatsträgt, ist die Änderungserkennung pro Entity zu prüfen — global geänderte Quellen müssten dann alle betreffen, entity-eigene nur eine. - Dritte Effektgruppe: Ein Card-Effekt, der weder als
Modifiernoch alsAbilityausdrückbar ist, kippt die Zwei-Gruppen-Teilung. Ein Fall reicht; es ist keine Frage der Häufigkeit. - 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.
- 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
Directzu drängen — und genau dieses Hineindrängen war der Defekt, den der Nachtrag vom 2026-08-10 behoben hat.