docs: add ADR-0004 spatial grid, fix Fraction stat mode, extend spec

This commit is contained in:
2026-08-10 14:32:22 +02:00
parent a9486d745f
commit 68a587b948
5 changed files with 781 additions and 42 deletions

View File

@@ -4,6 +4,18 @@
- **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
@@ -33,6 +45,9 @@ Run-Grenze.
### 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 |
@@ -41,26 +56,84 @@ Jeder Stat sammelt drei Akkumulatoren:
| `points` | Prozentpunkte (0,15 = +15 pp) | additiv, **einmal am Ende** geclamped |
| `mult` | Faktor | multiplikativ |
Jeder Stat trägt zusätzlich **ein Bit**, seinen Anwendungsmodus:
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 Wert wächst mit dem Bonus:
- **`Direct`** — der Stat ist eine **Größe**; `points` skalieren sie nach oben:
`(base + flat) × (1 + clamp(points)) × mult`
- **`Interval`** — der Wert ist eine Zeitspanne in Sekunden und **schrumpft** mit dem Bonus:
- **`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. Damit gibt es drei
Akkumulatoren und vier Anwendungsregeln, ohne dass irgendwo eine fünfte Regel entsteht.
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(points)` ist `points.clamp(0.0, cap)`. Das `cap` ist ein Balancing-Wert und steht in
`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:
@@ -83,8 +156,8 @@ bespielt werden — technisch stehen jedem Stat alle drei offen (die übrigen si
| `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` |
| `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` |
@@ -93,6 +166,84 @@ bespielt werden — technisch stehen jedem Stat alle drei offen (die übrigen si
`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
@@ -259,6 +410,26 @@ danach.
`(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 →
@@ -337,3 +508,7 @@ Sichtbarkeitsanforderung ab.
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.