docs: add ADR-0004 spatial grid, fix Fraction stat mode, extend spec
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user