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 - **Datum:** 2026-08-10
- **Betrifft:** Turm-Stats, Workshop, Cards, In-Run-Upgrades, Ultimate; Datentyp aller Stat-Werte - **Betrifft:** Turm-Stats, Workshop, Cards, In-Run-Upgrades, Ultimate; Datentyp aller Stat-Werte
- **Baut auf:** ADR-0001 (Balancing-Daten als Rust-Konstanten hinter `Balance`) - **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 ## Kontext
@@ -33,6 +45,9 @@ Run-Grenze.
### 1. Drei Akkumulatoren, ein Anwendungsmodus pro Stat ### 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: Jeder Stat sammelt drei Akkumulatoren:
| Akkumulator | Einheit | Verknüpfung | | Akkumulator | Einheit | Verknüpfung |
@@ -41,26 +56,84 @@ Jeder Stat sammelt drei Akkumulatoren:
| `points` | Prozentpunkte (0,15 = +15 pp) | additiv, **einmal am Ende** geclamped | | `points` | Prozentpunkte (0,15 = +15 pp) | additiv, **einmal am Ende** geclamped |
| `mult` | Faktor | multiplikativ | | `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` `(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` `(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 Der Modus ist eine Eigenschaft des Stats, kein Sonderfall des Modifikators. Es bleibt bei **einem**
Akkumulatoren und vier Anwendungsregeln, ohne dass irgendwo eine fünfte Regel entsteht. 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 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 „0,083 Auslösungen/s". Eine Cooldown-Reduktion ist deshalb kein eigener Akkumulator, sondern
`points` im `Interval`-Modus. `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 ### 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 `Balance.caps`, pro Stat einer (Crit Chance, Defense %, jeder Cooldown). Es gibt keinen globalen
Clamp und keinen Default im Code. 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 Geclamped wird **die Summe, genau einmal, nach der Summierung** — nie pro Quelle. Folgen, die
ausdrücklich gewollt sind: 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) | — | | `MaxHealth` | > 0 | Direct | `flat` (Workshop, In-Run), `mult` (Cards) | — |
| `CritFactor` | > 0 | Direct | `mult` | — | | `CritFactor` | > 0 | Direct | `mult` | — |
| `CoinGain` | 1,0 | Direct | `mult` | — | | `CoinGain` | 1,0 | Direct | `mult` | — |
| `CritChance` | 0 | Direct | `points` | `caps.crit_chance` | | `CritChance` | 0 | **Fraction** | `points` | `caps.crit_chance` |
| `DefensePercent` | 0 | Direct | `points` | `caps.defense_percent` | | `DefensePercent` | 0 | **Fraction** | `points` | `caps.defense_percent` |
| `HealthRegen` | **0** | Direct | `flat` (Workshop), `mult` (Cards) | — | | `HealthRegen` | **0** | Direct | `flat` (Workshop), `mult` (Cards) | — |
| `DefenseFlat` | 0 | Direct | `flat`, `mult` | — | | `DefenseFlat` | 0 | Direct | `flat`, `mult` | — |
| `UltimateCooldown` | > 0 | **Interval** | `points` | `caps.cooldown_reduction` | | `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, `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. 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" **`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 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 `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`. `(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. 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`: **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`. 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 → 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 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 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. 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.

View File

@@ -0,0 +1,296 @@
# ADR-0004: Spatial Grid für Gegner-Nachbarschaft und weiche Separation
- **Status:** Angenommen
- **Datum:** 2026-08-10
- **Betrifft:** Gegner-gegen-Gegner-Kollision, Zielauswahl in `tower_fire`, geteilter Reichweiten-Scan
für das Auslöse-System, Projektil-Kollision
- **Baut auf:** ADR-0001 (alle Zahlen aus `Res<Balance>`), ADR-0002 (Ablageregel `Resource` vs.
`Component`; vollständige Neuberechnung statt Buchführung), Perf-Messungen aus #4,
Cap-Analyse in #1, Auslöse-Entscheidung in #12
## Kontext
Die Gegner-gegen-Gegner-Kollision steht seit #4 als „offene Architekturfrage" im Raum — Stapeln
zulassen oder Spatial Grid mit weicher Separation — und hat seitdem in keiner ADR und auf keiner
Liste gestanden. Sie entscheidet, ob 10 000 Gegner spielbar statt nur lauffähig sind, und sie ist
teuer nachzurüsten.
Der eigentliche Grund für eine eigene ADR ist aber nicht die Separation. **Drei Systeme brauchen
dieselbe Nachbarschaftsabfrage und wollten sie bisher jedes für sich beantworten:**
| Abfrage | Radius | Aufrufe pro Frame |
|---|---|---|
| Weiche Separation: „welche Gegner sind mir zu nahe?" | Separationsradius (klein) | einmal **je Gegner** → bis 10 000 |
| Zielauswahl in `tower_fire`: „welcher Gegner ist der nächste?" | Turm-`Range` (groß) | einmal (ein Turm) |
| Geteilter Reichweiten-Scan für das Auslöse-System (#12) | Turm-`Range` (groß) | einmal (ein Turm) |
Dazu kommt die **Projektil-Kollision** mit einem kleinen Trefferradius und einem Aufruf je
Projektil. #4 hat sie als einzige davon gemessen.
#12 hat den geteilten Reichweiten-Scan bereits entschieden — „der Scan wird einmal gemacht und
geteilt, sonst läuft er siebenmal pro Frame gegen 10 000 Entities". Diese ADR erledigt den zweiten
Teil derselben Beobachtung: **der eine geteilte Scan ist immer noch ein linearer Durchlauf über
alle Gegner.** Ein Grid macht daraus eine Abfrage, an der vier Konsumenten hängen — es ist geteilte
Infrastruktur, keine Optimierung eines einzelnen Systems.
### Die Messungen, gegen die hier gerechnet wird (#4, #1)
| Größe | Wert |
|---|---|
| Gesamte Gameplay-Logik bei 10 000 Gegnern | **0,114 ms** von 16,67 ms (0,7 %) |
| `move_enemies`, teuerstes Logik-System | **~10 ns pro Gegner** → 0,100 ms bei 10 000 |
| Population-Maintainer bei 50 000 Gegnern | 6,1 µs |
| Kollisions-Paartest | 1,5–6,5 ns, exakt linear in n·m |
| Naives O(n·m) trägt bis | **~5·10⁵ Paartests pro Frame** |
| Bruch unter 60 fps | ~70 000 Sprites, Rendering dominiert mit 96–98 % |
`move_enemies` ist mit 0,100 ms von 0,114 ms fast die gesamte Logik. Das ist der Maßstab: Ein
System, das einen Durchlauf über dieselbe Gegnermenge macht, kostet höchstens so viel wie dieses.
### Geprüft an der Bevy-0.19-Doku (docs.rs über context7, nicht aus dem Gedächtnis)
- Bevy 0.19 bringt **keine** Struktur für Nachbarschaftsabfragen im Gameplay mit.
`bevy::math::bounding` liefert `Aabb2d`, `BoundingCircle`, `Bounded2d` und `IntersectsVolume` —
also Volumen und Schnitttests für die **Narrow Phase**, keine Broad Phase. Wer eine
Nachbarschaftsabfrage will, baut sie oder holt sie sich als Dependency.
- `Query::par_iter` / `par_iter_mut` existieren; `par_iter` ist auf lesende Queries beschränkt.
Zusammen mit `Local<Parallel<Vec<_>>>` wäre ein parallel gefüllter Rebuild möglich — er wird hier
**nicht** gebaut, siehe Rechnung unten.
- `Local<T>` und `Resource` überleben Systemläufe. Die Bucket-Vektoren des Grids werden über
`clear()` wiederverwendet; es gibt keine Allokation pro Frame.
## Entscheidung
### 1. Ein Uniform Grid als `Resource`, einmal pro Frame neu gebaut
`SpatialGrid` ist eine `Resource`: ein **dichtes Array fester Größe** über die Arena, je Zelle ein
`Vec<Entity>`. Die Arena ist beschränkt (die Spawn-Peripherie muss laut Spec-Feature 1 sichtbar
sein) und der Turm steht fest in der Mitte — es gibt keinen Grund für eine Hashmap. Der Zellindex
ist zwei Divisionen und zwei `floor`. Gegner außerhalb des Gitters werden in die Randzelle geklemmt.
Das Grid ist **kein** `Component` an der Turm-Entity: Es beschreibt nicht den Turm, sondern die
Welt, und es überlebt keinen Run-Wechsel als Zustand — es wird ohnehin jeden Frame neu gebaut.
Damit bleibt die Ablageregel aus ADR-0002 §6 unberührt.
### 2. Zellgröße: der kleinste Radius, der **pro Gegner** abgefragt wird
Die Kosten einer Radiusabfrage sind `(⌈2r/c⌉+1)²` Zellen mal der mittleren Belegung. Beide
Fehlerrichtungen kosten: `c ≪ r` erzeugt viele leere Zellenköpfe, `c ≫ r` erzeugt Kandidaten, die
der Narrow-Phase-Test wieder verwirft.
**Die Zellgröße folgt dem Separationsradius, nicht der Turm-Reichweite.** Der Grund ist nicht der
Radius, sondern die **Häufigkeit**: Die Separationsabfrage läuft einmal je Gegner, also bis zu
10 000-mal pro Frame; der Reichweiten-Scan und die Zielauswahl laufen bei genau einem Turm **einmal**
pro Frame. Eine Zellgröße, die für die einmalige große Abfrage gewählt wird, verteuert die
10 000-fache kleine Abfrage; umgekehrt verteuert eine kleine Zelle die einmalige große Abfrage nur
um Zellenköpfe, und die sind kein Narrow-Phase-Test.
Verbindlich:
```
cell_size = ‹1,0› × max(Separationsradius, Trefferradius)
```
Alle drei Werte sind ‹Platzhalter› und leben in `Balance` (ADR-0001 §4 — keine Schwelle als Literal
im Gameplay-Code). Bei `cell_size = Separationsradius` berührt eine Separationsabfrage genau den
3×3-Block um die eigene Zelle und nie mehr.
Die Turm-`Range` bestimmt die Zellgröße ausdrücklich **nicht**. Täte sie es, läge die gesamte in
Reichweite befindliche Population in wenigen Zellen — und die Separationsabfrage wäre wieder O(n),
also genau das, was das Grid entfernen soll. `Range` ist außerdem ein Stat, der über den Run wächst
(ADR-0002 §3); eine Zellgröße, die einem wachsenden Stat folgt, wäre eine Struktur, die sich im
laufenden Run umbaut.
### 3. Rebuild pro Frame, kein inkrementelles Update — gerechnet, nicht geschätzt
Der Rebuild ist ein Durchlauf über dieselbe Gegnermenge wie `move_enemies`, in derselben
Speicherreihenfolge, mit **weniger** Arithmetik: `move_enemies` liest `Transform`, bildet die
Richtung zur Mitte (Subtraktion plus Normalisierung, also eine Wurzel), skaliert und **schreibt**
`Transform` zurück. Der Rebuild liest `Transform`, rechnet zwei Divisionen und zwei `floor` und
schiebt eine `Entity` in einen `Vec`.
| | Rechnung | Ergebnis |
|---|---|---|
| Obergrenze Rebuild bei `HARD_ENEMY_CAP` | 10 000 × ≤10 ns | **≤0,10 ms** |
| Anteil am Frame-Budget | 0,10 / 16,67 | **≤0,6 %** |
| Logik gesamt danach | 0,114 + 0,10 | **≤0,22 ms = ≤1,3 %** |
| Selbst am gemessenen Bruchpunkt | 70 000 × 10 ns | 0,7 ms = 4,2 % |
Die 10 ns sind eine großzügige Obergrenze, weil sie den teureren Nachbarn als Maßstab nimmt.
**Was ein inkrementelles Update dagegen einspart, ist nach oben durch dieselben ≤0,10 ms begrenzt —
und es spart nicht einmal die, denn den Zellindex muss es genauso je Gegner ausrechnen, um einen
Zellwechsel überhaupt zu erkennen.** Der Rest der Ersparnis ist der `Vec`-Push. Dagegen steht: ein
Rückindex je Entity (in welcher Zelle und an welcher Position liegt sie?), ein Entfernen aus einem
`Vec` in der Mitte, und ein Zustand, der zwischen Frames korrekt bleiben muss, während Gegner
spawnen, sterben und bei radialer Konvergenz ständig Zellgrenzen überqueren.
Das ist dieselbe Abwägung, die ADR-0002 §6 für die Akkumulatoren getroffen hat, mit demselben
Ergebnis: **vollständige Neuberechnung statt Buchführung über das Rückgängigmachen.** Buchführung
schlägt fehl, und hier kauft sie unter 0,6 % Frame-Budget.
Aus demselben Grund wird der Rebuild **nicht parallelisiert**. `par_iter` plus
`Local<Parallel<Vec<_>>>` und ein Merge-Schritt wären in Bevy 0.19 verfügbar; sie kaufen einen
Bruchteil von 0,6 %.
Nebenrechnung zur nicht entschiedenen Spielgeschwindigkeit (#1, Spec „Lücken" Punkt 11): Nur die
Simulation skaliert, nicht das Rendering. x4 ergibt ≤0,4 ms Rebuild, also ≤2,4 % des Budgets. Die
Entscheidung hier präjudiziert den Multiplikator nicht.
### 4. Die Projektil-Kollision läuft über dasselbe Grid
#4 hat gemessen, dass naives O(n·m) bis ~5·10⁵ Paartests pro Frame trägt, und genau das empfohlen.
Diese ADR kehrt das um, aus drei Gründen:
1. **Der gemessene Spielraum ist am Auslegungspunkt aufgebraucht.** 10 000 Gegner × ‹50›
gleichzeitige Projektile sind 5·10⁵ Paartests — die Grenze, nicht ein Abstand zu ihr.
2. **Beide Faktoren wachsen entlang der Schwierigkeitskurve, das Budget nicht.** `n` wächst mit
`target_population(wave)`, `m` wächst, weil `AttackInterval` über Workshop, Cards und
In-Run-Upgrades sinkt (ADR-0002 §3, Modus `Interval`). Das Produkt wächst also quadratisch gegen
ein festes Budget. Ein Grenzwert, den man im ersten Run einhält und in Run 20 reißt, ist kein
Grenzwert.
3. **Das Grid steht ohnehin.** Die Abfrage kostet nur noch den 3×3-Block, weil der Trefferradius
nach Punkt 2 in die Zellgröße eingegangen ist. Ein zweiter, anders strukturierter Durchlauf über
die Gegnermenge wäre genau das Muster, das #12 als Bruchstelle benannt hat.
Der Narrow-Phase-Test bleibt, was er war: der gemessene 1,5–6,5-ns-Paartest, nur gegen ‹k›
Kandidaten aus dem Block statt gegen `n`. `bevy::math::bounding` liefert dafür `BoundingCircle` und
`IntersectsVolume`.
### 5. Weiche Separation: ein geklemmter Versatz, keine Kraft
Die Separation ist **keine Kollisionsauflösung**. Überlappung bleibt erlaubt; das Ziel ist, den Ring
lesbar zu halten, nicht Durchdringung zu verhindern. Wer Durchdringung verbietet, baut den Stau, den
die Cap-Analyse als „Stau am Turmring, den keine DPS-Menge auflöst" benannt hat.
Je Gegner, aus dem 3×3-Block:
1. **Nachbar-Budget:** höchstens ‹k› Nachbarn werden berücksichtigt. Das deckelt Kosten **und**
Kraft in einem — dieselbe Regelform wie das „Ziel-Limit pro Auslösung" der Cap-Analyse, das die
Spec in Feature 8 schon trägt. Eine Projektregel statt zweier.
2. **Summe der Wegdrück-Vektoren**, je Nachbar normiert und mit dem Abstand abfallend gewichtet.
3. **Klemmung:** Der resultierende Versatz wird auf ‹f› × (Speed × dt) mit ‹f› < 1 begrenzt und als
Positionsversatz angewandt, nicht als Beschleunigung.
**Das Problem, das die Klemmung löst, sitzt am Turmring.** Radial auf einen Punkt konvergierende
Körper erzeugen dort die höchste Dichte, die es im Spiel gibt. Die Nachbarschaft eines Gegners am
Ring ist tangential ungefähr isotrop, radial aber nicht — hinter ihm, zum Turm hin, steht niemand,
der zurückdrückt. Der Rest-Vektor zeigt deshalb **nach außen, vom Turm weg**. Ohne Klemmung ist
das die Pulsier-Fehlerform: Der Ring drückt sich selbst auseinander, die Gegner laufen wieder heran,
der Ring drückt sich wieder auseinander — und dazwischen fallen Gegner aus ihrer Angriffsreichweite.
Mit der Klemmung kann ein Gegner in einem Frame nie weiter versetzt werden, als er sich bewegt. Der
radiale Anlauf wird also nie umgekehrt, ein Gegner verlässt seine Angriffsposition nicht, und Jitter
braucht ein Überschwingen, das es konstruktiv nicht mehr gibt. Aus der Kraft wird eine begrenzte
Ausdünnung.
‹k›, ‹f›, Separationsradius und die Abstandsgewichtung sind **‹Platzhalter›** in `Balance`. Sie
findet nur Spielen, wie die vier freien Zahlen aus #8. Diese ADR erfindet keine.
### 6. Einordnung in den Frame
Verbindliche Reihenfolge, explizit festgelegt, nicht aus der Registrierungsreihenfolge geerbt:
1. `move_enemies` — schreibt `Transform`
2. `rebuild_spatial_grid` — liest `Transform`, schreibt `SpatialGrid`
3. alle Konsumenten, lesend auf `SpatialGrid`: weiche Separation, geteilter Reichweiten-Scan,
`tower_fire`, Projektil-Kollision
**Verbindlich: Kein Konsument iteriert die Gegner-Query direkt.** Das ist die schärfere Fassung des
Akzeptanzkriteriums, das die Spec für Feature 4 schon führt — aus „es gibt genau einen
Reichweiten-Scan" wird „es gibt genau einen Durchlauf über die Gegnermenge, und das ist der
Rebuild".
Eine Folge wird ausdrücklich in Kauf genommen: Die Separation schreibt `Transform`, nachdem das Grid
gebaut wurde. Das Grid ist innerhalb desselben Frames also um genau einen Separationsversatz
veraltet. Der ist nach Punkt 5 kleiner als ein Bewegungsschritt und damit kleiner als eine Zelle.
Die Alternative — Separation vor dem Rebuild — verschiebt dieselbe Ungenauigkeit auf `tower_fire`
und die Auslöse-Bedingungen, wo sie sichtbarer wäre.
## Verworfene Alternativen
- **Stapeln zulassen, keine Separation** (der Vorschlag, den die Spec unter „Lücken" Punkt 9
festgehalten hat). Der billigste Weg, und für sich genommen vertretbar — aber er löst nur eines
der vier Probleme. Die Zielauswahl in `tower_fire` bleibt O(n), obwohl #4 sie als nächsten
Hotspot nach dem Rendering benannt hat, und der geteilte Reichweiten-Scan bleibt ein linearer
Durchlauf. Das Grid trägt sich über drei Konsumenten, nicht über die Separation. Dazu kommt ein
Design-Argument: Am Turmring liegen ohne Separation tausende Sprites deckungsgleich übereinander,
und die Soll-Population — die Größe, auf der die gesamte Schwierigkeitskurve steht — ist dann
nicht mehr abzulesen.
- **Echte Kollision über eine Physik-Engine** (`bevy_rapier`, `avian`). Löst Durchdringung, also
nicht das Problem; bringt einen Solver, einen Integrationsschritt und eine Dependency, deren
Release-Takt an Bevy-Versionen hängt — dieselbe Einwendung, die ADR-0001 gegen
`bevy_common_assets` erhoben hat. Bei tausenden radial konvergierenden Körpern produziert ein
Solver genau den Stau, den die Cap-Analyse benennt.
- **Eine fertige Spatial-Query-Crate** (etwa `bevy_spatial`). Spart den Rebuild-Code, der aus einer
Schleife und einem Index besteht, und tauscht ihn gegen eine Dependency plus eine fremde
Vorstellung davon, was eine Abfrage kostet. Ob eine 0.19-kompatible Fassung existiert, **wurde
nicht geprüft** — die Entscheidung hängt nicht daran.
- **Quadtree oder BVH.** Passen sich ungleicher Dichte an. Die Dichte hier ist aber bekannt und
gerichtet — ein Attraktor in der Mitte, radiale Anläufe —, und ihr Rebuild ist kein linearer
Durchlauf, sondern eine Sortierung mit Vergleichen. Die Rechnung in Punkt 3 gilt für sie nicht.
- **Inkrementelles Update des Grids.** Siehe Punkt 3: Die Ersparnis ist nach oben durch ≤0,10 ms
begrenzt, der Preis ist ein Rückindex je Entity und eine Bug-Klasse.
- **Ein eigenes Grid je Konsument, jeweils mit passender Zellgröße.** Drei bis vier Rebuilds statt
einem und drei bis vier Stellen, an denen ein veraltetes Grid entstehen kann. Der gemessene Preis
**eines** Rebuilds ist das Argument dagegen.
## Konsequenzen
**Positiv**
- Vier Nachbarschaftsabfragen haben eine Quelle. Ein neuer Konsument (Death Wave, ein späterer
Aura-Effekt) bekommt sie, ohne einen fünften Durchlauf über die Gegnermenge einzuführen.
- Die Zielauswahl in `tower_fire` — laut #4 der nächste Hotspot nach dem Rendering — wird von einem
linearen Scan zu einem Zellenlauf von innen nach außen, der beim ersten Treffer abbricht.
- Der Rebuild hat keinen Zustand zwischen Frames. Es gibt kein veraltetes Grid, keinen Rückindex
und keine Reparaturroutine.
- Die Projektil-Kollision hört auf, entlang der Schwierigkeitskurve zu wachsen.
**Negativ**
- **Der erste unbedingte Kostenpunkt des Projekts.** Alles bisherige läuft bei Änderung
(`EffectiveStats`, ADR-0002 §7) oder bei Ereignis. Der Rebuild läuft jeden Frame, auch wenn nichts
passiert. ≤0,6 % ist bezahlbar, aber es ist eine neue Art von Kosten.
- `SpatialGrid` ist eine `Resource`, die sich **jeden Frame ändert**. `is_changed()` auf ihr ist als
Signal wertlos. Sie darf insbesondere nie eine Quelle für die `EffectiveStats`-Neuberechnung
werden (ADR-0002 §7), sonst läuft die wieder jeden Frame und verliert genau den Beobachtungspunkt,
für den sie gebaut wurde.
- Eine Zellgröße für vier Radien ist ein Kompromiss. Die Reichweiten-Abfrage durchläuft einen Block,
der mit dem `Range`-Stat wächst — siehe Revisions-Auslöser 2.
- Die Separation ist innerhalb des Frames um einen Versatz veraltet (Punkt 6).
- Die weiche Separation wird nach Gefühl getunt, nicht gegen einen Test. ‹k›, ‹f› und der
Separationsradius sind so frei wie die vier Zahlen aus #8 — und der Beleg, dass sie stimmen, ist
ein Blick auf den Turmring, keine Zahl.
## Offene Punkte, die diese ADR nicht entscheidet
- **Death Wave als vierter Konsument.** Der radiale Sweep fragt einen wachsenden Ring ab und muss
Gegner „in der Reihenfolge ihres Abstands" treffen (Spec-Feature 8). Ein zellweiser Lauf von innen
nach außen ist die naheliegende Form, und der gebatchte Massentod aus Spec-„Lücken" Punkt 10 wäre
dann eine Zellordnung statt tausender Einzelereignisse. Das ist hier **nicht** entschieden — es
gehört zu der ADR, die für Massenwirkung und Ziel-Limit noch fehlt.
- **Arena-Maße und Zellenzahl.** Die Kantenlänge des Gitters folgt aus der Arena, und die steht
nicht fest. Bis dahin ist offen, ob die Spawn-Peripherie im Gitter liegt oder in die Randzellen
klemmt.
- **`SlowAura` und andere Gegner-modifizierende Fähigkeiten** (ADR-0002 §9) sind naheliegende
weitere Konsumenten. Sie fallen mit der Card-Liste.
## Revisions-Auslöser
Diese Entscheidung wird neu getroffen, sobald **eines** der folgenden Ereignisse eintritt:
1. **Der Rebuild ist nicht mehr der billigere Durchlauf.** Eine Messung mit dem Aufbau aus
`examples/perf_spike.rs` bei `HARD_ENEMY_CAP` zeigt `rebuild_spatial_grid` teurer als
`move_enemies` im selben Frame. Die Entscheidung gegen das inkrementelle Update steht und fällt
mit dieser Annahme; ist sie widerlegt, wird sie neu getroffen — nicht vorher.
2. **Radien-Spreizung.** Ein Konsument braucht einen Abfrageradius kleiner als eine halbe Zelle oder
größer als ‹20› Zellkanten. Dann trägt **eine** Auflösung nicht mehr für alle Abfragen, und zwei
Auflösungen oder eine hierarchische Struktur sind zu prüfen.
3. **Die Separation frisst den radialen Fortschritt.** Ein Gegner braucht vom Spawn bis zum Turmring
mehr als ‹1,2›× die aus `Speed` und Abstand erwartete Zeit. Dann ist die Klemmung zu schwach
dosiert oder die Form der Separation falsch — beobachtbar in der Run-Telemetrie, nicht nur im
Bild.
4. **Das Bewegungsmodell ist nicht mehr rein radial.** Ausweichen, Umkreisen, Knockback oder ein
zweiter Anziehungspunkt. Dann fällt die Dichteannahme „Maximum am Turmring", auf der Punkt 5
steht, und die Zellgröße ist neu zu bestimmen.

51
docs/agents/domain.md Normal file
View File

@@ -0,0 +1,51 @@
# Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
## Before exploring, read these
- **`CONTEXT.md`** at the repo root, or
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
## File structure
This repo is **single-context**:
```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-....md
│ └── 0002-....md
└── src/
```
Multi-context layout (only relevant if a `CONTEXT-MAP.md` ever appears at the root):
```
/
├── CONTEXT-MAP.md
├── docs/adr/ ← system-wide decisions
└── src/
├── <context-a>/
│ ├── CONTEXT.md
│ └── docs/adr/ ← context-specific decisions
└── <context-b>/
├── CONTEXT.md
└── docs/adr/
```
## Use the glossary's vocabulary
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_

View File

@@ -0,0 +1,54 @@
# Issue tracker: Gitea
Issues und Specs für dieses Repo leben als **Gitea-Issues** auf `https://gitlab.hive-brain.de` (Repo `MDietermann/InfiniTower`).
> **Achtung, irreführender Hostname:** Die Domain heißt `gitlab.hive-brain.de`, dahinter läuft aber **Gitea**, kein GitLab. Nicht `glab` benutzen — die CLI hier ist [`tea`](https://gitea.com/gitea/tea).
## Setup (einmalig)
```bash
tea login add --name hive --url https://gitlab.hive-brain.de --token <access-token>
```
Token in Gitea unter *Settings → Applications → Generate New Token* mit Scope `issue` (bzw. `repo`) erzeugen. `tea` findet Login und Repo danach selbst, wenn es innerhalb des Clones läuft; sonst explizit `--login hive --repo MDietermann/InfiniTower` anhängen.
Binary: <https://dl.gitea.com/tea/> oder `go install code.gitea.io/tea@latest`.
**Mindestversion: 0.15.0** (`tea --version` prüfen). Ältere Builds kennen weder `--labels` beim Anlegen noch `issues edit` / `comment` / `issues close` — damit fällt die halbe Konvention unten aus.
## Konventionen
- **Issue anlegen**: `tea issues create --title "..." --description "..."`. Für mehrzeilige Beschreibungen ein Heredoc in `-d` einsetzen. Labels direkt mit `--labels a,b`, Assignees mit `--assignees <user>`.
- **Issue lesen**: `tea issues <index>` (Alias `tea issue <index>`). Kommentare separat mit `tea comment list <index>`.
- **Issues listen**: `tea issues list --state open --output json`. Filter: `--labels`, `--assignee`, `--milestones`, `--keyword`. Paginierung über `--limit` / `--page` (Default 30 — bei Vollständigkeitsbedarf hochsetzen).
- **Kommentieren**: `tea comment add <index> --description "..."`.
- **Labels setzen / entfernen**: `tea issues edit <index> --add-labels "..." --remove-labels "..."`. Vorhandene Labels: `tea labels list --output json`; neue anlegen mit `tea labels create --name ... --color ...`.
- **Schließen**: `tea issues close <index>`. Nimmt keinen Abschlusskommentar entgegen — erst `tea comment add <index> --description "..."`, dann schließen.
- **Pull Requests**: Gitea nennt sie wie GitHub „pull requests". `tea pulls list|create|view|merge|close`, Review-Kommandos `tea pulls approve|reject|review`.
Bei Unsicherheit über eine Flag: `tea <command> --help` ausführen statt raten.
## PRs als Request-Surface
**PRs as a request surface: no.** _(Auf `yes` setzen, wenn externe PRs in diesem Repo als Feature-Requests behandelt werden sollen; `/triage` liest dieses Flag.)_
Bei `yes` laufen PRs durch dieselben Labels und States wie Issues, über die `tea pulls`-Äquivalente. Gitea nummeriert Issues und PRs in **einem gemeinsamen Zähler** (wie GitHub, anders als GitLab) — `#42` ist also entweder ein Issue oder ein PR, nie beides.
## Wenn ein Skill sagt „publish to the issue tracker"
Ein Gitea-Issue anlegen (`tea issues create`).
## Wenn ein Skill sagt „fetch the relevant ticket"
`tea issues <index>` plus `tea comment list <index>`.
## Wayfinding-Operationen
Für `/wayfinder`. Die **Map** ist ein einzelnes Issue, die Tickets sind **Child**-Issues.
- **Map**: ein Issue mit Label `wayfinder:map`, das den Notes / Decisions-so-far / Fog-Body trägt. `tea issues create --labels wayfinder:map`.
- **Child-Ticket**: ein Issue mit `Part of #<map>` als erster Zeile der Beschreibung und Label `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Beim Claim wird es dem treibenden Dev assigned.
- **Blocking**: Gitea hat native Issue-Dependencies („Depends on"), aber `tea` hat dafür **kein** Subcommand. Deshalb hier per Konvention eine `Blocked by: #<n>, #<n>`-Zeile oben in der Beschreibung. Ein Ticket ist unblocked, wenn alle genannten Issues geschlossen sind.
- **Frontier-Query**: `tea issues list --state open --output json`, auf die Children der Map einschränken, dann alles rauswerfen, das einen offenen Blocker in der `Blocked by`-Zeile oder einen Assignee hat; das erste in Map-Reihenfolge gewinnt.
- **Claim**: `tea issues edit <n> --add-assignees <user>` — der erste Write der Session.
- **Resolve**: `tea comment add <n> --description "<answer>"`, dann `tea issues close <n>`, dann einen Context-Pointer (Gist + Link) an die Decisions-so-far der Map anhängen.

View File

@@ -6,8 +6,9 @@
wir erkennen, dass es fertig ist wir erkennen, dass es fertig ist
- **Baut auf:** [ADR-0001](adr/0001-balancing-daten-als-rust-konstanten.md) (Balancing-Daten), - **Baut auf:** [ADR-0001](adr/0001-balancing-daten-als-rust-konstanten.md) (Balancing-Daten),
[ADR-0002](adr/0002-stat-und-modifier-pipeline.md) (Stat- und Modifier-Pipeline), [ADR-0002](adr/0002-stat-und-modifier-pipeline.md) (Stat- und Modifier-Pipeline),
[ADR-0003](adr/0003-save-format-ort-und-migration.md) (Save-Format), `CONTEXT.md` (Glossar und [ADR-0003](adr/0003-save-format-ort-und-migration.md) (Save-Format),
Slice-Scope), Karte in Issue #1 [ADR-0004](adr/0004-spatial-grid-fuer-gegner-nachbarschaft.md) (Spatial Grid und weiche
Separation), `CONTEXT.md` (Glossar und Slice-Scope), Karte in Issue #1
- **Vokabular:** verbindlich aus `CONTEXT.md`. Diese Spec führt keine neuen Begriffe ein, außer den - **Vokabular:** verbindlich aus `CONTEXT.md`. Diese Spec führt keine neuen Begriffe ein, außer den
beiden in Abschnitt „Zwei Grenzwerte, zwei Begriffe" ausgewiesenen. beiden in Abschnitt „Zwei Grenzwerte, zwei Begriffe" ausgewiesenen.
@@ -71,10 +72,13 @@ Alles hier ist entschieden. Alles andere ist ‹Platzhalter› oder steht unter
| Card-Slots | Start **1**, Wachstum auf **5**; die Slot-Zahl ist selbst Progression | #11 | | Card-Slots | Start **1**, Wachstum auf **5**; die Slot-Zahl ist selbst Progression | #11 |
| Card-Quelle | Drop aus Fortschritt (Boss-Kill, neuer Wellen-Rekord). Kein Kauf, keine dritte Währung | #11 | | Card-Quelle | Drop aus Fortschritt (Boss-Kill, neuer Wellen-Rekord). Kein Kauf, keine dritte Währung | #11 |
| Card-Randbedingungen | Slot 2 fällt **früh** (erste paar Runs); **mindestens zwei Epics** | #11 | | Card-Randbedingungen | Slot 2 fällt **früh** (erste paar Runs); **mindestens zwei Epics** | #11 |
| Slot-Quelle | **Kauf aus der Meta-Währung im Workshop**, exponentiell wachsende Kosten wie bei den Stat-Upgrades. Kein zweiter Freischalt-Mechanismus, keine Meilenstein-Schwelle | diese Spec (löst Widerspruch 2) |
| Utility-Tab | **Ökonomie-Upgrades, keine Kampf-Stats** — vier Zeilen, siehe Feature 6 | #3-Kalibrierung, `docs/WikiData.md`; diese Spec (löst Widerspruch 5) |
| Gegner-gegen-Gegner | **Spatial Grid mit weicher Separation**; dasselbe Grid trägt Zielauswahl, Reichweiten-Scan und Projektil-Kollision | ADR-0004 (löst Lücke 9) |
| Ultimate | genau eine: **Death Wave**, radialer Sweep, automatisch ausgelöst | #9 | | Ultimate | genau eine: **Death Wave**, radialer Sweep, automatisch ausgelöst | #9 |
| Auslöse-Regel | **Cooldown UND ein Weltzustand**, je Fähigkeit fest verdrahtet, nicht einstellbar; feuern mehrere gleichzeitig, feuern alle | #12 | | Auslöse-Regel | **Cooldown UND ein Weltzustand**, je Fähigkeit fest verdrahtet, nicht einstellbar; feuern mehrere gleichzeitig, feuern alle | #12 |
| Währungen | zwei: `Coins` (In-Run, verfällt) und die Meta-Währung (Workshop). Nicht ineinander konvertierbar | Issue #1, #11 | | Währungen | zwei: `Coins` (In-Run, verfällt) und die Meta-Währung (Workshop). Nicht ineinander konvertierbar | Issue #1, #11 |
| Turm-Stats | 12, siehe Tabelle in ADR-0002 §3 | ADR-0002 | | Turm-Stats | 16, siehe Tabelle in ADR-0002 §3 samt Nachtrag vom 2026-08-10 | ADR-0002 |
**Prüfkriterium für die fertige Kurve** (#8, gilt für die vier freien Zahlen): **Prüfkriterium für die fertige Kurve** (#8, gilt für die vier freien Zahlen):
@@ -122,6 +126,14 @@ Der Spawner ist **cap-getrieben, nicht plan-getrieben**: er füllt bis zur `targ
auf, statt N Gegner nach Zeitplan zu setzen (Cap-Analyse, Issue #1). Gegner laufen radial von außen auf, statt N Gegner nach Zeitplan zu setzen (Cap-Analyse, Issue #1). Gegner laufen radial von außen
auf den Turm zu und greifen ihn im Nahbereich an. auf den Turm zu und greifen ihn im Nahbereich an.
Gegner **stapeln nicht frei übereinander**: Sie drücken sich weich auseinander, nach
[ADR-0004](adr/0004-spatial-grid-fuer-gegner-nachbarschaft.md). Das ist keine Kollisionsauflösung —
Überlappung bleibt erlaubt, verboten ist nur, dass tausende Sprites am Turmring deckungsgleich
liegen und die Soll-Population unlesbar machen. Der Versatz ist auf einen Bruchteil des
Bewegungsschritts geklemmt und kann den radialen Anlauf deshalb nie umkehren. Die Nachbarschaft
kommt aus dem `SpatialGrid`, das einmal pro Frame neu gebaut wird und außerdem Feature 4, das
Auslöse-System und die Projektil-Kollision bedient.
**Akzeptanz** **Akzeptanz**
- Bei laufender Spawn-Phase pendelt die Zahl lebender Gegner um `target_population(wave)`, mit einer - Bei laufender Spawn-Phase pendelt die Zahl lebender Gegner um `target_population(wave)`, mit einer
Abweichung von höchstens ‹±10 %› über ein 5-Sekunden-Fenster. Abweichung von höchstens ‹±10 %› über ein 5-Sekunden-Fenster.
@@ -129,6 +141,12 @@ auf den Turm zu und greifen ihn im Nahbereich an.
Sprung-zu-Welle auf die höchste im Debug-Panel erreichbare Welle. Sprung-zu-Welle auf die höchste im Debug-Panel erreichbare Welle.
- Bei 10 000 gleichzeitigen Gegnern bleibt die Frametime unter 16,67 ms (Nachmessung mit dem Aufbau - Bei 10 000 gleichzeitigen Gegnern bleibt die Frametime unter 16,67 ms (Nachmessung mit dem Aufbau
aus `examples/perf_spike.rs`, nach dem Wechsel auf echte Sprites zu wiederholen). aus `examples/perf_spike.rs`, nach dem Wechsel auf echte Sprites zu wiederholen).
- Der Rebuild des `SpatialGrid` kostet bei 10 000 Gegnern **nicht mehr als `move_enemies` im selben
Frame** (ADR-0004 §3 — die Entscheidung gegen ein inkrementelles Update steht auf dieser Annahme;
reißt sie, ist die ADR fällig, nicht die Zahl).
- Kein Gegner bewegt sich in einem Frame vom Turm **weg**, solange ihn keine Fähigkeit dorthin
drückt — die Separationsklemmung macht das konstruktiv unmöglich, und der Turmring pulsiert
sichtbar nicht.
- Jeder Archetyp ist an Sprite-Farbe oder -Form ohne Text unterscheidbar. - Jeder Archetyp ist an Sprite-Farbe oder -Form ohne Text unterscheidbar.
### 4. Turm-Dauerfeuer und geteilter Reichweiten-Scan ### 4. Turm-Dauerfeuer und geteilter Reichweiten-Scan
@@ -138,23 +156,37 @@ den Reichweiten-Zustand; `tower_fire` und **alle** Auslöse-Bedingungen (Feature
Ergebnis. Das ist keine Optimierung für später, sondern die Bedingung, unter der die Ergebnis. Das ist keine Optimierung für später, sondern die Bedingung, unter der die
Auslöse-Entscheidung aus #12 tragfähig ist. Auslöse-Entscheidung aus #12 tragfähig ist.
Der Reichweiten-Zustand und die Zielauswahl sind **Abfragen an das `SpatialGrid`**, kein linearer
Durchlauf über die Gegner (ADR-0004). #12 hat die siebenfache Ausführung desselben Scans beseitigt;
ADR-0004 beseitigt, dass der eine verbleibende Scan noch O(n) ist — die Zielauswahl läuft
zellenweise von der Turmmitte nach außen und bricht beim ersten Treffer ab. Damit gilt eine
schärfere Regel als bisher: **Genau ein System durchläuft die Gegnermenge, und das ist der
Grid-Rebuild.**
**Akzeptanz** **Akzeptanz**
- Bei mindestens einem Gegner in Reichweite feuert der Turm ohne jede Eingabe innerhalb von - Bei mindestens einem Gegner in Reichweite feuert der Turm ohne jede Eingabe innerhalb von
`EffectiveStats.AttackInterval` Sekunden. `EffectiveStats.AttackInterval` Sekunden.
- Ohne Gegner in Reichweite feuert er nicht. - Ohne Gegner in Reichweite feuert er nicht.
- Es existiert genau ein System, das die Gegnermenge in Turm-Reichweite bestimmt; ein `grep` nach - Es existiert genau ein System, das die Gegnermenge in Turm-Reichweite bestimmt, und es fragt das
einem zweiten Reichweiten-Scan über die Gegner-Query findet keinen. `SpatialGrid`; ein `grep` nach einem zweiten Reichweiten-Scan über die Gegner-Query findet keinen.
- **Außer `rebuild_spatial_grid` iteriert kein System die Gegner-Query, um Nachbarn zu finden** —
weder die Separation, noch `tower_fire`, noch das Auslöse-System, noch die Projektil-Kollision
(ADR-0004 §6).
### 5. Stat- und Modifier-Pipeline ### 5. Stat- und Modifier-Pipeline
Vollständig nach [ADR-0002](adr/0002-stat-und-modifier-pipeline.md): drei Akkumulatoren (`flat`, Vollständig nach [ADR-0002](adr/0002-stat-und-modifier-pipeline.md): drei Akkumulatoren (`flat`,
`points`, `mult`), ein `Direct`/`Interval`-Bit je Stat, Clamp einmal auf die Summe, `EffectiveStats` `points`, `mult`), ein Modus-Feld je Stat mit den drei Varianten `Direct` / `Interval` /
als `Component` an der Turm-Entity, Neuberechnung bei Änderung. `type Stat = f32`. Alle Zahlen aus `Fraction`, Clamp einmal auf die Prozentpunkt-Summe, `EffectiveStats` als `Component` an der
`Res<Balance>` (ADR-0001). Turm-Entity, Neuberechnung bei Änderung. `type Stat = f32`. Alle Zahlen aus `Res<Balance>`
(ADR-0001).
**Akzeptanz** **Akzeptanz**
- Das durchgerechnete Beispiel aus ADR-0002 („Durchgerechnetes Beispiel") ist als Test hinterlegt - Das durchgerechnete Beispiel aus ADR-0002 („Durchgerechnetes Beispiel") ist als Test hinterlegt
und grün — mit den dort genannten Platzhalterwerten, nicht mit den späteren echten. und grün — mit den dort genannten Platzhalterwerten, nicht mit den späteren echten. Das schließt
den **Crit-Chance-Fall** ein: Drei Quellen mit zusammen 37 pp ergeben 37 %, nicht 0.
- Kein Stat im Modus `Direct` hat `base = 0` und `points` als einzige erwartete Quelle
(ADR-0002 §1, Prüfregel 3) — prüfbar an der Stat-Tabelle, ohne das Spiel zu starten.
- Nach einer Änderung an `Workshop`, `EquippedCards` oder `RunUpgrades` läuft die Neuberechnung - Nach einer Änderung an `Workshop`, `EquippedCards` oder `RunUpgrades` läuft die Neuberechnung
genau einmal; ohne Änderung läuft sie nicht (nachweisbar über einen Zähler im Debug-Panel). genau einmal; ohne Änderung läuft sie nicht (nachweisbar über einen Zähler im Debug-Panel).
- `sync_tower_abilities` ist der einzige Schreiber von Fähigkeits-Components; sein `debug_assert` - `sync_tower_abilities` ist der einzige Schreiber von Fähigkeits-Components; sein `debug_assert`
@@ -171,6 +203,28 @@ kauft der Spieler In-Run-Upgrades in drei Tabs (Attack / Defence / Utility), jed
(Buy x1). Alle verfallen am Run-Ende, weil sie als `RunUpgrades`-Component an der Turm-Entity hängen (Buy x1). Alle verfallen am Run-Ende, weil sie als `RunUpgrades`-Component an der Turm-Entity hängen
(ADR-0002 §6). (ADR-0002 §6).
**Der Utility-Tab trägt Ökonomie-Upgrades, keine Kampf-Stats.** Das ist keine Verlegenheitslösung,
sondern das, was Utility im Referenzspiel ist: ein Ökonomie-Multiplikator (Kalibrierungsbericht in
#3; `docs/WikiData.md`, Workshop-Abschnitt, listet dort Cash Bonus, Cash/Wave, Coins/Kill Bonus und
Free Upgrades). Vier Zeilen, jede mit ihrem Grund:
| Zeile | Stat | Warum sie da ist |
|---|---|---|
| Coin-Ertrag pro Kill | `CoinGain` | der Grundkanal — Einkommen skaliert linear mit Kills und damit mit der Soll-Population; ohne ihn hat der Tab keinen Anker |
| Coins am Wellenende | `CoinsPerWave` | der einzige Ertrag, der **nicht** mit der Gegnerdichte wächst. Die Cap-Analyse (#1) verlangt, die Economy gegen *Kills pro Welle* zu normieren statt gegen Wellen — ein wellengebundener Sockel ist der Gegenpol dazu und trägt früh, wenn die Population noch klein ist |
| Coin-Bonus auf Crit-Kills | `CritCoinGain` | koppelt Utility an Attack: Er ist wertlos, solange `CritChance` bei 0 steht, und wird stark, sobald der Attack-Tab bedient wurde. Genau das macht die Kaufreihenfolge zu einer Entscheidung statt zu einer Sortierung (#8, siehe Akzeptanz unten) |
| Chance auf ein kostenloses Upgrade | `FreeUpgradeChance` | der einzige Utility-Effekt, der in die anderen Tabs zurückwirkt: Er kauft nicht Coins, sondern **Zeit**, und wirkt damit direkt auf die Tuning-Zielgröße „Zeit zwischen zwei bezahlbaren Käufen". Eine Auswertung je Wellenende, ein kostenloses Level in einem zufällig gewählten Tab — das Original hat drei getrennte Stats dafür, #3 empfiehlt für Utility ausdrücklich „stark vereinfachen" |
Die vier Stats stehen in ADR-0002 §3 (Nachtrag). **`MetaGain` gehört ausdrücklich nicht in diesen
Tab**: Ein aus `Coins` gekaufter Multiplikator auf die Meta-Währung wäre ein Konvertierungspfad
zwischen den beiden Währungen, und den schließt Issue #1 aus. `MetaGain` ist ein Workshop-Upgrade
(Feature 10).
Damit stehen im Panel 5 Attack-, 4 Defence- und 4 Utility-Zeilen — **13 von den ~24 Zeilen, die das
Layout hergibt.** Die Folgerung des HUD-Kommentars „der Spieler kann in einem Run nicht alles
kaufen" trägt deshalb nicht mehr aus dem Layout; sie muss aus den Kosten kommen oder gar nicht
(siehe „Widersprüche", Punkt 5).
**Akzeptanz** **Akzeptanz**
- Ein Kauf ist zu jedem Zeitpunkt eines laufenden Runs möglich, auch mitten in der Spawn-Phase, ohne - Ein Kauf ist zu jedem Zeitpunkt eines laufenden Runs möglich, auch mitten in der Spawn-Phase, ohne
dass die Simulation anhält. dass die Simulation anhält.
@@ -182,6 +236,10 @@ kauft der Spieler In-Run-Upgrades in drei Tabs (Attack / Defence / Utility), jed
- Kosten und Wirkung unterscheiden sich zwischen den drei Tabs merklich genug, dass die - Kosten und Wirkung unterscheiden sich zwischen den drei Tabs merklich genug, dass die
Telemetrie über zehn Runs **nicht** dieselbe Kaufreihenfolge zeigt (#8: sonst ist die Telemetrie über zehn Runs **nicht** dieselbe Kaufreihenfolge zeigt (#8: sonst ist die
Kaufreihenfolge eine Sortierung, keine Entscheidung). Kaufreihenfolge eine Sortierung, keine Entscheidung).
- Der Utility-Tab enthält **keinen** Kauf, der die Meta-Währung, ihre Drop-Chance oder ihre Menge
verändert — kein Pfad im Run wandelt `Coins` in Meta-Fortschritt.
- Jede der vier Utility-Zeilen hat die Sichtbarkeitsanforderung aus ADR-0002 §11 durchlaufen; für
`CritCoinGain` liegt die dort zu nennende Fortschritts-Stelle **hinter** dem ersten Crit-Kauf.
### 7. Auslöse-System für Fähigkeiten ### 7. Auslöse-System für Fähigkeiten
@@ -197,7 +255,8 @@ Priorität.
- Sind zwei Fähigkeiten im selben Frame auslösebereit, feuern beide im selben Frame. - Sind zwei Fähigkeiten im selben Frame auslösebereit, feuern beide im selben Frame.
- Die Bedingungswerte stehen in `Balance` und sind über das Debug-Panel zur Laufzeit änderbar; keine - Die Bedingungswerte stehen in `Balance` und sind über das Debug-Panel zur Laufzeit änderbar; keine
Schwelle steht als Literal im Gameplay-Code (ADR-0001 §4). Schwelle steht als Literal im Gameplay-Code (ADR-0001 §4).
- Das System liest den Reichweiten-Zustand aus Feature 4 und führt keinen eigenen Scan aus. - Das System liest den Reichweiten-Zustand aus Feature 4 und führt keinen eigenen Scan aus — weder
über die Gegner-Query noch als eigene Abfrage am `SpatialGrid` (ADR-0004 §6).
### 8. Ultimate: Death Wave ### 8. Ultimate: Death Wave
@@ -221,6 +280,13 @@ kaufbar und haben keine eigene Währung. Eine Card ist entweder ein Stat-Modifik
**Fähigkeit** (`CardEffect::Modifier` / `CardEffect::Ability`, ADR-0002 §8) — Fähigkeits-Cards feuern **Fähigkeit** (`CardEffect::Modifier` / `CardEffect::Ability`, ADR-0002 §8) — Fähigkeits-Cards feuern
über dasselbe System wie die Ultimate. Drei Seltenheiten, Slots von 1 auf 5. über dasselbe System wie die Ultimate. Drei Seltenheiten, Slots von 1 auf 5.
**Slots werden im Workshop aus der Meta-Währung gekauft** (Feature 10) — sie stehen dort neben den
Stat-Upgrades, mit derselben exponentiellen Kostenform. Es gibt keinen zweiten Freischalt-Weg: keine
Wellen-Meilensteine, keine Boss-Zahl, keinen Drop. Cards selbst bleiben unverkäuflich und fallen
weiter als Drop; gekauft wird nur die **Breite** des Loadouts. Damit ist die eigentliche
Meta-Entscheidung des Slice ausgesprochen: Breite (ein Slot mehr) gegen Stärke (ein Stat-Level
mehr), aus demselben Topf.
Das **Loadout steht vor dem Run fest**. Im laufenden Run sind Cards über den Pause-Button nur Das **Loadout steht vor dem Run fest**. Im laufenden Run sind Cards über den Pause-Button nur
einsehbar, nicht wechselbar (Issue #1). Damit wird im laufenden Run nie eine Fähigkeits-Component einsehbar, nicht wechselbar (Issue #1). Damit wird im laufenden Run nie eine Fähigkeits-Component
entfernt (ADR-0002 §10, Nachtrag zu #6). entfernt (ADR-0002 §10, Nachtrag zu #6).
@@ -233,14 +299,28 @@ entfernt (ADR-0002 §10, Nachtrag zu #6).
eine abgelegte erzeugt keine. eine abgelegte erzeugt keine.
- Während eines Runs ändert sich `EquippedCards` nicht — kein Pfad in der UI erlaubt es. - Während eines Runs ändert sich `EquippedCards` nicht — kein Pfad in der UI erlaubt es.
- Der Card-Pool enthält mindestens zwei Epics, und Slot 2 ist innerhalb der ersten ‹drei› Runs - Der Card-Pool enthält mindestens zwei Epics, und Slot 2 ist innerhalb der ersten ‹drei› Runs
erreichbar (#11; beide Bedingungen sind an das Abnahmekriterium des Slice gebunden). **bezahlbar** (#11; beide Bedingungen sind an das Abnahmekriterium des Slice gebunden).
- Es gibt genau einen Pfad, der `card_slots` erhöht, und der ist ein Workshop-Kauf; kein Boss-Kill,
kein Wellen-Rekord und kein Drop verändert die Slot-Zahl.
### 10. Meta-Währung und Workshop ### 10. Meta-Währung und Workshop
Die zweite Währung fällt aus denselben Kill-Events wie `Coins`, ist aber nicht in sie konvertierbar. Die zweite Währung fällt aus denselben Kill-Events wie `Coins`, ist aber nicht in sie konvertierbar.
Drop-Chance je Normalgegner, garantiert je Boss, Menge wellenabhängig. Gutschrift beim Tod des Drop-Chance je Normalgegner, garantiert je Boss, Menge wellenabhängig. Gutschrift beim Tod des
Gegners, kein Pickup. Der `Workshop` kauft davon permanente Stat-Upgrades mit exponentiell Gegners, kein Pickup. Der `Workshop` kauft davon permanente Stat-Upgrades mit exponentiell
wachsenden Kosten. wachsenden Kosten. Unter den Stats liegt auch `MetaGain` — der Multiplikator auf die gedroppte
Meta-Menge, der aus demselben Grund **nicht** im In-Run-Shop steht (Feature 6).
**Der Workshop verkauft außerdem Card-Slots.** Sie stehen als eigene Zeile neben den Stat-Upgrades,
in derselben Währung, mit derselben exponentiellen Kostenform — vier Käufe insgesamt, von Slot 1 auf
Slot 5. Der Spieler entscheidet damit selbst zwischen Breite und Stärke; das ist die eine
Meta-Entscheidung, die der Slice zu zeigen hat. Ein zusätzliches persistiertes Feld braucht es
nicht: `card_slots: u8` aus ADR-0003 §5 ist zugleich Zustand und Kaufstufe.
**Der erste Slot-Preis ist keine freie Zahl.** #11 bindet Slot 2 an das Abnahmekriterium — er muss
innerhalb der ersten paar Runs fallen. Die Kosten für Slot 2 sind damit an den Meta-Ertrag der
ersten ‹drei› Runs gebunden, nicht an die Kostenkurve der Stat-Upgrades; erst ab Slot 3 ist der
Exponent frei. Beide Werte bleiben ‹Platzhalter› und leben in `Balance`.
**Akzeptanz** **Akzeptanz**
- Eine Ausgabe von `Coins` im Run verringert den Meta-Währungs-Stand nicht, und umgekehrt. - Eine Ausgabe von `Coins` im Run verringert den Meta-Währungs-Stand nicht, und umgekehrt.
@@ -249,6 +329,9 @@ wachsenden Kosten.
- Ein Workshop-Kauf erhöht den `flat`- bzw. `mult`-Beitrag des betroffenen Stats sofort und - Ein Workshop-Kauf erhöht den `flat`- bzw. `mult`-Beitrag des betroffenen Stats sofort und
überlebt ein Run-Ende sowie einen Programm-Neustart. überlebt ein Run-Ende sowie einen Programm-Neustart.
- Über die ersten zehn Runs trägt jeder Run mindestens ein Workshop-Level ein (#8, Prüfkriterium 2). - Über die ersten zehn Runs trägt jeder Run mindestens ein Workshop-Level ein (#8, Prüfkriterium 2).
- Slot 2 ist im Workshop nach höchstens ‹drei› Runs bezahlbar, ohne dass der Spieler auf
Stat-Upgrades verzichtet hat — die Prüfung ist ein gespielter Durchlauf, keine Rechnung.
- Ein gekaufter Slot erhöht `card_slots` um genau 1 und überlebt einen Programm-Neustart.
### 11. Run-Ende und Meta-Übergang ### 11. Run-Ende und Meta-Übergang
@@ -270,8 +353,10 @@ ihr, ohne Aufräum-Code.
### 12. In-Run-HUD und Pause ### 12. In-Run-HUD und Pause
Links das Gameplay-Fenster, rechts ein vertikales Panel über die volle Höhe. Das Panel trägt oben Links das Gameplay-Fenster, rechts ein vertikales Panel über die volle Höhe. Das Panel trägt oben
den Namen des aktiven Tabs, darunter ~8 Zeilen im Schema `<Stat Name>` / `<Stat Value>` links und den Namen des aktiven Tabs, darunter bis zu ~8 Zeilen im Schema `<Stat Name>` / `<Stat Value>` links
`Buy x1` / `<Cost>` rechts. Unten vier Tabs: **Attack Shop · Defence Shop · Utility Shop · und `Buy x1` / `<Cost>` rechts. Belegt sind davon 5 (Attack), 4 (Defence) und 4 (Utility) — das
Panel füllt seine Zeilen also **nicht** aus, und das ist eine Folge der Stat-Liste, kein
Zwischenstand (Feature 6). Unten vier Tabs: **Attack Shop · Defence Shop · Utility Shop ·
Ultimates**. Der Ultimates-Tab trägt die Cooldown-Anzeige (#12). Ein Pause-Button friert die Ultimates**. Der Ultimates-Tab trägt die Cooldown-Anzeige (#12). Ein Pause-Button friert die
**gesamte** Simulation ein und gibt Einsicht in die ausgerüsteten Cards. **gesamte** Simulation ein und gibt Einsicht in die ausgerüsteten Cards.
@@ -294,9 +379,14 @@ Der Zustand zwischen zwei Runs. Er trägt Workshop-Käufe, das Card-Loadout, den
den Start-Knopf für den nächsten Run. **Sein Aufbau ist offen** (siehe „Offen"); was hier steht, ist den Start-Knopf für den nächsten Run. **Sein Aufbau ist offen** (siehe „Offen"); was hier steht, ist
seine Funktion, nicht sein Layout. seine Funktion, nicht sein Layout.
Der Slot-Fortschritt ist dabei **kein eigener Bildschirmteil**: Slots werden im Workshop gekauft
(Feature 10) und sind dort eine Zeile neben den Stat-Upgrades. Der Meta-Screen zeigt sie im
Card-Bereich nur an, er verkauft sie nicht an zweiter Stelle.
**Akzeptanz** **Akzeptanz**
- Ein neuer Run ist nur von hier aus startbar. - Ein neuer Run ist nur von hier aus startbar.
- Workshop-Käufe, Card-Loadout und Slot-Stand sind von hier aus erreichbar und änderbar. - Workshop-Käufe, Card-Loadout und Slot-Stand sind von hier aus erreichbar und änderbar.
- Der Slot-Kauf ist genau einmal erreichbar, nämlich im Workshop.
- Beim Betreten nach einem Run zeigt der Screen, was dieser Run eingebracht hat: erreichte Welle, - Beim Betreten nach einem Run zeigt der Screen, was dieser Run eingebracht hat: erreichte Welle,
verdiente Meta-Währung, gedroppte Cards. verdiente Meta-Währung, gedroppte Cards.
- Steht `version` nach dem Laden auf `0`, erscheint hier die Meldung aus ADR-0003 §8 — und bis zur - Steht `version` nach dem Laden auf `0`, erscheint hier die Meldung aus ADR-0003 §8 — und bis zur
@@ -402,7 +492,8 @@ Für keinen dieser Punkte wird hier eine Antwort erfunden.
| **`MaxHealth` mitten im Run erhöht** — wächst die aktuelle HP mit, behält sie ihren Anteil, oder bleibt sie unverändert? | Das Health-System, nicht die Pipeline (ADR-0002, „Offene Punkte") | **Vor dem ersten Defence-Shop-Upgrade.** Die ADR-0002-Stat-Tabelle weist `MaxHealth` ausdrücklich `flat` aus In-Run-Upgrades zu — der Fall tritt bei jedem Health-Kauf ein, nicht theoretisch | | **`MaxHealth` mitten im Run erhöht** — wächst die aktuelle HP mit, behält sie ihren Anteil, oder bleibt sie unverändert? | Das Health-System, nicht die Pipeline (ADR-0002, „Offene Punkte") | **Vor dem ersten Defence-Shop-Upgrade.** Die ADR-0002-Stat-Tabelle weist `MaxHealth` ausdrücklich `flat` aus In-Run-Upgrades zu — der Fall tritt bei jedem Health-Kauf ein, nicht theoretisch |
| **`SlowAura`** — modifiziert Gegner statt den Turm und ist deshalb kein Stat | Fällt mit der Card-Liste; ADR-0002 §9 legt die Form fest (Component am Turm, das das Gegner-Bewegungssystem liest), nicht den Inhalt | Mit der Card-Liste | | **`SlowAura`** — modifiziert Gegner statt den Turm und ist deshalb kein Stat | Fällt mit der Card-Liste; ADR-0002 §9 legt die Form fest (Component am Turm, das das Gegner-Bewegungssystem liest), nicht den Inhalt | Mit der Card-Liste |
| **Zusammensetzung der Card-Liste**: Anzahl, Wirkungen, Verteilung auf die drei Seltenheiten, Drop-Wahrscheinlichkeiten je Stufe, Stufenzahl je Card | Ein Card-Inhalts-Ticket, gebunden an #8 (Zahlen) und #11 (Randbedingungen) | Vor Feature 9. **Zwei Randbedingungen sind bereits verbindlich**: Slot 2 muss früh fallen, und es braucht mindestens zwei Epics — sonst ist die Epic-Stufe eine Behauptung statt einer Mechanik | | **Zusammensetzung der Card-Liste**: Anzahl, Wirkungen, Verteilung auf die drei Seltenheiten, Drop-Wahrscheinlichkeiten je Stufe, Stufenzahl je Card | Ein Card-Inhalts-Ticket, gebunden an #8 (Zahlen) und #11 (Randbedingungen) | Vor Feature 9. **Zwei Randbedingungen sind bereits verbindlich**: Slot 2 muss früh fallen, und es braucht mindestens zwei Epics — sonst ist die Epic-Stufe eine Behauptung statt einer Mechanik |
| **Slot-Kurve**: wodurch ein Card-Slot freigeschaltet wird | Niemand — siehe „Lücken", Punkt 2 | offen | | **Slot-Preise**: was ein Card-Slot kostet | Der Entwickler beim Tunen. Die **Mechanik** ist entschieden — Kauf aus der Meta-Währung im Workshop, siehe Feature 10 und „Widersprüche", Punkt 2. Offen ist nur noch der Preis, und der ist für Slot 2 **nicht frei**: er ist an den Meta-Ertrag der ersten ‹drei› Runs gebunden | Zusammen mit den vier freien Zahlen, nach Feature 15 |
| **Zellgröße, Separationsradius, Nachbar-Budget ‹k› und Klemmfaktor ‹f›** | Der Entwickler beim Tunen; Form und Revisions-Auslöser stehen in ADR-0004 | Mit Feature 3. Der Beleg ist ein Blick auf den Turmring, keine Zahl |
--- ---
@@ -411,6 +502,10 @@ Für keinen dieser Punkte wird hier eine Antwort erfunden.
Beim Zusammentragen aller Entscheidungen sind Stellen aufgefallen, an denen zwei Quellen sich Beim Zusammentragen aller Entscheidungen sind Stellen aufgefallen, an denen zwei Quellen sich
widersprechen oder an denen eine Frage niemandem gehört. Sie stehen hier, statt geglättet zu werden. widersprechen oder an denen eine Frage niemandem gehört. Sie stehen hier, statt geglättet zu werden.
Aufgelöste Punkte werden **nicht gelöscht**, sondern als *entschieden, siehe X* markiert und
behalten ihren Befund. Der Weg zu einer Entscheidung ist so lange wertvoll, wie jemand sie in Frage
stellen könnte. Aufgelöst sind bisher die Punkte **2**, **5**, **9** und **14**.
### Widersprüche ### Widersprüche
1. **Card-Drops überqueren die Run-Grenze, aber ADR-0003 kennt sie nicht.** ADR-0003 §6 nennt 1. **Card-Drops überqueren die Run-Grenze, aber ADR-0003 kennt sie nicht.** ADR-0003 §6 nennt
@@ -423,14 +518,19 @@ widersprechen oder an denen eine Frage niemandem gehört. Sie stehen hier, statt
zuerst die Ausnahmeliste in ADR-0003 §6 erweitern — nicht stillschweigend ein zweites System zuerst die Ausnahmeliste in ADR-0003 §6 erweitern — nicht stillschweigend ein zweites System
schreiben lassen. schreiben lassen.
2. **Die Slot-Kurve gehört niemandem, ist aber Abnahmebedingung.** #11 entscheidet „Start bei 1, 2. **Die Slot-Kurve gehört niemandem, ist aber Abnahmebedingung.** — **Entschieden, siehe Feature 10
wächst auf 5, die Slot-Zahl ist selbst Progression" und bindet Slot 2 ausdrücklich ans und Feature 9.** *Der Befund bleibt stehen, weil er zeigt, wo die Lücke herkam.* #11 entscheidet
Abnahmekriterium des Slice. **Wodurch** ein Slot freigeschaltet wird, sagt niemand: nicht #11 „Start bei 1, wächst auf 5, die Slot-Zahl ist selbst Progression" und bindet Slot 2 ausdrücklich
(schiebt es an #8/#10), nicht #8 (die vier freien Zahlen enthalten keine Slot-Kurve), nicht die ans Abnahmekriterium des Slice. **Wodurch** ein Slot freigeschaltet wird, sagte niemand: nicht
Karte (der Fog nennt nur den Meta-Screen-Aufbau). `card_slots: u8` ist in ADR-0003 persistiert, #11 (schiebt es an #8/#10), nicht #8 (die vier freien Zahlen enthalten keine Slot-Kurve), nicht
aber es gibt keinen Mechanismus, der ihn erhöht. Das ist keine Zahl, die man beim Tunen findet — die Karte (der Fog nennt nur den Meta-Screen-Aufbau). `card_slots: u8` ist in ADR-0003
es ist eine unentschiedene Mechanik: Kauf aus Meta-Währung? Wellen-Meilenstein? Boss-Zahl? Sie persistiert, aber es gab keinen Mechanismus, der ihn erhöht.
blockiert das Abnahmekriterium und muss vor Feature 9 entschieden werden. **Auflösung:** Slots werden **aus der Meta-Währung im Workshop gekauft**, neben den
Stat-Upgrades, mit derselben exponentiellen Kostenform. Kein zweiter Freischalt-Mechanismus,
keine Meilenstein-Schwelle. Der Grund ist nicht Sparsamkeit, sondern dass daraus die
Meta-Entscheidung des Slice wird: Breite gegen Stärke aus einem Topf. Die Randbedingung aus #11
gilt unverändert weiter und macht den ersten Slot-Preis zu einer gebundenen, nicht zu einer
freien Zahl — siehe „Offen". Was diese Auflösung neu aufwirft, steht unten als Punkt 16.
3. **Die Ticket-Überschrift von #8 verspricht 10–20 Minuten, die Entscheidung liefert 6–9.** #8 3. **Die Ticket-Überschrift von #8 verspricht 10–20 Minuten, die Entscheidung liefert 6–9.** #8
heißt „Wellen- und Economy-Kurve für einen **10–20-Minuten-Run**", sein erster Kommentar heißt „Wellen- und Economy-Kurve für einen **10–20-Minuten-Run**", sein erster Kommentar
@@ -449,9 +549,10 @@ widersprechen oder an denen eine Frage niemandem gehört. Sie stehen hier, statt
Event-Cap (Boss). Die Regel bleibt richtig, ihr Anwendungsbereich ist ein Viertel so groß wie Event-Cap (Boss). Die Regel bleibt richtig, ihr Anwendungsbereich ist ein Viertel so groß wie
beschrieben — wer sie liest, baut sonst eine Klassifizierung für Klassen, die es nicht gibt. beschrieben — wer sie liest, baut sonst eine Klassifizierung für Klassen, die es nicht gibt.
5. **~24 Shop-Zeilen stehen gegen 12 Stats.** Der HUD-Kommentar in #10 leitet aus 8 Zeilen × 3 Tabs 5. **~24 Shop-Zeilen stehen gegen 12 Stats.** — **Entschieden, siehe Feature 6 und den Nachtrag zu
ADR-0002 §3.** *Der Befund bleibt stehen.* Der HUD-Kommentar in #10 leitet aus 8 Zeilen × 3 Tabs
„etwa 24 In-Run-Upgrades" ab und macht daraus eine Design-Aussage („der Spieler kann in einem Run „etwa 24 In-Run-Upgrades" ab und macht daraus eine Design-Aussage („der Spieler kann in einem Run
nicht alles kaufen"). ADR-0002 §3 kennt aber insgesamt **12** Stats, und davon gehören nicht alles kaufen"). ADR-0002 §3 kannte damals aber insgesamt **12** Stats, und davon gehören
`UltimateCooldown` und `WaveCooldown` nicht in die drei Shop-Tabs. Die Aufteilung ergibt grob `UltimateCooldown` und `WaveCooldown` nicht in die drei Shop-Tabs. Die Aufteilung ergibt grob
Attack 5 (`Damage`, `AttackInterval`, `Range`, `CritChance`, `CritFactor`), Defence 4 Attack 5 (`Damage`, `AttackInterval`, `Range`, `CritChance`, `CritFactor`), Defence 4
(`MaxHealth`, `DefensePercent`, `DefenseFlat`, `HealthRegen`) und **Utility 1** (`CoinGain`). Ein (`MaxHealth`, `DefensePercent`, `DefenseFlat`, `HealthRegen`) und **Utility 1** (`CoinGain`). Ein
@@ -460,8 +561,18 @@ widersprechen oder an denen eine Frage niemandem gehört. Sie stehen hier, statt
die keine Stats aus ADR-0002 sind, und dann ist ADR-0002 §3 unvollständig. Beides ist eine die keine Stats aus ADR-0002 sind, und dann ist ADR-0002 §3 unvollständig. Beides ist eine
Entscheidung, keine Detailfrage. Entscheidung, keine Detailfrage.
Verschärfend: #3 empfiehlt für Utility ausdrücklich „stark vereinfachen", weil das Utility-System Verschärfend: #3 empfiehlt für Utility ausdrücklich „stark vereinfachen", weil das Utility-System
des Originals an Labs und Perks hängt — beides im Nebel. Der Utility-Tab ist damit der schwächste des Originals an Labs und Perks hängt — beides im Nebel. Der Utility-Tab war damit der schwächste
Teil der HUD-Struktur. Teil der HUD-Struktur.
**Auflösung:** Der zweite Ast wird gewählt — ADR-0002 §3 **war** unvollständig und ist um vier
Utility-Stats gewachsen (`CoinsPerWave`, `CritCoinGain`, `FreeUpgradeChance`, `MetaGain`; der
Nachtrag ist in der ADR als solcher markiert). Utility ist damit das, was #3 im Referenzspiel
gefunden hat: ein Ökonomie-Multiplikator, kein Kampf-Stat. Der erste Ast trägt trotzdem mit:
Bei 5 + 4 + 4 = 13 belegten von ~24 möglichen Zeilen füllt das Panel seine Zeilen nicht aus, und
**die Folgerung „der Spieler kann in einem Run nicht alles kaufen" folgt nicht mehr aus dem
Layout.** Sie muss aus der Kostenkurve kommen (#8, Tuning-Zielgröße „Zeit zwischen zwei
bezahlbaren Käufen") oder fallen. Punkt 6 unten hatte für dieselbe Kette schon festgehalten, dass
von der HUD-Rechnung nur noch die Zahl 24 übrig ist — jetzt ist auch die keine Design-Aussage
mehr, sondern eine Layout-Obergrenze.
6. **Die „12–17 Kaufmomente" sind zweimal überholt, stehen aber noch in der Kette.** Der 6. **Die „12–17 Kaufmomente" sind zweimal überholt, stehen aber noch in der Kette.** Der
HUD-Kommentar (13:38) rechnet mit 12–17 Kaufmomenten aus einer 20-Minuten-Session, #8 (13:51) HUD-Kommentar (13:38) rechnet mit 12–17 Kaufmomenten aus einer 20-Minuten-Session, #8 (13:51)
@@ -491,14 +602,20 @@ widersprechen oder an denen eine Frage niemandem gehört. Sie stehen hier, statt
**eine** Boss-Kill pro Run in der Frühphase der einzige garantierte Drop überhaupt — er trägt **eine** Boss-Kill pro Run in der Frühphase der einzige garantierte Drop überhaupt — er trägt
Meta-Währung und Card-Chance gleichzeitig. Meta-Währung und Card-Chance gleichzeitig.
9. **Gegner-gegen-Gegner-Kollision ist seit #4 offen und steht auf keiner Liste.** #4 und die 9. **Gegner-gegen-Gegner-Kollision ist seit #4 offen und steht auf keiner Liste.** —
Cap-Analyse nennen sie als „offene Architekturfrage": Stapeln zulassen oder Spatial Grid mit **Entschieden, siehe [ADR-0004](adr/0004-spatial-grid-fuer-gegner-nachbarschaft.md).** *Der
weicher Separation. Bei tausenden radial konvergierenden Körpern kommen O(n²) und ein Stau am Befund bleibt stehen.* #4 und die Cap-Analyse nannten sie als „offene Architekturfrage": Stapeln
Turmring zusammen, den keine DPS-Menge auflöst. Sie steht **nicht** im Fog der Karte, in keiner zulassen oder Spatial Grid mit weicher Separation. Bei tausenden radial konvergierenden Körpern
ADR und in keinem der offenen Punkte, die diese Spec zugewiesen bekommen hat. Sie entscheidet kommen O(n²) und ein Stau am Turmring zusammen, den keine DPS-Menge auflöst. Sie stand **nicht**
aber, ob 10 000 Gegner überhaupt spielbar sind, und sie ist teuer nachzurüsten. im Fog der Karte, in keiner ADR und in keinem der offenen Punkte, die diese Spec zugewiesen
*Vorschlag zur Auflösung, nicht Entscheidung:* Stapeln zulassen — bei Programmer-Art in 2D bekommen hat.
vertretbar, und der Spatial Grid ist der teure Weg, der jederzeit nachrüstbar bleibt. *Der Vorschlag dieser Spec war „Stapeln zulassen" — er ist verworfen worden.* Nicht wegen der
Separation, sondern weil das Grid **drei weitere Konsumenten** hat, die bisher jeder für sich
über die Gegnermenge laufen wollten: die Zielauswahl in `tower_fire` (laut #4 der nächste Hotspot
nach dem Rendering), der geteilte Reichweiten-Scan aus #12 und die Projektil-Kollision. Genau der
Scan, dessen Siebenfach-Ausführung #12 als Bruchstelle benannt hat, wird damit zu einer
Grid-Abfrage. Der Rebuild ist gegen die Messungen aus #4 gerechnet, nicht geschätzt: ≤0,10 ms bei
`HARD_ENEMY_CAP`, also ≤0,6 % des Frame-Budgets.
10. **Ziel-Limit für Flächeneffekte und gebatchter Massentod haben keine ADR.** Die Cap-Analyse 10. **Ziel-Limit für Flächeneffekte und gebatchter Massentod haben keine ADR.** Die Cap-Analyse
ordnet beides ausdrücklich „der Architektur" zu, aber ADR-0002 regelt Stats und ordnet beides ausdrücklich „der Architektur" zu, aber ADR-0002 regelt Stats und
@@ -523,3 +640,49 @@ widersprechen oder an denen eine Frage niemandem gehört. Sie stehen hier, statt
Slice hat nur das In-Run-HUD (entworfen) und den Meta-Screen (offen). Diese Spec hängt die Slice hat nur das In-Run-HUD (entworfen) und den Meta-Screen (offen). Diese Spec hängt die
Meldung an den Meta-Screen — womit der offene Meta-Screen-Entwurf eine Anforderung erbt, die Meldung an den Meta-Screen — womit der offene Meta-Screen-Entwurf eine Anforderung erbt, die
aus einer ganz anderen Ecke kommt. aus einer ganz anderen Ecke kommt.
### Neu aufgefallen beim Einarbeiten der drei Entscheidungen (2026-08-10)
14. **Die `points`-Stats mit Basis 0 rechnen sich in ADR-0002 auf null.** — **Behoben, siehe
ADR-0002 §1.** *Der Befund bleibt stehen, weil er ein ausgelieferter Defekt gewesen wäre, kein
Beobachtungsposten.* Die `Direct`-Formel aus ADR-0002 §1 lautete
`(base + flat) × (1 + clamp(points)) × mult`. Die Stat-Tabelle in §3 wies `CritChance` und
`DefensePercent` aber `base = 0` **und** `points` als einzigen erwarteten Akkumulator zu.
Eingesetzt: `(0 + 0) × (1 + 0,30) × 1 = 0`. Crit Chance hätte im Spiel **nie über 0 % steigen
können**, egal wie viele Workshop-Level, Cards und In-Run-Upgrades zusammenkommen — und weder
Compiler noch ein Formeltest hätten es angezeigt. Es ist exakt dieselbe Falle, die die ADR zwei
Absätze weiter unten für `HealthRegen` und `DefenseFlat` benennt und begründet, nur dass sie dort
für `mult` gezogen wurde und für `points` nicht. Aufgefallen ist sie beim Einordnen des neuen
`FreeUpgradeChance`, der sich korrekt an die Form von `CritChance` gehalten und den Fehler
deshalb geerbt hat.
**Behebung:** ADR-0002 §1 hat einen dritten Anwendungsmodus **`Fraction`** bekommen —
`clamp((base + flat + points) × mult)` — für Stats, die selbst ein Anteil sind. `CritChance`,
`DefensePercent` und `FreeUpgradeChance` stehen jetzt dort; an ihren `base`-, „Erwartet"- und
`Clamp`-Spalten hat sich nichts geändert. Es ist ein dritter Wert desselben Feldes, kein zweites
Bit; drei billigere Wege sind in der ADR geprüft und mit Begründung verworfen. Eine Prüfregel und
ein fünfter Revisions-Auslöser verhindern die Wiederkehr, das durchgerechnete Beispiel der ADR
trägt den Fall jetzt als Rechnung, und `Interval` ist nicht betroffen.
15. **Nicht-Konvertierbarkeit ist bisher nur in eine Richtung ausgesprochen.** Issue #1 und #11
begründen die zwei Währungen damit, „dass eine In-Run-Ausgabe keinen Meta-Fortschritt kostet" —
also `Meta → Coins`. Die Gegenrichtung hat nie jemand benannt: Ein aus `Coins` gekauftes
In-Run-Upgrade, das die Meta-Drop-Chance oder die Meta-Menge erhöht, wandelt `Coins` in
Meta-Fortschritt und hebelt die Trennung genauso aus, nur unauffälliger. Der Fall wird real,
sobald der Utility-Tab Ökonomie-Upgrades trägt (Feature 6) — er ist genau der Tab, in den so ein
Upgrade thematisch gehören *würde*. Diese Spec zieht die Konsequenz oben ein (`MetaGain` ist
Workshop-only, mit einer Akzeptanzzeile in Feature 6 und einer Zeilen-Anmerkung in ADR-0002 §3),
aber die **Regel selbst** steht damit an einer Stat-Tabelle statt in der Entscheidung, aus der
sie folgt. Wer die Zwei-Währungs-Entscheidung liest, findet die zweite Richtung dort nicht.
16. **Slots aus der Meta-Währung erzeugen genau die Konkurrenz, die #11 für Cards ausgeschlossen
hat.** #11 verwirft eine gemeinsame Währung für Cards mit dem Argument: „beide Achsen
konkurrieren dann linear um denselben Topf, und niemand kauft Cards, bis der Workshop ausgereizt
ist — das Original verhindert das ausschließlich über Nicht-Fungibilität, nicht über Preise."
Die Slot-Entscheidung (Punkt 2) tut fast das: Sie stellt die Card-Achse — nicht die Karten,
aber ihre Breite — in dieselbe Kaufwarteschlange wie die Stat-Upgrades. Zwei Unterschiede halten
sie tragfähig: Cards selbst fallen weiterhin gratis, die Achse ist also nie hinter dem Shop
verschlossen; und Slot 2 muss laut #11 früh bezahlbar sein, was das Versagensmuster „niemand
kauft, bis der Workshop ausgereizt ist" für den einen Kauf ausschließt, der im Abnahmekriterium
steht. Für Slot 3 bis 5 gilt dieser Schutz **nicht** — dort ist das Argument aus #11 unverändert
gültig, und ob die Slots 3–5 im Slice je gekauft werden, ist eine offene Frage an die
Kostenkurve, keine an die Mechanik.