Compare commits

...

13 Commits

Author SHA1 Message Date
d9688c74dc docs: record project lessons that are not derivable from code or ADRs 2026-08-10 16:09:57 +02:00
158d10b498 chore: track project instructions 2026-08-10 15:53:48 +02:00
524f3fdaef docs: chart and specify the vertical slice
Schließt die /wayfinder-Reise ab (Karte: Issue #1). Enthält CONTEXT.md,
die Vertical-Slice-Spec, vier ADRs, den In-Run-HUD-Entwurf, den gesammelten
Wiki-Primärtext und den Perf-Spike, der das Entity-Budget gemessen hat.

Kein Produktionscode: src/main.rs bleibt das Bevy-Scaffold.
2026-08-10 15:07:59 +02:00
6e1df29364 refactor: drop spent ablation knobs from perf spike, trim ADR-0002 consequences 2026-08-10 15:07:36 +02:00
68a587b948 docs: add ADR-0004 spatial grid, fix Fraction stat mode, extend spec 2026-08-10 14:32:22 +02:00
a9486d745f docs: add vertical slice spec 2026-08-10 14:02:39 +02:00
cb13adce33 docs: add ADR-0003 on save format, location and migration 2026-08-10 13:46:03 +02:00
1d8175cb5a docs: add in-run HUD mockup 2026-08-10 13:38:25 +02:00
0c812e3c1c docs: add ADR-0002 on stat and modifier pipeline 2026-08-10 13:19:24 +02:00
6fa10f72de docs: add ADR-0001 on balancing data location 2026-08-10 13:02:57 +02:00
40a19f39db test: add throwaway perf spike measuring 2D entity budget 2026-08-10 12:38:20 +02:00
8f057e2991 docs: sharpen wave definition and flag Coins naming collision 2026-08-10 12:38:20 +02:00
445f133c90 docs: add CONTEXT.md with glossary and slice scope 2026-08-10 10:47:29 +02:00
13 changed files with 2954 additions and 0 deletions

13
CLAUDE.md Normal file
View File

@@ -0,0 +1,13 @@
# InfiniTower
Bevy-Spiel in Rust (edition 2024, bevy 0.19).
## Agent skills
### Issue tracker
Issues und Specs leben als **Gitea**-Issues auf `gitlab.hive-brain.de/MDietermann/InfiniTower` — trotz des Hostnamens kein GitLab. CLI ist `tea`, nicht `glab`. Siehe `docs/agents/issue-tracker.md`.
### Domain docs
Single-context: `CONTEXT.md` und `docs/adr/` im Repo-Root, beide werden erst bei Bedarf angelegt. Siehe `docs/agents/domain.md`.

33
CONTEXT.md Normal file
View File

@@ -0,0 +1,33 @@
# CONTEXT
Single-Context-Dokument für InfiniTower. Legt die Begriffe fest, bevor sie in Code und Spec auseinanderlaufen. Siehe `docs/agents/domain.md` für die Konsum-Regeln (Glossar-Vokabular verbindlich, ADR-Konflikte offen benennen).
## Glossar
| Begriff | Code-Bezeichner | Definition |
|---|---|---|
| Run | `Run` | Ein Durchlauf von Welle 1 bis zum Tod des Turms — die abgrenzende Zeiteinheit für In-Run-Upgrades, die mit dem Run-Ende verfallen. |
| Welle | `Wave` | Ein Zeitabschnitt aus zwei separat getakteten Phasen — Spawn-Phase (Gegner erscheinen) und Cooldown-Phase (keine neuen Spawns, aber Gegner laufen und kämpfen weiter). Die nächste Welle beginnt erst, wenn beide Timer abgelaufen sind, nicht wenn alle Gegner besiegt sind — Gegner können ihre Welle überdauern. Wellen folgen einer einzigen endlosen Schwierigkeitskurve ohne Tiers. |
| Turm | `Tower` | Die einzige spielereigene Einheit, fest in der Mitte der Arena. Schießt selbstständig (idle) auf Gegner; der Spieler wirkt nur über In-Run-Upgrades, Workshop, Cards und die Ultimate auf ihn ein. |
| Gegner | `Enemy` | Eine gegnerische Einheit, die innerhalb einer Welle radial von außen auf den Turm zuläuft. |
| Coins | `Coins` | Die Ressource, die während eines Runs durch besiegte Gegner verdient wird und im Run als Währung dient (u. a. für In-Run-Upgrades) — getrennt von der Ressource, die die permanente Meta-Progression (Workshop) finanziert. Entspricht in seiner Rolle dem `Cash` des Referenzspiels, nicht dessen gleichnamigem `Coins` (dort die Meta-Währung) — Namenskollision beim Lesen von Wiki-Material beachten. |
| In-Run-Upgrade | `RunUpgrade` | Ein innerhalb eines Runs aus Coins gekauftes Upgrade des Turms, das mit dem Run-Ende verfällt — im Gegensatz zu den permanenten Workshop-Upgrades. |
| Workshop | `Workshop` | Meta-Progression zwischen Runs: permanente Stat-Upgrades des Turms. Wirkt über Runs hinweg, nicht innerhalb eines einzelnen Runs. |
| Card | `Card` | Meta-Progression-Element neben dem Workshop; Teil der zwischen Runs bestehenden Fortschrittsschicht des Slice. Mechanik über "Meta-Progression" hinaus noch nicht festgelegt (siehe Offen). |
| Ultimate | `Ultimate` | Die eine aktive Fähigkeit im Slice — im Gegensatz zum sonst rein passiven Dauerfeuer des Turms. Ob sie manuell vom Spieler ausgelöst wird oder automatisch nach Idle-Logik feuert, ist offen (siehe Offen, #9). Dient als Muster für spätere weitere Ultimates. |
| Slice | `Slice` (Branch-Präfix `slice/`) | Der aktuell abgegrenzte Vertical-Slice-Funktionsumfang des Lernprojekts, wie in Issue #1 kartiert. Grenze zwischen "drin" und "raus" siehe Slice-Scope unten. |
## Slice-Scope
Drin ist ein Turm in der Mitte einer 2D-Arena, der über eine einzige endlose Wellenkurve mit radial anlaufenden Gegnern idle schießt, während der Spieler über In-Run-Upgrades, Workshop, Cards und eine einzelne Ultimate eingreift. Save/Load ist drin, Offline-Progress nicht. Die Umsetzung ist Desktop-nativ mit `bevy_sprite` und orthographischer Kamera, in Programmer-Art ohne Art-Direction. Raus sind Pfadfindung, Turm-Platzierung und Maze-Building, ebenso Tiers, Labs, Modules und Perks. Raus ist außerdem alles Server-seitige — Guilds, Tournaments, Cloud-Saves, Accounts, IAP — sowie Mobile- und Web-Ports, die erst nach dem Slice folgen.
## Wayfinder-Karte
Der vollständige Zuschnitt des Vertical Slice ist in Issue #1 kartiert: https://gitlab.hive-brain.de/MDietermann/InfiniTower/issues/1
## Offen
- Card: Die Karte definiert Cards nur als Teil der Meta-Progression neben dem Workshop, ohne Mechanik (Deck? Einzelauswahl? Drop-Quelle?). Muss vor Umsetzung geklärt werden (voraussichtlich Ticket #8 oder eigenes Spec-Ticket).
- Card-Währung: Offen ist, ob Cards im Slice eine eigene dritte Währung bekommen oder als Drop verteilt werden (z. B. Boss-Kill, neuer Wellen-Rekord) — im Referenzspiel kosten Cards eine dedizierte Währung (Gems), deren Quellen (Ads, IAP) im Slice entfallen. Hängt mit dem Card-Eintrag zusammen (siehe oben).
- Meta-Währung: Der Slice hat aktuell keinen benannten Begriff für die Ressource, die den Workshop finanziert (im Referenzspiel trägt diese Ressource den Namen `Coins`, der bei uns bereits für die In-Run-Währung vergeben ist). Muss geklärt werden, bevor Workshop-Kosten spezifiziert werden.
- Ultimate-Auslöser: Unklar, ob die Ultimate manuell vom Spieler ausgelöst wird oder automatisch nach Idle-Logik feuert. Klärt Ticket #9; die Antwort berührt das Kern-Gefühl "Idle".

349
docs/WikiData.md Normal file
View File

@@ -0,0 +1,349 @@
# Wiki/Enemies
Different Enemies
In the game there are 4 categories of enemies: Normal, Elite, Fleet and Boss. The max spawn cap at any one moment is 150 comprised of caps of 120 Normal enemies, 20 elites, and 10 bosses. Fleet Enemies occupy the Normal enemy spawn cap.
Shared characteristics
Enemies gain 4% more damage per hit(Heat-up Mechanic)
Enemies Lose 50% of their coin value if they are alive for more than 3 waves(Coin decay Mechanic)
Enemy Mass increases by 4% each wave they are alive
Normal Enemies
Normal enemies include Basic, Fast, Range, Tank and Protector. These enemies are in a single group and their chance to spawn in a wave will add to 100% for each wave.
Protectors have special spawn rules with only 1 per wave and a cooldown between the next chance to spawn. There is a limit of 8 at once.
Bosses
Bosses have a 0% spawn rate on normal waves and 100% on boss waves. Boss waves are every 10 waves except when a battle condition reduces the waves. Bosses are immune to Orbs(Excluding Boss Orb Hit Lab), Death Ray, Shockwave and Black Hole. Bosses drop module shards and reroll shards which are further increased after researching the appropriate module-based labs.
Elite Enemies
Elite enemies include Vampire, Ray and Scatter. There is a limit of only 1 spawn per wave or up to 2 at higher waves. All Elite enemies are immune to Orbs, Death Ray, Shockwave and Black Hole. Elite enemies will drop Elite Cells when defeated. The on screen Limit per Elite type is 8. Scatter children do not count towards this limit.
Elite enemies share a total % spawn split between the 3 types, the spawn % are: 1%, 4%, 9%, 16%, 25%, 36%, 49%, 64%, 81% and 100%. The table below shows the percent per elite as shown in game. There is a hidden 0.33 repeating when the value is not divisible by 3.
The displayed chance jumps from 33% to 100% once the background chance for a second elite per wave also reaches 100%. Meaning if the chance says 33%, you’re guaranteed at least one, possibly two elites per wave. If it says 100%, you’re guaranteed two elite per wave.
Single Elite Enemy spawns
Table below shows the rate when 1 elite enemy can spawn.
% T1 T2 T3 T4 T5 T6 T7 T8 T9 T10 T11 T12 T13 T14 T15 T16 T17 T18 T19 T20 T21
0% 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0.33% 500 450 405 365 328 295 266 239 215 194 174 157 141 127 114 41 37 33 30 27 24
1.33% 1000 900 810 729 656 590 531 478 430 387 349 314 282 254 229 102 92 83 75 68 61
3% 1500 1350 1215 1094 984 886 797 717 646 581 523 471 424 381 343 205 185 166 150 135 122
5% 2000 1800 1620 1458 1312 1181 1063 957 861 775 697 628 565 508 458 308 277 250 225 203 182
8% 3000 2700 2430 2187 1968 1771 1594 1435 1291 1162 1046 941 847 763 686 411 370 333 300 270 243
12% 4000 3600 3240 2916 2624 2362 2126 1913 1722 1550 1395 1255 1130 1017 915 617 555 500 450 405 365
16% 5000 4500 4050 3645 3281 2952 2657 2391 2152 1937 1743 1569 1412 1271 1144 823 741 667 600 540 486
21% 6000 5400 4860 4374 3937 3543 3189 2870 2583 2325 2092 1883 1695 1525 1373 1029 926 833 750 675 608
27% 7000 6300 5670 5103 4593 4133 3720 3348 3013 2712 2441 2197 1977 1779 1601 1235 1111 1000 901 811 729
33% 8000 7200 6480 5832 5249 4724 4252 3826 3444 3099 2789 2510 2259 2033 1830 1441 1297 1167 1051 946 851
Double Elite Enemy Spawns
The below table shows the rate when a 2nd elite enemy can spawn.
% T1 T2 T3 T4 T5 T6 T7 T8 T9 T10 T11 T12 T13 T14 T15 T16 T17 T18 T19 T20 T21
0.33% 8000 7200 6480 5832 5249 4724 4252 3826 3444 3099 2789 2510 2259 2033 1830 1441 1297 1167 1051 946 851
1.33% 9000 8100 7290 6561 5905 5314 4783 4305 3874 3487 3138 2824 2542 2288 2059 1647 1482 1334 1201 1081 973
3% 10000 9000 8100 7290 6561 5905 5314 4783 4305 3874 3487 3138 2824 2542 2288 1853 1667 1500 1351 1216 1094
5% 11000 9900 8910 8019 7217 6495 5846 5261 4735 4262 3835 3452 3107 2796 2516 2058 1853 1667 1501 1351 1216
8% 12000 10800 9720 8748 7873 7086 6377 5740 5166 4649 4184 3766 3389 3050 2745 2264 2038 1834 1651 1486 1337
12% 13000 11700 10530 9477 8529 7676 6909 6218 5596 5036 4533 4080 3672 3304 2974 2470 2223 2001 1801 1621 1459
16% 14000 12600 11340 10206 9185 8267 7440 6696 6027 5424 4881 4394 3954 3559 3203 2676 2408 2168 1951 1756 1580
21% 15000 13500 12150 10935 9841 8857 7972 7174 6457 5811 5230 4708 4236 3813 3432 2882 2594 2334 2101 1891 1702
27% 16000 14400 12960 11664 10497 9448 8503 7653 6887 6199 5579 5022 4519 4067 3660 3088 2779 2501 2251 2026 1824
33% 17000 15300 13770 12393 11153 10038 9034 8131 7318 6586 5928 5336 4801 4321 3889 3294 2964 2668 2402 2161 1945
Fleet Enemies
Disclaimer: This article covers information gathered by the community, and details from the V28.3 patch notes. Some information may be inaccurate.
Fleet Enemies include Saboteur, Commander, and Overcharge and can be encountered on T14 and beyond. Fleet enemies are Immune to Orbs, Death Ray, shockwave, knockback, Black Hole Damage, and Black Hole Pull. They have 50% resistance to Chrono Field, Chrono Loop(CF+), Thunder Bot, Poison Swamp Stun, and Land Mine Stun. The Plasma Cannon card cannot target fleet enemies. Fleet enemies do not collide with other enemies. Commander and Overcharge will both enter the Tower Range before stopping while Saboteur will fully approach the tower. They have an 85% resistance to Thorns, and Thorn damage will only deal a percentage of their current health instead of their maximum.
Fleet Enemies start with a very slow attack rate at Wave 1, which reaches its maximum on Wave 4000.
Overview
Fleet Enemy Overview
Fleet Enemy Description Speed Health Attack
Saboteur Selects an ultimate weapon on tower hit, decreasing its upgrade level by 1 and deactivating the ultimate weapon if it reaches 0. Wall hits do not affect ultimate weapons. 2x 20x 0*
Commander Empowers enemies within an area, providing a 1.15x boost to health, mass, and speed with each pulse, capped to 8 boosts per enemy. This pulse does not affect bosses. 0.5x 20x N/A
Overcharge Shoots a returning projectile at the Tower, dealing 1.4x damage with each hit. Fires a final projectile on death. 1x 20x Wave 1**
*Saboteur still registers as attacking for thorn effects, but does not proc Energy Shield or break The Wall.
**Overcharge damage is locked to Wave 1's damage for that tier.
Fleet Enemy Spawns
Fleet enemies will always spawn when they should. Wave skipping a wave where a Fleet Enemy would spawn will spawn the fleet enemy. Fleet enemies spawn with a group of their related enemies. The count of spawned enemies appears to be a random number in the 10-14 range. Fleet spawn time is random within the wave it will spawn. As of v28.3, the Fleet enemy spawn buddies have their own cap, so they are guaranteed and can raise the overall enemy count.
Fleet Companions
Fleet Companions
Overcharge Ranged
Commander Tank
Saboteur Fast
Fleets at Higher Waves on All Tiers
Fleets have been added to all tiers. Tier 1 will have Fleets starting at wave 15,000, and each subsequent tier will have Fleets appear 250 waves earlier (T2 = 14,750, T3 = 14,500, etc). Once they appear, they will spawn every 100 waves.
Low Tier Spawning
Tier First Spawn Wave Spawn Frequency
Waves / Fleet
Tier 1 15,000 100
Tier 2 14,750 100
Tier 3 14,500 100
Tier 4 14,250 100
Tier 5 14,000 100
Tier 6 13,750 100
Tier 7 13,500 100
Tier 8 13,250 100
Tier 9 13,000 100
Tier 10 12,750 100
Tier 11 12,500 100
Tier 12 12,250 100
Tier 13 12,000 100
Fleets in Battle Condition Tiers and Tournaments
Bonus spawns are a continuation of the high wave fleets from lower tiers. The bonus ones will not have drops if there's a regular fleet around.
Spawning Behavior
Tier First Wave Frequency
Wave/fleet
Bonus Spawn
First Wave
Frequency
Wave / Fleet
Tier 14+
(Legends prior to V27.3)
895 30 N/A N/A
Tier 14 2495 1000 11,750 100
Tier 15 1495 750 11,500 100
Tier 16 995 500 11,250 100
Tier 17 495 250 11,000 100
Tier 17+ (Legends from V27.3+) 895 30
Tier 18 95 100 10,750 100
Tier 19 45 50 10,500 100
Tier 20 5 10 waves/1 fleet 10,250 100
Tier 21 5 10 waves/2 fleets 10,000 100
Tier 22 5 10 waves/3 fleets 9,750 100
Tier 23 5 10 waves/3 fleets 9,500 100
Tier 24 5 10 waves/3 fleets 9,250 100
Fleet Rewards
Upon death, fleet enemies will drop reroll shards or module shards.
Chance Drop
80% Reroll Shards
5% Cannon Module Shards
5% Armor Module Shards
5% Generator Module Shards
5% Core Module Shards
# Workshop
## Workshop Upgrades
### Attack Upgrades
Damage • Attack Speed • Critical Chance • Critical Factor • Range • Damage Per Meter • Multishot Chance • Multishot Targets • Rapid Fire Chance • Rapid Fire Duration • Bounce Shot Chance • Bounce Shot Targets • Bounce Shot Range • Super Crit Chance • Super Crit Mult • Rend Armor Chance • Rend Armor Mult
### Defense Upgrades
Health • Health Regen • Defense % • Defense Absolute • Thorn Damage • Lifesteal • Knockback Chance • Knockback Force • Orb Speed • Orbs • Shockwave Size • Shockwave Frequency • Land Mine Chance • Land Mine Damage • Land Mine Radius • Death Defy • Wall Health • Wall Rebuild
### Utility Upgrades
Cash Bonus • Cash / Wave • Coins / Kill Bonus • Coins/Wave • Free Attack Upgrade • Free Defense Upgrade • Free Utility Upgrade • Interest / Wave • Recovery Amount • Max Recovery • Package Chance • Enemy Attack Level Skip • Enemy Health Level Skip
### Ultimate Weapons
Chain Lightning • Smart Missiles • Death Wave • Chrono Field • Inner Land Mines • Golden Tower • Poison Swamp • Black Hole • Spotlight
## Workshop Enhancement
### Attack Upgrades
Damage • Rend Max • Critical Factor • Damage/Meter • Super Crit Mult • Attack Speed
### Defense Upgrades
Health • Health Regen • Wall Health • Land Mine Damage • Defense Absolute • Orbs Size
### Utility Upgrades
Cash Bonus • Coin Bonus • Cells/Kill Bonus • Free Upgrades • Recovery Packages • Enemy Level Skip
# Cards
Description
Cards are items that can be bought for 20 Gems, which give a special effect based on what card you have equipped. You can only have a certain amount of cards equipped, depending on how many slots you have. A card can have a max level of 7.
Locked Cards
Cards can have a locked icon. When in a run, cards with this icon can never be removed until the round ends. It is possible to switch to other card presets containing all the same locked cards during a run, but any differences in locked cards will prevent the preset from being selected.
When a boss or fleet enemy is alive, all cards become locked and cannot be changed until all bosses/fleet enemies are killed.
Card Slots
The amount of cards you can have equipped depends on how many slots you have. The max amount of slots is 22 through Gems (for a total of 58,400 Gems), but 28 in total with the Key upgrades in the harmony tech tree.
Costs
Slot Gem Cost
1 Free
2 50
3 100
4 200
5 300
6 400
7 500
8 600
9 750
10 1000
11 1200
12 1400
13 1600
14 1800
15 2500
16 3500
17 4500
18 5500
19 6500
20 7500
21 8500
22 10000
Maxing A Card
To max a card you need 80 copies of 1 card, costing a total of 1600 Gems. You will obtain copies of cards when buying cards.
Costs Per Star
1-star (unlock): 20 gems
2-star: 3 copies, 40 gems - the unlock card will count as 1/3 toward 2-stars
3-star: 5 copies, 100 gems
4-star: 8 copies, 160 gems
5-star: 12 copies, 240 gems
6-star: 20 copies, 400 gems
7-Star: 32 copies, 640 gems
With a total of 31 cards, 49600 gems are needed to reach max level for all cards.
Buying Cards
Cards can be 1 of 3 rarities: Common, Rare and Epic. When buying cards, the rarity is chosen first then a card is selected within that rarity. The chance of getting each rarity is: Common: 80%, Rare: 17%, epic: 3%.
When card is maxed, you can no longer receive that card. Once all commons have been bought, the chance to buy a rare card is 97%. When all cards are purchased, you can no longer use the buy card button. The event mission to buy [x] cards will complete instantly after all cards are bought.
Milestones
Some cards cannot be bought until they are unlocked in Milestones. If all other cards are maxed before these are unlocked, you will not be able to buy more cards until a Milestone is unlocked. These cards and their unlocks are:
Recovery Package Chance - Rare - Tier 2 Wave 750
Land Mine Stun - Rare - Tier 7 Wave 250
Nuke - Epic - Tier 11 Wave 10
Ultimate Crit - Epic - Tier 14 Wave 50
Area of Effect - Epic - Tier 20 Wave 80
Note: Despite anecdotal evidence, Death Ray DOES NOT have a lower draw rate than standard epics, and no special requirements are needed to draw it.
List of Cards
Common Cards
Rarity Name Description Lv. 1 Lv. 2 Lv. 3 Lv. 4 Lv. 5 Lv. 6 Lv. 7
Common Damage Increase tower damage by [x] 1.50 2.00 2.40 2.80 3.20 3.60 4.00
Common Attack Speed Increase tower attack speed by [x] 1.25 1.40 1.55 1.70 1.85 2.00 2.15
Common Health Increase tower health by [x] 1.50 2.00 2.40 2.80 3.20 3.60 4.00
Common Health Regen Increase tower health regen by [x] / sec 1.40 1.60 1.80 2.00 2.20 2.40 2.60
Common Range Increase tower range by [x] 1.15 1.20 1.25 1.30 1.35 1.40 1.45
Common Cash Increase all cash earned by [x] 1.20 1.40 1.60 1.80 2.00 2.20 2.40
Common Coins Increase all coins earned by [x] 1.15 1.20 1.25 1.30 1.35 1.40 1.45
Common Slow Aura All enemies in tower range speed decreased by [x]% 13% 16% 19% 22% 25% 28% 31%
Common Critical Chance Increase critical chance by [x]% 5% 6% 7% 8% 9% 10% 11%
Common Enemy Balance Increase enemies spawned each wave, cash earned per kill increased by [x] 1.30 1.40 1.50 1.60 1.70 1.80 1.90
Common Extra Defense Increase defense percent by [x]% 5% 6% 7% 8% 9% 10% 11%
Common Fortress Increase defense absolute by [x] 1.30 1.45 1.60 1.75 1.90 2.05 2.20
Rare Cards
Rarity Name Description Lv. 1 Lv. 2 Lv. 3 Lv. 4 Lv. 5 Lv. 6 Lv. 7
Rare Free Upgrades Increases all free upgrade chances per wave by [x]% 4% 5% 6% 7% 8% 9% 10%
Rare Extra Orb A spinning orb with a speed of [x] that destroys enemies on contact (except bosses) .30 .40 .50 .60 .70 .80 0.90
Rare Plasma Canon Fire one big plasma shot at a boss dropping its health by [x]% 30% 34% 38% 42% 46% 50% 54%
Rare Critical Coin If a basic enemy dies from a critical shot it has a chance to drop coins of [x]% 15% 18% 21% 24% 27% 30% 33%
Rare Wave Skip Chance to skip a wave and earning coins and cash equal to the previous wave x1.10 of [x]% 9% 10% 11% 13% 15% 17% 19%
Rare Intro Sprint Waves Increases by 10 each time, for the first [x] waves or up to your highest wave. A Boss spawns every waves and no coins are earned during Intro Sprint. 20 30 40 50 60 80 100
Rare Land Mine Stun Land mines have a 40% chance to stun enemies for [x] sec (except bosses) 1.4 1.8 2.2 2.6 3.0 3.4 3.8
Rare Recovery Package Chance Increase recovery package spawn chance by [x]% 15% 18% 21% 24% 27% 30% 33%
Epic Cards
Rarity Name Description Lv. 1 Lv. 2 Lv. 3 Lv. 4 Lv. 5 Lv. 6 Lv. 7
Epic Death Ray A powerful ray that destroys enemies on contact (except bosses), with a duration of # sec 2.3 sec 2.7 sec 3.1 sec 3.5 sec 3.9 sec 4.4 sec 4.9 sec
Epic Energy Net Fire a special net at a boss immobilizing it for # sec 2.5 sec 2.8 sec 3.1 sec 3.4 sec 3.7 sec 4.0 sec 4.3 sec
Epic Super Tower The tower becomes super for 15 seconds, tower damage increased by [x] (30 sec cooldown) 2.5 2.9 3.3 3.7 4.1 4.5 5.0
Epic Second Wind Revive the tower with half health once per round and creates an invincible shield for [x] sec 10 sec 15 sec 20 sec 25 sec 30 sec 35 sec 40 sec
Epic Demon Mode Once per Round: Activate Demon Mode. Grants 300x Projectile Damage & Invincibility for [x] sec 180 sec 200 sec 220 sec 240 sec 260 sec 280 sec 300 sec
Epic Energy Shield Shield that ignores a single attack, replenishes after [x] min 20 min 18 min 16 min 14 min 12 min 10 min 8 min
Epic Wave Accelerator Reduce the cooldown between waves by [x]% 30% 34% 38% 42% 46% 50% 54%
Epic Berserker Increase damage by [x] of total damage absorbed this round (max of x8 tower damage) 0.8% 0.9% 1.0% 1.1% 1.2% 1.3% 1.4%
Epic Ultimate Crit Ultimate weapons gain a [x]% chance to deal critical damage of (Tower Crit Factor) 1% 1.33% 1.66% 2% 2.33% 2.66% 3%
Epic Nuke Destroys [x] of enemies. 25% 35% 45% 55% 65% 80% 100%
Epic Area of Effect Increase area of effect damage for Inner Land Mine, Poison Swamp, Smart Missile, Flame Bot, and Land Mine by [x] 5% 8% 11% 14% 17% 20% 25%
Card Mastery
When 30 cards are maxed and the player has unlocked the Card Mastery Milestone at Tier 16, Wave 100, Card Masteries are unlocked. Card Masteries can be individually unlocked on the cards page. A card must be equipped to have that mastery effect active. Unlocking a Card Mastery costs a varying amount of stones and unlocks that card's Mastery Labs.
Card Mastery Overview
Below is a table showing the different Card Masteries, their stone costs, and how the effects are amplified with levels:
Card Mastery Description Stone Cost 0 1 2 3 4 5 6 7 8 9
Damage Increases card stat multiplier 750 x1.4 x1.8 x2.2 x2.6 x3 x3.4 x3.8 x4.2 x4.6 x5
Attack Speed Increases card stat multiplier 750 x1.03 x1.06 x1.09 x1.12 x1.15 x1.18 x1.21 x1.24 x1.27 x1.3
Health Increases card stat multiplier 750 x1.2 x1.4 x1.6 x1.8 x2 x2.2 x2.4 x2.6 x2.8 x3
Health Regen Increases card stat multiplier 750 x1.4 x1.8 x2.2 x2.6 x3 x3.4 x3.8 x4.2 x4.6 x5
Range Adds damage per meter bonus multiplier 750 x1.2 x1.4 x1.6 x1.8 x2 x2.2 x2.4 x2.6 x2.8 x3
Cash Adds chance for elites to drop reroll dice 500 0.4% 0.8% 1.2% 1.6% 2% 2.4% 2.8% 3.2% 3.6% 4%
Coins Increases card stat multiplier 1250 x1.03 x1.06 x1.09 x1.12 x1.15 x1.18 x1.21 x1.24 x1.27 x1.3
Slow Aura Reduces enemy attack speed 1000 x1.05 x1.1 x1.15 x1.2 x1.25 x1.3 x1.35 x1.4 x1.45 x1.5
Critical Chance Bonus to Crit Chance, super Crit chance, and Super Crit Factor 750 +1% +2% +3% +4% +5% +6% +7% +8% +9% +10%
Enemy Balance Chance for double elite spawn 1000 6% 12% 18% 24% 30% 36% 42% 48% 54% 60%
Extra Defense Increases card stat multiplier 1000 +0.7% +1.4% +2.1% +2.8% +3.5% +4.2% +4.9% +5.6% +6.3% +7.0%
Fortress Reduces wall rebuild time 750 -10s -20s -30s -40s -50s -60s -70s -80s -90s -100s
Free Upgrades Lock a stat from free ups 500 1 2 3 4 5 6 7 8 9 10
Extra Orb Orb coin bonus 750 x1.04 x1.08 x1.12 x1.16 x1.2 x1.24 x1.28 x1.32 x1.36 x1.4
Plasma Cannon Percent of plasma cannon applied to elites 1250 5% 10% 15% 20% 25% 30% 35% 40% 45% 50%
Critical Coin Percent chance to drop two coins instead of one. [Critical coin only adds a 33% chance to drop 1 coin on Basics. This becomes 33% chance to drop 2 coins on Basics.] 1000 10% 20% 30% 40% 50% 60% 70% 80% 90% 100%
Wave Skip Chance to double wave skip 1000 10% 15% 20% 25% 30% 35% 40% 45% 50% 55%
Intro Sprint Increases how many waves it's active 1250 x1.8 x3.6 x5.4 x7.2 x9 x10.8 x12.6 x14.4 x16.2 x18
Land Mine Stun Chance stunned enemies will miss attacks 1000 2.5% 5% 7.5% 10% 12.5% 15% 17.5% 20% 22.5% 25%
Package Chance Packages have a chance to drop common modules 1000 0.4% 0.8% 1.2% 1.6% 2% 2.4% 2.8% 3.2% 3.6% 4%
Death Ray Death ray partially pierces protector shield 750 5% 10% 15% 20% 25% 30% 35% 40% 45% 50%
Energy Net Damage multi to bosses when trapped and 10s after 750 x2 x4 x6 x8 x10 x12 x14 x16 x18 x20
Super Tower 35% of super tower bonus is applied to UWs, reduces cooldown 1000 -3s -6s -9s -12s -15s -18s -21s -24s -27s -30s
Second Wind Increases HP regen for 400 waves when triggered 1000 x1.9 x2.8 x3.7 x4.6 x5.5 x6.4 x7.3 x8.2 x9.1 x10
Demon Mode Lingering damage multi for 300 waves 1000 x1.5 x2 x2.5 x3 x3.5 x4 x4.5 x5 x5.5 x6
Energy Shield Energy shield activates a blast that repels all enemies back by a percent of tower max range and destroy[s] all enemy projectiles. The charge times of Rays are reset. 1000 5% 10% 15% 20% 25% 30% 35% 40% 45% 50%
Wave Accelerator Increases spawn rate acceleration 1000 110% 120% 130% 140% 150% 160% 170% 180% 190% 200%
Berserker Increases damage cap from berserk to x500 for a limited time after death defy 750 30s 60s 90s 120s 150s 180s 210s 240s 270s 300s
Ultimate Crit Increases UW crit chance 750 +0.3% +0.7% +1.0% +1.3% +1.7% +2.0% +2.3% +2.7% +3.0% +3.3%
Nuke Reduces enemy attack speed for 300 waves after Nuke use 750 5% 10% 15% 20% 25% 30% 35% 40% 45% 50%
Area of Effect Increases Area of Effect radius 1000 2.5% 5% 7.5% 10% 12.5% 15% 17.5% 20% 22.5% 25%
# Waves and Rounds
Rounds and waves are not interchangeable terms. Each has set definitions used throughout the game.
Waves
Waves have 2 phases: length and cooldown. These phases have separate timers and once both timers complete, the next wave will begin. The length is when enemies spawn while the cooldown does not have any spawns. Waves can be tracked on the wave counter next to the tower health bar.
Timers at x1 Speed
Normal Wave
Length Cooldown
26.00 Seconds 9.00 Seconds
*Wave cooldown is halved for tournaments
Rounds
Rounds are the entire run. Rounds start at wave 1 and end when you die. If an ability can be used once per round, it can only be used once until you die.
Tower
The tower sits center of the screen with the workshop upgrades available at the bottom. The workshop menu can be collapsed by clicking the same icon that is currently opened.
Above the workshop is a summary of the tower stats. Damage, Health regen and all coin bonuses are listed above the health bar. The health bar sits above the workshop on the left hand side of the screen. Activation buttons and wall health bar will appear above the tower health bar when available.
Game speed is located above the wave counter.
Enemies
There are 6 enemy type (see: Enemies). Enemy information can be viewed during a round in the Wave Info Menu by clicking the wave counter on the right side of the screen. This menu will show all enemy types and their stats. This includes spawn rate.
Health and damage for basic enemies are located next to the wave counter on the bottom right of the screen.
Currencies
There are 7 main currencies: Cash, Coins, Gems, Power Stones, Medals, Keys, and 5 types of Shards. The top left corner shows 3 currencies: Cash, Coins and Gems. By clicking on the currencies you can switch the display from current owned to currency/min for cash and coins.
Cash is used for upgrades during a round. Cash is earned during rounds from killing enemies, and at the end of each wave. Temporary boots to Cash multipliers can come from Cards, Sub-modules, and Ultimate Weapons.
Coins are used for Workshop Upgrades, Lab Research, and upgrading Modules. Coins are earned during rounds from completing daily and weekly missions, killing enemies, at the end of each wave, participating in Tournaments, and completing Milestones. Temporary boosts to coin multipliers can come from Cards, Modules, Sub-modules, Bots, and Ultimate Weapons. Permanent Coin multipliers can be unlocked in Milestones, purchased for Medals during events, and purchased for real world money.
Gems are used for buying Cards, unlocking Card Slots, rushing Labs, unlocking Lab Slots, & buying Modules. Gems can be earned by watching ads, completing 3rd party offers from the Offer Wall, a daily free Gem bonus, completing daily and weekly Missions, purchased during events in exchange for Medals, are found floating during rounds, and for participating in Tournaments. Gems can also be purchased for real world money.
Power Stones are used for unlocking and upgrading Ultimate Weapons. Stones can be earned for participating in Tournaments, purchased during events in exchange for Medals, and are. Power Stone packs can be purchased for real world money available ~monthly.
Medals are earned for completing Event Missions and spent in the Event Shop. Medals can be spent purchasing other currencies, unlocking Bots, and upgrading Bots.
Keys are earned through placements in the Legends Tier of Tournament. Their use is found in The Vault.
Shards are used for upgrading Modules. The 5 kinds of Shards are Reroll, Attack, Defense, Utility, and Ultimate Weapon. Each type of Shard upgrades it's respective Module, and Reroll Shards are used to reroll Sub-modules. Shards can be earned completing daily missions, shattering common or rare modules, and for participating in Tournaments.
Under gems, Energy Shield charges will be shown when equipped.

View File

@@ -0,0 +1,134 @@
# ADR-0001: Balancing-Daten als Rust-Konstanten hinter einer `Balance`-Resource
- **Status:** Angenommen
- **Datum:** 2026-08-10
- **Betrifft:** Wellenkurven, Kosten und Wirkung von In-Run-Upgrades, Workshop-Upgrade-Kurven, Gegner-Stats pro Archetyp, Card-Effekte
## Kontext
Der Slice braucht einen Ort für seine Balancing-Zahlen. Die Datenmenge ist klein und bekannt:
drei bis vier Gegner-Archetypen, die tragenden Turm-Stats (Damage, Attack Speed, Health,
Defense, optional Crit), Workshop-Upgrades mit 20–50 Leveln, Cards mit maximal sieben Stufen,
eine Ultimate.
Zwei Größen sind **nicht** aus dem Referenzspiel ableitbar und müssen von Hand getunt werden:
die HP-/Schadens-Skalierung pro Welle und die Kostenkurven der Upgrades. Das Wiki dokumentiert
beides nicht (siehe `docs/WikiData.md`). Wie teuer eine Tuning-Iteration ist, ist deshalb das
entscheidende Kriterium.
**Gemessen** (dieses Repo, `examples/perf_spike.rs` als Stellvertreter für einen realen
Gameplay-Crate, ~490 Zeilen; Bevy-Dependencies bereits kompiliert im gemeinsamen
`CARGO_TARGET_DIR`):
| Vorgang | Zeit |
|---|---|
| Inkrementeller Rebuild `--release` (Datei angefasst) | 9–10 s |
| Inkrementeller Rebuild `dev` | 22–26 s |
| Prozessstart bis Fenster und Frames | ~1,4 s |
| **Edit-to-see-Loop (Release)** | **~11 s** |
Der Release-Build ist schneller als der Debug-Build — das Linken der Debug-Info dominiert. Die
Annahme „Balancing braucht Release, also ist der Rebuild teuer" ist damit empirisch widerlegt.
Dagegen steht der Rest der Schleife: Eine Wellenkurve bewertet man, indem man von Welle 1 an
spielt. Das kostet Minuten, nicht Sekunden. Die ~11 s Rebuild sind ein einstelliger Prozentsatz
eines Tuning-Zyklus.
**Was Bevy 0.19 mitbringt** (geprüft über docs.rs und context7, nicht aus dem Gedächtnis):
- Es gibt **keinen** eingebauten generischen Loader für strukturierte Daten (RON/TOML/JSON) für
eigene Typen. Ein eigener `AssetLoader` ist Pflicht (Vorbild: Bevy-Beispiel `custom_asset`).
- Hot-Reload erfordert das **nicht-default** Cargo-Feature `file_watcher`.
`AssetPlugin::watch_for_changes_override` ist laut Doku ausdrücklich nicht der empfohlene Weg.
- `ron` (0.12.2) und `serde` (1.0.229) liegen bereits als transitive Dependencies im
`Cargo.lock`; sie direkt zu deklarieren kostet keine zusätzliche Compile-Zeit. `file_watcher`
dagegen zieht `notify` neu ein und funktioniert nur auf Desktop.
**Randbedingungen:** Desktop nativ zuerst, Mobile und Web später (siehe Slice-Scope in
`CONTEXT.md`) — auf Wasm gibt es keinen Dateisystem-Watcher, und Assets müssten asynchron über
HTTP geladen werden. Kein Modding-Ziel, keine externen Autoren, ein Entwickler mit Toolchain.
## Entscheidung
Die Balancing-Daten leben als **Rust-Konstanten in einem eigenen Modul `balance`**, das eine
einzige `Balance`-Resource befüllt. Gameplay-Systeme lesen ausschließlich `Res<Balance>` und
sehen die Konstanten nie.
Verbindlich:
1. `balance` enthält nur Daten und Datenstrukturen — keine Gameplay-Logik, keine Bevy-Systeme.
2. `Balance` ist eine `Resource` aus reinen Datenstrukturen, so geformt, dass sie ohne
Umbau serialisierbar wäre (flache Felder, Zahlen und Vektoren, keine `Handle`, keine
`Entity`). Ein `serde`-Derive wird **jetzt nicht** hinzugefügt.
3. Genau **eine** Funktion befüllt `Balance` aus den Konstanten. Sie ist die Naht, an der ein
späterer Asset-Ladepfad ansetzt.
4. Kein Gameplay-Code liest eine Konstante aus `balance` direkt. Diese Regel ist die einzige
Bedingung, unter der die Entscheidung ihren Wert behält.
## Verworfene Alternativen
- **Konstanten verstreut im Gameplay-Code** — gleiche Iterationskosten wie die gewählte Lösung,
aber ein späterer Wechsel auf Assets würde jedes System anfassen, das eine Zahl liest.
- **Daten-Assets (RON) über den `AssetServer` mit Hot-Reload** — der Preis (eigener
`AssetLoader`, direkte `serde`-/`ron`-Deps, `file_watcher` mit `notify`, ein
Lade-Zustand im Start-Ablauf, ausgelieferte Asset-Dateien plus asynchrones Laden auf Web und
Mobile) kauft ~11 s pro Tuning-Zyklus, dessen teurer Teil das Spielen ist. Zusätzlich: Eine
Wellenkurve lässt sich mitten im Run ohnehin nicht sinnvoll austauschen — die laufende Welle
hat ihre Werte bereits vergeben, man startet den Run neu.
- **`bevy_common_assets` statt eigenem Loader** (0.17.0 unterstützt bevy 0.19.0) — spart den
Loader-Code, ändert aber nichts an den übrigen Kosten und tauscht sie gegen eine
Dependency, deren Release-Takt an Bevy-Versionen hängt.
- **Konfiguration über Umgebungsvariablen** (wie in `examples/perf_spike.rs`) — trägt für ein
paar Spike-Knöpfe, nicht für Tabellen mit 50 Leveln.
## Abweichung von der naheliegenden Wahl
Die übliche Antwort in der Spieleentwicklung lautet „Daten raus aus dem Code". Sie stammt aus
Kontexten, die hier nicht gelten: mehrere Designer ohne Compiler, Balancing-Patches ohne
Client-Update, Modding. Der Slice hat keinen davon. Wo die Zahl steht, ist deshalb nicht die
Frage, die die Iterationszeit entscheidet — die entscheidende Frage ist, ob man die Zahl ohne
Neustart ändern und die interessante Welle ohne Vorspielen erreichen kann. Beides adressiert
kein Dateiformat, sondern ein Debug-Panel, das `Balance` im laufenden Prozess mutiert, plus
einen Sprung-zu-Welle-Pfad. Beides setzt auf `Res<Balance>` auf, also auf genau die Naht, die
diese ADR festlegt — und beides ist billiger als eine Asset-Pipeline.
## Konsequenzen
**Positiv**
- Keine neuen Dependencies, kein neues Cargo-Feature, kein Lade-Zustand im Start-Ablauf.
- Keine Plattform-API im Spielkern — Mobile und Web erben keine Asset-Auslieferung.
- Falsche Zahlen und fehlende Felder sind Compile-Fehler statt Laufzeit-Parse-Fehler.
- Balancing-Werte sind in die Binary gebacken; ein Save kann nicht auf Werte verweisen, die es
im aktuellen Build nicht mehr gibt.
**Negativ**
- Jede Zahländerung kostet einen Rebuild (~11 s heute, wachsend mit dem Crate).
- Nicht-Entwickler können nicht balancen. Für den Slice irrelevant, für ein späteres Ziel nicht.
- Das Bevy-Asset-System bleibt in diesem Bereich ungelernt. Wenn das ein Lernziel ist, gehört es
in ein eigenes Ticket mit einem echten Asset (z. B. Sprites), nicht in die Balancing-Daten.
**Folgeaufgaben** (nicht Teil dieser ADR)
- Run-Telemetrie (Zeit bis Tod, Coins pro Welle, DPS-Verlauf) — ohne sie beschleunigt ein
schnellerer Edit-Loop nur das Raten.
- Debug-Panel und Sprung-zu-Welle-Pfad, beide auf `Res<Balance>`.
## Revisions-Auslöser
Diese Entscheidung wird neu getroffen, sobald **eines** der folgenden Ereignisse eintritt:
1. **Rebuild-Schwelle:** `cargo build --release` nach einer Änderung, die nur `src/balance/`
berührt, dauert wiederholt **über 30 s** (heute 9–10 s). Nachmessen mit derselben Methode wie
oben: Datei anfassen, bauen, drei Läufe, Median.
2. **Zyklus-Schwelle:** Eine protokollierte Tuning-Session enthält **mehr als 30
Rebuild-Zyklen pro Stunde**, deren Diff ausschließlich `src/balance/` betrifft. Das ist der
Punkt, an dem die 11 s messbar zur dominierenden Kostenstelle werden — vorher nicht.
3. **Scope-Änderung:** Balancing-Werte müssen von jemandem geändert werden können, der keine
Rust-Toolchain hat, oder ohne neuen Build ausgeliefert werden (Live-Balancing, Modding,
externe Tester). Keines davon steht im Slice-Scope.
Auslöser 1 und 2 werden zuerst durch das Debug-Panel beantwortet, nicht durch Assets — es ist die
billigere Antwort auf dieselbe Beobachtung. Erst wenn das Panel gebaut ist und die Schwelle
weiterhin gerissen wird, ist der Wechsel auf Assets fällig. Auslöser 3 führt direkt zu Assets.

View File

@@ -0,0 +1,506 @@
# ADR-0002: Stat- und Modifier-Pipeline
- **Status:** Angenommen
- **Datum:** 2026-08-10
- **Betrifft:** Turm-Stats, Workshop, Cards, In-Run-Upgrades, Ultimate; Datentyp aller Stat-Werte
- **Baut auf:** ADR-0001 (Balancing-Daten als Rust-Konstanten hinter `Balance`)
- **Nachträge:**
- **2026-08-10 — §3 um vier Utility-Stats ergänzt.** Auslöser: Widerspruch 5 der
Vertical-Slice-Spec (`docs/spec-vertical-slice.md`) — der Utility-Tab des In-Run-Shops hatte mit
`CoinGain` genau **einen** Stat, und die Spec musste entweder den Tab oder diese ADR für
unvollständig erklären.
- **2026-08-10 — §1 korrigiert: dritter Anwendungsmodus `Fraction`.** Auslöser: Beim Einordnen des
neuen `FreeUpgradeChance` fiel auf, dass die `Direct`-Formel jeden Stat mit `base = 0` und
`points` als einziger Quelle auf **null** rechnet. Betroffen waren `CritChance` und
`DefensePercent` — **ein Defekt, kein Beobachtungsposten**: Crit-Chance hätte im Spiel nie über
0 % steigen können. §1 und §2 sind neu gefasst, in §3 hat sich für diese beiden Zeilen die
**Modus-Spalte** geändert (sonst nichts), und das durchgerechnete Beispiel hat einen
Prozent-Stat bekommen, damit der Fall belegt ist.
## Kontext
Vier Quellen wirken auf dieselben Turm-Stats: **In-Run-Upgrades** (verfallen am Run-Ende),
**Workshop** (permanent), **Cards** (1–5 Slots im Slice) und die **Ultimate** (Death Wave,
zeitlich begrenzt). Ohne feste Stapelregel wird jede neue Quelle zu einer eigenen Sonderregel,
und ein Save, der einen berechneten Wert speichert, friert eine Formel ein, die sich noch ändert.
Zwei Randbedingungen aus dem Repo:
- Es gibt genau **einen** Turm. Ticket #4 hat gemessen: bei 10 000 Gegnern kostet die gesamte
Gameplay-Logik **0,114 ms von 16,67 ms**; 96–98 % gehen ins Rendering. Eine Stat-Berechnung
pro Frame wäre nicht messbar. Performance ist hier also **kein** Argument.
- Alle Zahlen kommen laut ADR-0001 aus `Res<Balance>`. Diese ADR fügt keine Zahlen hinzu,
sondern nur die Struktur, in der sie stehen. Auch die Clamp-Grenzen sind Balancing-Werte und
leben in `Balance`.
**Geprüft an der Bevy-0.19-Doku** (docs.rs, über context7): Components lassen sich zur Laufzeit
über `commands.entity(e).insert(..)` / `.remove::<T>()` an- und abheften; `RemovedComponents<T>`
und die Lifecycle-Hooks `on_add`/`on_insert`/`on_discard`/`on_remove` existieren, werden hier
aber **nicht** gebraucht (siehe Entfernungspfad). Change Detection über `Res<T>::is_changed()`
und `Changed<T>` ist der Auslöser der Neuberechnung. `Resource` = weltweit genau einmal,
`Component` = an eine Entity gebunden und stirbt mit ihr — dieser Unterschied trägt unten die
Run-Grenze.
## Entscheidung
### 1. Drei Akkumulatoren, ein Anwendungsmodus pro Stat
> **Korrigiert am 2026-08-10.** Die erste Fassung kannte zwei Modi und rechnete die Prozent-Stats
> auf null. Der Befund und die Korrektur stehen am Ende dieses Punktes.
Jeder Stat sammelt drei Akkumulatoren:
| Akkumulator | Einheit | Verknüpfung |
|---|---|---|
| `flat` | absolute Einheiten des Stats | additiv |
| `points` | Prozentpunkte (0,15 = +15 pp) | additiv, **einmal am Ende** geclamped |
| `mult` | Faktor | multiplikativ |
Jeder Stat trägt zusätzlich **ein Feld**, seinen Anwendungsmodus. Es hat drei Varianten, und die
Frage, die es beantwortet, ist immer dieselbe: **Was tun Prozentpunkte mit diesem Stat?**
- **`Direct`** — der Stat ist eine **Größe**; `points` skalieren sie nach oben:
`(base + flat) × (1 + clamp(points)) × mult`
- **`Interval`** — der Stat ist eine **Zeitspanne** in Sekunden; `points` skalieren sie nach unten:
`(base + flat) × (1 − clamp(points)) ÷ mult`
- **`Fraction`** — der Stat **ist selbst ein Anteil** (Chance, Prozentsatz); `points` skalieren
nichts, sie **sind** der Wert:
`clamp((base + flat + points) × mult)`
Der Modus ist eine Eigenschaft des Stats, kein Sonderfall des Modifikators. Es bleibt bei **einem**
Feld und drei Akkumulatoren; hinzugekommen ist eine dritte Variante desselben Feldes, nicht ein
zweites Bit und kein vierter Akkumulator.
Zeitbasierte Stats bleiben **Intervalle, keine Raten**: Der Spieler liest „12 s Cooldown", nicht
„0,083 Auslösungen/s". Eine Cooldown-Reduktion ist deshalb kein eigener Akkumulator, sondern
`points` im `Interval`-Modus.
Bei einem `Fraction`-Stat tragen `base`, `flat` und `points` **dieselbe Einheit** — Prozentpunkte —,
weil der Wert des Stats aus nichts anderem besteht. Deshalb werden sie addiert und nicht
gegeneinander skaliert, und deshalb greift der Clamp auf das Ergebnis: Bei einem `Fraction`-Stat
*ist* das Ergebnis die Prozentpunkt-Summe. `mult` steht innerhalb des Clamps, damit die harte Wand
aus Punkt 2 nicht durch einen Faktor umgangen werden kann.
#### Warum es einen dritten Modus gibt und keine billigere Lösung
Die erste Fassung dieser ADR kannte nur `Direct` und `Interval`. Damit rechneten `CritChance` und
`DefensePercent` **auf null**: Beide haben `base = 0` und beziehen ihre Wirkung ausschließlich aus
`points`, und `(0 + 0) × (1 + 0,30) × 1 = 0`. Drei Quellen mit je 10 pp Crit-Chance hätten
zusammen 0 % ergeben. Es ist dieselbe Basis-null-Falle, wegen der es den `flat`-Akkumulator
überhaupt gibt (siehe Punkt 3) — sie wurde dort für `mult` gezogen und für `points` übersehen,
obwohl `(1 + points)` genauso ein Faktor auf die Basis ist.
Die Ursache ist nicht die Basis, sondern eine Doppelbedeutung: Bei `Damage` und `MaxHealth` ist der
Stat eine Größe und Prozentpunkte vergrößern sie. Bei `CritChance` ist der Stat **selbst** ein
Prozentsatz; die Prozentpunkte sind der Wert und skalieren nichts. Zwei verschiedene Dinge trugen
denselben Akkumulatornamen und wurden mit derselben Formel bedient.
Drei billigere Wege wurden geprüft und verworfen:
1. **Prozent-Stats nehmen `flat` statt `points`.** Rechnet richtig (`(0 + 0,30) × 1 × 1 = 0,30`) und
käme ohne dritten Modus aus — aber der Clamp hängt in Punkt 2 an `points`. Die Caps für Crit
Chance und Defense % würden ersatzlos ausfallen, und sie sind der Grund, warum diese Stats
überhaupt einen Cap-Eintrag haben.
2. **Den Clamp generell auf das Ergebnis legen** statt auf die Prozentpunkt-Summe. Löst die
Prozent-Stats, ändert aber die Bedeutung der Cooldown-Caps: Aus „höchstens 60 pp Reduktion" würde
„mindestens 24 s Dauer". Der Cap-Wert wechselte die Einheit, würde von `base` abhängig, und eine
Basisänderung im Balancing verschöbe still eine Obergrenze.
3. **Eine Ausnahme je Zeile in der Stat-Tabelle.** Genau das, was der Modus verhindern soll — der
Modus ist eine Eigenschaft des Stats, und eine Eigenschaft gehört in ein Feld, nicht in eine
Fußnote.
Bleibt der dritte Modus. Er kostet eine `enum`-Variante, keinen Akkumulator und keine Regel, die
irgendwo außerhalb dieser drei Formeln steht; der Compiler erzwingt bei jedem neuen Stat eine
Entscheidung, welcher der drei es ist.
**Prüfregel für jede neue Zeile der Stat-Tabelle**, in dieser Reihenfolge:
1. Ist der Stat selbst ein Anteil oder eine Wahrscheinlichkeit → **`Fraction`**.
2. Ist er eine Zeitspanne in Sekunden → **`Interval`**.
3. Sonst → **`Direct`**. Dann muss `base + flat` an der nach Punkt 11.1 genannten Fortschritts-Stelle
**größer als 0** sein — sonst ist der Stat entweder falsch eingeordnet oder er braucht eine
`flat`-Quelle. Ein `Direct`-Stat mit `base = 0` und `points` als einziger Quelle ist genau der
Fehler, den diese Korrektur behebt.
### 2. Clamp: summieren, dann einmal begrenzen
`clamp(x)` ist `x.clamp(0.0, cap)`. Das `cap` ist ein Balancing-Wert und steht in
`Balance.caps`, pro Stat einer (Crit Chance, Defense %, jeder Cooldown). Es gibt keinen globalen
Clamp und keinen Default im Code.
**Worauf `clamp` angewandt wird, sagt die Formel des Modus** — und es ist in allen drei Fällen
dieselbe Größe: die **Prozentpunkt-Summe**. Bei `Direct` und `Interval` ist das der
`points`-Akkumulator, weil nur er Prozentpunkte trägt. Bei `Fraction` ist es der ganze Wert, weil
der Wert dort aus nichts anderem als Prozentpunkten besteht. Der Cap behält damit in jedem Fall die
Einheit „Prozentpunkte" und bleibt vom `base` des Stats unabhängig.
Geclamped wird **die Summe, genau einmal, nach der Summierung** — nie pro Quelle. Folgen, die
ausdrücklich gewollt sind:
- Die Reihenfolge der Quellen ist egal; das Ergebnis ist assoziativ und kommutativ.
- Es gibt **keine** abnehmenden Erträge, sondern eine harte Wand. Liefern drei Quellen je 30 pp
Cooldown-Reduktion bei `cap = 0,60`, wirken 60 pp und 30 pp verfallen ersatzlos.
- Die **ungeclampte** Summe bleibt für die Anzeige erhalten (siehe Sichtbarkeitsanforderung).
Sie wird nicht persistiert, sondern bei der Neuberechnung nebenher ermittelt.
### 3. Stat-Tabelle
`base` kommt immer aus `Balance`. „Erwartet" nennt die Akkumulatoren, die im Slice tatsächlich
bespielt werden — technisch stehen jedem Stat alle drei offen (die übrigen sind 0 bzw. 1).
| Stat | `base` | Modus | Erwartet | Clamp |
|---|---|---|---|---|
| `Damage` | > 0 | Direct | `flat` (Workshop, In-Run), `mult` (Cards) | — |
| `AttackInterval` | > 0 | **Interval** | `mult` (Workshop, Cards, In-Run) | — |
| `Range` | > 0 | Direct | `mult` | — |
| `MaxHealth` | > 0 | Direct | `flat` (Workshop, In-Run), `mult` (Cards) | — |
| `CritFactor` | > 0 | Direct | `mult` | — |
| `CoinGain` | 1,0 | Direct | `mult` | — |
| `CritChance` | 0 | **Fraction** | `points` | `caps.crit_chance` |
| `DefensePercent` | 0 | **Fraction** | `points` | `caps.defense_percent` |
| `HealthRegen` | **0** | Direct | `flat` (Workshop), `mult` (Cards) | — |
| `DefenseFlat` | 0 | Direct | `flat`, `mult` | — |
| `UltimateCooldown` | > 0 | **Interval** | `points` | `caps.cooldown_reduction` |
| `WaveCooldown` | > 0 | **Interval** | `points` | `caps.wave_cooldown_reduction` |
`HealthRegen` und `DefenseFlat` sind der Grund für den flachen Akkumulator: Ihre Basis ist 0,
und ein rein multiplikativer Bonus auf 0 bleibt 0. Dasselbe gilt später für Lifesteal und Thorns.
**Nachtrag 2026-08-10:** `CritChance` und `DefensePercent` standen in der ersten Fassung als
`Direct` in dieser Tabelle und rechneten deshalb auf null — dieselbe Basis-null-Falle, hier nur über
`points` statt über `mult`. Sie stehen jetzt als `Fraction`; `base`, „Erwartet" und `Clamp` sind
unverändert, geändert hat sich allein die Modus-Spalte. Die Begründung steht in Punkt 1.
#### Nachweis: rechnet jede Zeile richtig?
Einmal komplett durchgegangen, mit den Formeln aus Punkt 1. „Richtig" heißt: Der Stat kann seinen
erwarteten Quellen folgen, statt an einer Basis von 0 hängen zu bleiben.
| Stat | Modus | Prüfung | Richtig? |
|---|---|---|---|
| `Damage` | Direct | `base > 0`, `flat`+`mult` tragen | ja |
| `AttackInterval` | Interval | `base > 0`, `mult` teilt das Intervall | ja |
| `Range` | Direct | `base > 0`, `mult` trägt | ja |
| `MaxHealth` | Direct | `base > 0`, `flat`+`mult` tragen | ja |
| `CritFactor` | Direct | `base > 0`; ein Faktor wie 2,5× ist eine **Größe**, kein Anteil — `Direct` bleibt richtig | ja |
| `CoinGain` | Direct | `base = 1,0 > 0`, `mult` trägt | ja |
| `CritChance` | **Fraction** | war `Direct`: `(0+0)×(1+p) = 0`. Jetzt `clamp(0 + 0 + p)` | **korrigiert** |
| `DefensePercent` | **Fraction** | war `Direct`: dasselbe. Jetzt `clamp(0 + 0 + p)` | **korrigiert** |
| `HealthRegen` | Direct | `base = 0`, aber `flat` ist erwartete Quelle → `base + flat > 0`; Prüfregel 3 erfüllt | ja |
| `DefenseFlat` | Direct | wie `HealthRegen` | ja |
| `UltimateCooldown` | Interval | `base > 0`; `points` skalieren eine Dauer, also eine Größe | ja |
| `WaveCooldown` | Interval | `base > 0`; wie `UltimateCooldown` | ja |
| `CoinsPerWave` | Direct | `base = 0`, aber `flat` ist erwartete Quelle → wie `HealthRegen` | ja |
| `CritCoinGain` | Direct | `base = 1,0 > 0`, `mult` trägt | ja |
| `FreeUpgradeChance` | **Fraction** | eine Wahrscheinlichkeit → Prüfregel 1 | **korrigiert** |
| `MetaGain` | Direct | `base = 1,0 > 0`, `mult` trägt | ja |
**`Interval` ist von der Korrektur nicht betroffen.** Beide `Interval`-Stats haben eine Basis über
null, und ihre `points` skalieren eine Dauer — also eine Größe, nicht einen Anteil. Der inverse Term
`(1 − clamp(points))` bleibt wörtlich stehen, ebenso ihre Caps und das durchgerechnete
Ultimate-Cooldown-Beispiel weiter unten. Es gibt keinen Zeitspannen-Stat mit Basis 0; ein solcher
wäre auch sinnlos, weil ein Intervall von 0 s keine Reduktion braucht.
#### Nachtrag 2026-08-10: vier Utility-Stats
> **Diese vier Zeilen sind später hinzugekommen als die zwölf oben.** Auslöser war Widerspruch 5 der
> Vertical-Slice-Spec: Der Utility-Tab des In-Run-Shops stand mit `CoinGain` bei genau einer Zeile,
> während Attack fünf und Defence vier hat. Ein Tab mit einer Zeile ist kein Tab. Die Kalibrierung
> aus #3 sagt, was Utility im Referenzspiel ist — **kein Kampf-Stat, sondern ein
> Ökonomie-Multiplikator** —, und `docs/WikiData.md` listet dort Cash Bonus, Cash/Wave, Coins/Kill
> und Free Upgrades. Diese vier Zeilen übernehmen das Muster, nicht die Werte. An den zwölf Zeilen
> oben ist nichts geändert.
| Stat | `base` | Modus | Erwartet | Clamp |
|---|---|---|---|---|
| `CoinsPerWave` | **0** | Direct | `flat` (Workshop, In-Run), `mult` (Cards) | — |
| `CritCoinGain` | 1,0 | Direct | `mult` (Workshop, In-Run, Cards) | — |
| `FreeUpgradeChance` | 0 | **Fraction** | `points` (Workshop, In-Run) | `caps.free_upgrade_chance` |
| `MetaGain` | 1,0 | Direct | `mult` (Workshop, Cards) — **nicht** In-Run | — |
Damit hat die Tabelle **16** Stats. Drei der vier laufen im Modus `Direct`, `FreeUpgradeChance` als
Wahrscheinlichkeit im Modus `Fraction`; keiner ist eine Zeitspanne, also braucht keiner `Interval`.
`CoinsPerWave` hat Basis **0** und steht damit in derselben Gruppe wie `HealthRegen` und
`DefenseFlat`: Ein rein multiplikativer Bonus auf 0 bleibt 0, der flache Akkumulator ist der Grund,
dass es die Zeile überhaupt gibt.
**`MetaGain` ist die einzige Zeile mit einer Quellen-Einschränkung, und die ist keine
Balancing-Frage.** Ein aus `Coins` gekaufter Multiplikator auf die Meta-Währung wäre ein
Konvertierungspfad zwischen den beiden Währungen — und Nicht-Konvertierbarkeit ist eine
Kern-Entscheidung des Slice (Issue #1, #11). Die Einschränkung gehört deshalb in diese Tabelle und
nicht in die UI: Wer `MetaGain` in den In-Run-Shop legt, bricht eine Entscheidung, nicht ein Layout.
**`CritCoinGain` erbt eine Vorbedingung.** Es wirkt nur, solange `CritChance > 0`, und `CritChance`
hat Basis 0. Das ist gewollt — es koppelt den Utility-Tab an den Attack-Tab, damit die
Kaufreihenfolge eine Entscheidung ist und keine Sortierung. Es heißt aber auch, dass die
Sichtbarkeitsanforderung aus Punkt 11 hier zwingend greift: Die in Punkt 11.1 zu nennende
Fortschritts-Stelle muss **hinter** dem ersten Crit-Kauf liegen, sonst ist der Modifikator an der
genannten Stelle wirkungslos und nach Punkt 11.4 abzulehnen.
**`FreeUpgradeChance` ist als einziger der vier eine Wahrscheinlichkeit** und folgt deshalb der Form
von `CritChance` und `DefensePercent`: Basis 0, `points`, ein eigenes `cap` in `Balance`, Modus
**`Fraction`**. Beim Schreiben dieser Zeile ist aufgefallen, dass diese Form mit der damaligen
`Direct`-Formel auf null rechnete — der Fund hat die Korrektur in Punkt 1 ausgelöst. Nach Prüfregel 1
dort ist `Fraction` die richtige Einordnung, und der Cap greift auf die Prozentpunkt-Summe.
**`AttackInterval` ist der Stat, der die Zuordnung fast gesprengt hätte.** „Attack Speed ×1,25"
ist genrenüblich eine Rate, und eine Rate widerspricht der Intervall-Regel. Der
`Interval`-Modus löst das ohne Sonderregel: Die Karte liefert `mult = 1,25`, das Intervall wird
geteilt, der Spieler sieht weiterhin „×1,25 Angriffsgeschwindigkeit", und der Schuss-Timer
bekommt direkt eine Dauer. Deshalb heißt der Stat `AttackInterval` und nicht `AttackSpeed`.
### 4. `type Stat = f32`
Alias, kein Newtype. f32 trägt sieben signifikante Stellen — genug für den Slice.
Revisions-Auslöser unten.
### 5. Nur Quellen werden persistiert
Ein Save enthält **niemals** einen berechneten Wert: gespeichert werden Workshop-Level, besessene
und ausgerüstete Cards mit ihrem Level, die Slot-Zahl und (falls ein Run fortgesetzt wird) die
In-Run-Upgrade-Level. Alles andere wird beim Laden neu gerechnet.
Das entkoppelt #7 (Save/Load) vollständig von dieser ADR: Jede Formeländerung wirkt rückwirkend
auf bestehende Saves, ohne Migration. Es ist die einzige Festlegung hier, deren spätere Rücknahme
wirklich teuer wäre.
### 6. Herkunft steckt im Ablageort, nicht im Datenmodell
Die Akkumulatoren tragen **keine** Herkunftsmarkierung. Sie sind ein reiner Cache und werden bei
jeder Änderung **vollständig aus den Quellen neu gerechnet** — nie inkrementell addiert oder
subtrahiert. Bei einem Turm und ≤ 5 Slots ist das eine Handvoll Multiplikationen; die einzige
Alternative wäre Buchführung über das Rückgängigmachen einzelner Beiträge, und genau die geht
schief.
Die Herkunft, die man wirklich braucht, ist die Run-Grenze. Sie wird nicht als Feld ausgedrückt,
sondern als Ablageort:
| Lebensdauer | Ablage | Beispiele |
|---|---|---|
| permanent | `Resource` | `Balance`, `Workshop`, `OwnedCards`, `EquippedCards` |
| Run-gebunden | `Component` **an der Turm-Entity** | `RunUpgrades`, `EffectiveStats`, Ultimate-Buff, alle Fähigkeits-Components |
Die Turm-Entity wird **beim Run-Start gespawnt und beim Run-Ende despawnt**. Damit verschwindet
am Run-Ende genau das, was verschwinden muss, ohne dass irgendein Code es aufräumt. Kein
`clear()`, keine Reset-Liste, die man beim nächsten Feature zu erweitern vergisst.
Für die UI-Frage „woher kommen meine 340 % Damage?" gibt es eine Funktion, die die
Neuberechnung mit Zwischenwerten pro Quelle ausführt und das Ergebnis zurückgibt. Sie wird auf
Anforderung aufgerufen, nicht im Gameplay-Pfad, und speichert nichts.
### 7. `EffectiveStats` wird bei Änderung berechnet — aus Nachvollziehbarkeit
`EffectiveStats` ist ein `Component` an der Turm-Entity, kein `Resource`. Genau ein System
schreibt es, und es läuft, wenn eine Quelle sich geändert hat (`is_changed()` auf den
Quell-Resources, `Changed<..>` auf den Quell-Components) — nicht jeden Frame.
Der Grund ist **nicht** Performance. Jeden Frame zu rechnen wäre bei 0,114 ms Gesamtbudget für
die Gameplay-Logik nicht messbar. Der Grund ist, dass es dann genau einen Zeitpunkt gibt, an dem
ein Stat sich ändert: ein Breakpoint, eine Log-Zeile, ein Wert, den man ansehen kann. Bei
Neuberechnung pro Frame wäre jeder falsche Wert ein Beobachtungsproblem statt eines Fehlers an
einer Stelle.
### 8. Card-Effekte sind ein `enum`
```rust
type Stat = f32;
enum CardEffect {
Modifier { stat: StatKind, kind: ModifierKind, value: Stat },
Ability(AbilityKind),
}
enum ModifierKind { Flat, Points, Mult }
```
Kein Trait-Object, keine Registry, kein Plugin-Mechanismus. Null externe Autoren, kein
Modding-Ziel (ADR-0001, Slice-Scope). Ein neuer Stat-Modifikator ist eine neue `StatKind`-Variante
plus eine Zeile in `Balance`; der Compiler zeigt jede Stelle, die nachziehen muss. Genau das ist
der Nutzen und nicht der Preis.
### 9. Fähigkeiten sind Components, keine Zahlen
Ein Card-Effekt der Gruppe `Ability` heftet ein eigenes Component an die Turm-Entity —
`DeathRay { duration }`, `SlowAura { factor }` — mit einem eigenen System. Death Ray ist kein
Stat und wird nicht in die Zahlen-Pipeline gepresst. Fähigkeits-Parameter (Dauer, Radius) leben
im Component, nicht in `EffectiveStats`.
Das fängt auch die Modifikatoren ab, die gar nicht am Turm ansetzen: `SlowAura` verändert die
Geschwindigkeit von Gegnern. Als Stat wäre das ein Fremdkörper, als Component am Turm, das
das Gegner-Bewegungssystem liest, ist es die naheliegende Form.
**Fähigkeits-Components dürfen keinen Zustand tragen, der einen Run überdauern soll.** Cooldowns
und Timer im Component sind in Ordnung — sie sollen beim Neuaufbau zurückgesetzt werden.
„Einmal pro Run"-Zustand gehört in eine Run-gebundene Ablage, die nicht bei jedem
Karten-Abgleich neu geschrieben wird.
### 10. Entfernungspfad für Fähigkeits-Components
Eine nicht entfernte Fähigkeits-Component ist der wahrscheinlichste Bug dieser Architektur: Sie
zeigt sich Wochen später als „der Turm hat manchmal noch den Death Ray", und dann ist die
Ursache nicht mehr rückverfolgbar. Der Schutz ist strukturell, nicht disziplinarisch:
**Ein einziges System `sync_tower_abilities` ist der einzige Schreiber von
Fähigkeits-Components.** Es fügt nirgends an der Stelle ein, an der eine Karte gewechselt wird.
Es läuft mit derselben Änderungserkennung wie die `EffectiveStats`-Neuberechnung und gleicht ab:
Für jede `AbilityKind`-Variante wird das Soll (aus den Quellen) mit dem Ist (`Has<T>` an der
Turm-Entity) verglichen, fehlende Components werden per `insert` ergänzt, überzählige per
`remove::<T>()` entfernt. Weil abgeglichen und nicht ereignisweise gepflegt wird, fallen die drei
geforderten Fälle auf denselben Pfad:
1. **Card-Wechsel im laufenden Run** — `EquippedCards` ändert sich → Abgleich läuft → die
Component der abgelegten Karte wird per `remove::<T>()` entfernt, die der neuen eingefügt.
Das ist der einzige Fall, in dem überhaupt jemand entfernt.
2. **Run-Ende** — die Turm-Entity wird despawnt. Es gibt nichts zu entfernen; die Components
verschwinden mit der Entity. Deshalb steht in Punkt 6, dass alles Run-gebundene an der
Turm-Entity hängt.
3. **Laden eines Saves** — der Save enthält nur Quellen (Punkt 5). Laden heißt: Quellen setzen,
Turm frisch spawnen, Abgleich einmal vor dem ersten Gameplay-Frame laufen lassen. Eine
übriggebliebene Component kann es nicht geben, weil die Entity gerade erst entstanden ist.
Dazu eine `debug_assert`-Prüfung am Ende des Abgleichs: Die Menge der Fähigkeits-Components an
der Turm-Entity ist gleich der aus den Quellen abgeleiteten Menge. Bei einem Turm kostet das
nichts und verwandelt den späten, unauffälligen Bug in einen sofortigen Abbruch.
Bevys Lifecycle-Hooks und `RemovedComponents` werden dafür ausdrücklich **nicht** verwendet: Ein
Abgleich, der das Soll kennt, braucht keine Benachrichtigung darüber, was gerade passiert ist.
### 11. Sichtbarkeitsanforderung an jeden neuen Modifikator
Ein Modifikator, dessen Wirkung der Spieler nicht spürt, hat versagt — und keine technische
Prüfung zeigt das an. Der Compiler akzeptiert einen Bonus, der gegen einen Clamp läuft, und ein
Test, der die Formel prüft, ist grün.
**Verbindlich für jeden neuen Modifikator, vor dem Merge:**
1. Der Autor nennt eine Fortschritts-Stelle, an der der Modifikator wirken soll (Welle, Workshop-
Stand, Card-Level).
2. Er notiert den betroffenen `EffectiveStats`-Wert **vor und nach** dem Modifikator an dieser
Stelle — mit der ungeclampten Summe, falls ein Clamp beteiligt ist.
3. Er nennt die **beobachtbare** Folge in einer Größe, die die Run-Telemetrie aus ADR-0001
ohnehin erfasst: Zeit bis Welle N, DPS-Verlauf, Coins pro Minute, überlebte Welle. Die
Änderung muss dort über der Streuung zwischen zwei Läufen liegen.
4. **Läuft der Modifikator an der genannten Stelle in einen Clamp, ist er abgelehnt** — nicht
dokumentiert, nicht mit einem Hinweis versehen: abgelehnt oder umskaliert. Ein Bonus, der
ersatzlos verfällt, ist im Spiel nicht von einem fehlenden Bonus zu unterscheiden.
Punkt 3 ist die einzige Anforderung, die sich nicht am Schreibtisch erfüllen lässt, und deshalb
die einzige, die wirkt.
## Durchgerechnetes Beispiel
> **Alle Zahlen sind Platzhalter.** Die echten Werte entscheidet Ticket #8. Hier zählt der
> Rechenweg, nicht das Ergebnis.
**Damage** (Modus `Direct`), Basis 10, Workshop-Level 12 zu +1,20 je Level, zwei Cards:
| Schritt | Rechnung | Wert |
|---|---|---|
| Basis aus `Balance` | — | 10,00 |
| `flat` = Workshop Damage Lv 12 × 1,20 | 10,00 + 14,40 | 24,40 |
| `points` = 0 (keine Quelle liefert pp auf Damage) | 24,40 × (1 + 0,00) | 24,40 |
| `mult` = Card „Damage" Lv 3 (×2,40) × Card „Berserker" Lv 5 (×1,30) = 3,12 | 24,40 × 3,12 | **76,13** |
Ein In-Run-Upgrade „+15 pp Damage" (hypothetisch, ohne Clamp auf diesem Stat) ergäbe
`24,40 × 1,15 × 3,12 = 87,55` — die Prozentpunkte greifen **vor** den Multiplikatoren, nicht
danach.
**Health Regen** (Basis **0**), zeigt, wozu der flache Akkumulator da ist:
`(0 + 4,00 Workshop) × 1,60 (Card Lv 2) = 6,40 HP/s`.
Ohne flachen Akkumulator: `0 × 1,60 = 0`. Die Karte wäre wirkungslos.
**Crit Chance** (Modus **`Fraction`**, Basis **0**, `caps.crit_chance = 0,80`) — der Fall, an dem
die erste Fassung dieser ADR gescheitert ist. Drei Quellen liefern Prozentpunkte:
| Schritt | Rechnung | Wert |
|---|---|---|
| Basis aus `Balance` | — | 0,00 |
| Workshop Crit Chance Lv 20 zu +1 pp je Level | `points += 0,20` | 0,20 |
| Card „Präzision" Lv 4 | `points += 0,12` | 0,32 |
| In-Run-Upgrade Crit Chance | `points += 0,05` | 0,37 |
| `clamp((0 + 0 + 0,37) × 1)` bei `cap = 0,80` | — | **0,37 → 37 % Crit-Chance** |
**Mit der alten `Direct`-Formel wäre dasselbe `(0 + 0) × (1 + 0,37) × 1 = 0` gewesen** — zwanzig
Workshop-Level, eine Karte auf Stufe 4 und ein gekauftes In-Run-Upgrade zusammen wirkungslos, ohne
dass ein Test oder der Compiler es angezeigt hätte.
Der Clamp greift unverändert: Kommt eine vierte Quelle mit +50 pp dazu, ist die Summe 0,87 und der
geclampte Wert 0,80 → **80 %**. Die **ungeclampte** Summe 0,87 bleibt für die Anzeige erhalten
(Punkt 2), und die vierte Quelle wäre nach der Sichtbarkeitsanforderung (Punkt 11.4) abzulehnen oder
umzuskalieren, weil 7 der 50 pp ersatzlos verfallen.
**Ultimate-Cooldown** (Modus `Interval`), Basis 60 s, `caps.cooldown_reduction = 0,60`:
Workshop −8 pp und Card −30 pp ergeben 38 pp → `60 × (1 − 0,38) = 37,2 s`.
Kommt eine dritte Quelle mit −30 pp dazu, ist die Summe 68 pp, geclamped auf 60 pp →
`60 × 0,40 = 24,0 s`. Von den 30 pp der dritten Quelle wirken nur 22, die restlichen 8 verfallen
ersatzlos — eine vierte Quelle wäre komplett wirkungslos. Genau das lehnt die
Sichtbarkeitsanforderung ab.
## Verworfene Alternativen
- **Nur additiv oder nur multiplikativ.** Rein additiv verpufft im Endgame, rein multiplikativ
lässt Stats mit Basis 0 (Health Regen, später Lifesteal, Thorns) unerreichbar. Beide Formen
scheitern an je einer Hälfte der Stat-Tabelle.
- **Additiv innerhalb einer Quelle, multiplikativ zwischen Quellen** (die Frage aus dem Ticket).
Klingt ordentlich, koppelt aber die Rechenregel an die Herkunft: Dieselbe Karte wirkt anders,
je nachdem, ob ein Workshop-Upgrade denselben Stat trifft. Die drei Akkumulatoren erreichen
dasselbe Verhalten, ohne dass ein Modifikator wissen muss, wer sonst noch am Stat hängt.
- **Modifikatoren als Liste mit Herkunft, Entfernen durch Subtraktion.** Braucht eine stabile
Identität pro Modifikator, Buchführung beim Run-Ende und ist gegen Rundungsdrift bei f32 nicht
robust. Vollständige Neuberechnung kostet bei einem Turm nichts und hat keinen dieser Fehler.
- **Card-Effekte als Trait-Objects oder Registry.** Kauft Erweiterbarkeit ohne externe Autoren.
Der Preis ist der Verlust der Vollständigkeitsprüfung durch den Compiler — genau die Prüfung,
die einen vergessenen Stat sichtbar macht.
- **Fähigkeiten als Stat-Werte** (z. B. „Death-Ray-Stärke" als Zahl in `EffectiveStats`). Zwingt
jede Fähigkeit in eine Skalarform, die sie nicht hat, und lässt `EffectiveStats` um Felder
wachsen, die kein Gameplay-System liest.
- **`EffectiveStats` als `Resource`.** Funktioniert bei einem Turm, verliert aber die Kopplung
an die Entity-Lebensdauer — und damit den kostenlosen Reset am Run-Ende, der in Punkt 10 die
Bug-Klasse verhindert.
## Offene Punkte, die diese ADR nicht entscheidet
- **`MaxHealth` ist der einzige Stat mit einem veränderlichen Ist-Wert daneben.** Steigt
`MaxHealth` mitten im Run, muss entschieden werden, ob die aktuelle HP mitwächst, den Anteil
behält oder unverändert bleibt. Das ist keine Frage der Pipeline, sondern des Health-Systems;
es ist hier nur festgehalten, damit es nicht zwischen den Tickets verschwindet.
- **Bedingte Modifikatoren** („nur gegen Bosse", „skaliert mit erlittenem Schaden" wie
Berserker). Im Slice gibt es keinen; taucht einer auf, ist er entweder eine `Ability` oder er
bricht die Zwei-Gruppen-Teilung des `enum` (siehe Revisions-Auslöser 3).
## Konsequenzen
Was diese Entscheidung einbringt, steht in den Punkten 1 bis 11 und wird hier nicht wiederholt.
Was sie kostet, steht nur hier:
- Der Clamp ist eine harte Wand. Ohne die Sichtbarkeitsanforderung entstehen wirkungslose
Modifikatoren, die niemandem auffallen. Die Prüfung ist manuell und damit die schwächste
Stelle dieser ADR.
- Vollständige Neuberechnung skaliert nicht auf viele Turm-artige Entities mit vielen Quellen.
Bei einem Turm ist das irrelevant, und die Alternative wäre die Buchführung, die oben
verworfen wurde.
- Ein neuer Effekt, der weder Stat-Modifikator noch Fähigkeit ist, erzwingt eine Änderung am
`enum` und an jedem `match`. Das ist beabsichtigt, aber es ist Arbeit.
- Die Ablageregel „Run-gebunden = Component an der Turm-Entity" ist eine Konvention. Sie hält
nur, solange sie eingehalten wird; ein Run-Zustand, der versehentlich als `Resource` landet,
überlebt den Run und bringt genau den Bug zurück, den Punkt 10 verhindern soll.
## Revisions-Auslöser
1. **Zahlentyp:** Ein Stat-Wert im Spiel überschreitet **10⁶**, oder eine Anzeige rundet
sichtbar (zwei Läufe mit gleichem Stand zeigen unterschiedliche letzte Stellen). Dann wird
`type Stat` neu entschieden — f64 zuerst, Big-Number-Semantik nur, wenn f64 auch reißt.
2. **Mehrere Turm-artige Entities:** Sobald mehr als eine Entity `EffectiveStats` trägt, ist die
Änderungserkennung pro Entity zu prüfen — global geänderte Quellen müssten dann alle
betreffen, entity-eigene nur eine.
3. **Dritte Effektgruppe:** Ein Card-Effekt, der weder als `Modifier` noch als `Ability`
ausdrückbar ist, kippt die Zwei-Gruppen-Teilung. Ein Fall reicht; es ist keine Frage der
Häufigkeit.
4. **Clamp-Druck:** Wenn an der vorgesehenen Fortschritts-Stelle mehr als eine Quelle je Stat
regelmäßig am Clamp anschlägt, ist nicht der Modifikator falsch, sondern das
Prozent-Budget — dann wird die harte Wand gegen abnehmende Erträge neu bewertet.
5. **Vierter Anwendungsmodus:** Ein Stat, der weder eine Größe noch eine Zeitspanne noch ein Anteil
ist. Ein Fall reicht. Die Prüfregel in Punkt 1 ist so gebaut, dass sie ihn sichtbar macht statt
ihn in `Direct` zu drängen — und genau dieses Hineindrängen war der Defekt, den der Nachtrag vom
2026-08-10 behoben hat.

View File

@@ -0,0 +1,304 @@
# ADR-0003: Save-Format, Speicherort und Migration
- **Status:** Angenommen
- **Datum:** 2026-08-10
- **Betrifft:** Persistenz des Meta-Fortschritts (Workshop, Cards, Loadout, Meta-Währung, Wellen-Rekord)
- **Baut auf:** ADR-0002 (nur Quellen werden persistiert; Ablageregel `Resource` vs. `Component`),
ADR-0001 (Balancing-Werte sind in die Binary gebacken)
## Kontext
ADR-0002 hat die schwierige Hälfte bereits entschieden: Ein Save enthält **niemals einen
berechneten Wert**, nur Quellen. Damit ist die Datenmenge klein, flach und formelunabhängig —
jede Formeländerung wirkt rückwirkend, ohne Migration. Offen bleiben Format, Ort, Migrationsregel
und das Verhalten bei einem kaputten Save.
Der Spannungspunkt ist der Ort: Er muss plattformüblich sein (Windows, Linux, macOS heute; Mobile
und Web später), ohne dass der Spielkern eine Plattform-API sieht.
**Was Bevy 0.19 mitbringt** (an der Quelle geprüft, nicht aus dem Gedächtnis — Belege unten):
Bevy 0.19 hat mit `bevy_settings` ein eingebautes Framework für persistente Nutzerdaten. Es liefert
genau die vier Dinge, die man sonst selbst bauen müsste:
1. **Pfadauflösung für vier Plattformen** über `bevy_platform::dirs::preferences_dir()`.
2. **Atomares Schreiben** — erst `<datei>.toml.new`, dann `fs::rename`.
3. **Feld-Toleranz beim Laden** — Reflect gleicht Feldnamen ab; fehlende Felder behalten ihren
Wert, unbekannte Schlüssel werden ignoriert. Das ist `#[serde(default)]`-Verhalten ohne Attribut.
4. **Web-Pfad** über `localStorage` statt Dateisystem, hinter derselben API.
Das Framework hat drei Lücken, die diese ADR schließen muss: es gibt **keinen Rückkanal** an die
App, ob Laden oder Schreiben geklappt hat; auf **Android und iOS** persistiert es nichts; und in
`store_fs.rs` fehlt nach einem fehlgeschlagenen `fs::write` ein `return`.
**Randbedingung aus dem Spielablauf:** Cards sind im laufenden Run über den Pause-Button nur
einsehbar, nicht wechselbar; das Loadout steht vor dem Run fest, und Pause friert die gesamte
Simulation ein. Damit ändert sich **während eines Runs keine einzige persistierte Quelle**. Das
Schreibfenster liegt vollständig zwischen den Runs — es ist keine Politik, sondern eine Folge.
## Entscheidung
### 1. Format und Ort: `bevy_settings` mit eigener Fortschrittsdatei
Der Meta-Fortschritt lebt als `Resource` mit `#[derive(SettingsGroup)]` und
`#[settings_group(file = "progress")]` — also in **`progress.toml`**, getrennt von der späteren
`settings.toml` für Grafik und Audio. Fortschritt und Optionen haben verschiedene Lebensdauern und
verschiedene Löschwege; wer seine Grafikoptionen zurücksetzt, darf nicht seinen Turm verlieren.
Der App-Name ist die Reverse-Domain der Repo-URL: **`de.hive-brain.mdietermann.infinitower`**.
Daraus ergeben sich die Orte:
| Plattform | Verzeichnis | Quelle |
|---|---|---|
| Windows | `%LOCALAPPDATA%\<app>\progress.toml` (`FOLDERID_LocalAppData`) | `dirs/windows.rs` |
| Linux | `$XDG_CONFIG_HOME/<app>/progress.toml`, sonst `~/.config/...` | `dirs/linux.rs` |
| macOS | `~/Library/Preferences/<app>/progress.toml` | `dirs/macos.rs` |
| Web | `localStorage` (kein Dateisystem) | `store_wasm.rs` |
| Android, iOS | **nichts** — `preferences_dir()` liefert `None` | `dirs/mod.rs` |
**Das Format ist damit TOML, und zwar nicht frei gewählt, sondern der Preis des Frameworks.** Die
Abwägung fällt trotzdem für Klartext aus: Ein Spieler kann `progress.toml` mit jedem Editor
aufmachen und sich Workshop-Level schenken. Es gibt kein Leaderboard, keinen Server, keine
Ökonomie und genau einen Spieler — der Schaden ist null, und der Nutzen beim Debuggen eines
Lernprojekts ist real. Verschleierung (Base64, Prüfsumme, Binärformat) hält keinen auf, der es
ernst meint, und kostet genau die Lesbarkeit, die hier den Wert ausmacht. Schummeln wird
ausdrücklich akzeptiert, nicht übersehen.
### 2. Der Sentinel: `version` als erstes Feld
Das erste Feld ist `version: u32` mit `Default = 0`. Beim Schreiben steht dort **immer** der
aktuelle Wert (heute `1`), nie `0`. Weil das Framework der App nicht mitteilt, ob geladen wurde,
ist dieses Feld der Rückkanal — er liegt in den Daten statt in der API.
Nach dem Laden verzweigt genau ein System dreifach:
| Gelesen | Bedeutung | Verhalten |
|---|---|---|
| `0` | Es wurde **nichts** geladen: erster Start **oder** unlesbare Datei | Meldung an den Spieler, **kein Schreiben** bis zur ersten bewussten Aktion |
| `== CURRENT` | Normalfall | Normal weiter |
| `0 < v < CURRENT` | Älterer Stand mit Bedeutungsänderung | Migrationsschritt läuft **vor** dem ersten Schreiben |
| `> CURRENT` | Save aus einer neueren Programmversion | Alle Schreibvorgänge für die Sitzung gesperrt, Meldung an den Spieler |
`0` ist bewusst zweideutig: Der Unterschied zwischen „erster Start" und „konnte nicht lesen" ist
plattformübergreifend nicht feststellbar, weil eine Existenzprüfung auf Dateiebene im Web keine
Entsprechung hat. Statt die Zweideutigkeit zu verstecken, wird sie ausgesprochen — siehe Punkt 4.
### 3. Regel: niemals schreiben, bevor bestätigt gelesen wurde
**Solange nicht feststeht, dass ein vorhandener Stand gelesen wurde, schreibt das Programm nicht.**
Diese Regel ist wichtiger als die Wahl des Frameworks und gilt auch, wenn Punkt 1 später fällt.
Der Grund ist nicht Robustheit, sondern Respekt vor investierter Zeit: Ein Save, der still durch
Defaults überschrieben wird, entwertet rückwirkend jede Stunde, die darin steckt — und der Spieler
erfährt es nie. Ein Programm, das stattdessen einen Satz sagt und wartet, kann beendet werden,
bevor Schaden entsteht, und die Datei liegt noch da.
### 4. Migration: Feldnamen sind der Vertrag, `version` ist die Notausfahrt
| Änderung | Was passiert | Nötig |
|---|---|---|
| Feld kommt hinzu | Schlüssel fehlt in der Datei → `Default` bleibt stehen | nichts |
| Feld fällt weg | Schlüssel ist unbekannt → wird ignoriert, verschwindet beim nächsten Schreiben | nichts |
| Feld ändert seine **Bedeutung** | Reflect wendet den alten Wert widerspruchslos **falsch** an | **Verbot, siehe unten** |
Für die ersten beiden Fälle reicht die Feld-Toleranz des Frameworks; ein Versionsfeld wäre dafür
überflüssig. Der dritte Fall ist der, den `#[serde(default)]`-artige Toleranz prinzipbedingt nicht
abfangen kann — der Schlüssel passt ja.
**Verbindlich: Eine Bedeutungsänderung erzwingt einen neuen Schlüsselnamen, nie eine
Neuinterpretation des alten.** Umbenennen heißt: alter Schlüssel wird ignoriert, neuer Schlüssel
startet auf `Default`. Der Spieler verliert **einen Wert**, nicht seinen Spielstand. Nur wenn genau
dieser eine Wert nicht verloren gehen darf, wird `version` erhöht und ein expliziter
Migrationsschritt geschrieben, der vor dem ersten Schreiben läuft.
Daraus folgt eine zweite verbindliche Regel: **Die Ablage ist namensbasiert, nie positionsbasiert.**
Workshop-Upgrades und Cards stehen als je ein benanntes Feld in der Datei, nicht als Liste mit
Indizes. Eine positionsbasierte Liste macht das Einfügen eines Upgrades in der Mitte zu einer
stillen Bedeutungsänderung aller nachfolgenden Werte — genau der Fall, den das Verbot verhindern
soll, nur unsichtbar.
`version` wird also von Tag eins mitgeschrieben, obwohl es im Normalbetrieb nichts tut. Der Grund
ist, dass man es nicht nachrüsten kann: Eine Datei ohne Versionsfeld ist von `version = 0` nicht zu
unterscheiden.
### 5. Was persistiert wird
| Feld | Typ | Herkunft |
|---|---|---|
| `version` | `u32` | Sentinel, siehe Punkt 2 |
| `workshop_levels` | Struct mit **einem benannten Feld je Upgrade** | Workshop-Käufe zwischen Runs |
| `card_levels` | Struct mit **einem benannten Feld je Card**, `0` = nicht besessen | fasst „besessen" und „Stufe" in einem Feld zusammen |
| `equipped_cards` | Liste von Card-Bezeichnern (Namen, keine Indizes) | Loadout, steht vor dem Run fest |
| `card_slots` | `u8` | freigeschaltete Slot-Zahl |
| *Meta-Währung* | ganzzahlig | **Name offen** — `CONTEXT.md` hat für diese Ressource noch keinen Begriff; der Feldname folgt dem Glossar, sobald er feststeht |
| `highest_wave` | `u32` | höchste je erreichte Welle |
**Kein Zeitstempel.** Ohne Offline-Progress liest ihn niemand, und ein Feld ohne Leser ist die
Miniaturausgabe genau des Fehlers, den ADR-0002 verbietet — gespeicherter Zustand, den niemand
braucht.
**Kein laufender Run.** Keine In-Run-Upgrades, kein Wellenstand, keine Coins, kein `EffectiveStats`.
**Kein `Balance`.** `Balance` ist eine `Resource`, wird aber nie persistiert: Ihre Werte sind laut
ADR-0001 in die Binary gebacken, und ein Save darf nicht auf Werte zeigen, die es im aktuellen
Build nicht mehr gibt.
### 6. Verhältnis zur Ablageregel aus ADR-0002
Die Regel hält, aber sie ist **einseitig** und wird hier präzisiert:
> Persistiert ⊆ `Resource`. Nicht jede `Resource` ist persistiert.
`Balance` ist der Gegenbeleg (Punkt 5). Die Markierung ist deshalb nicht „ist eine `Resource`",
sondern das `SettingsGroup`-Derive — am Typ ablesbar, nicht aus der Ablage zu erraten.
**Es gibt genau eine Ausnahme, und sie ist benannt:** `highest_wave` und die im Run verdiente
Meta-Währung entstehen *innerhalb* eines Runs, müssen ihn aber überdauern. Sie sind trotzdem keine
Run-Daten, sondern Meta-Werte, die aus Run-Daten abgeleitet werden. Verbindlich: **Dieser Übergang
findet genau einmal statt, am Run-Ende, in einem einzigen System.** Kein anderes System schreibt
aus Run-Zustand in eine persistierte `Resource`. Alles Übrige folgt ADR-0002 unverändert.
Die Randbedingung „Cards im Run nur einsehbar" entzieht ADR-0002 Punkt 10 nebenbei ihren Fall 1
(Card-Wechsel im laufenden Run). Der Abgleich in `sync_tower_abilities` bleibt richtig; er hat nur
einen Auslöser weniger. Das ist eine Beobachtung, keine Änderung an ADR-0002.
### 7. Wann geschrieben wird
- Nach **jeder** Änderung einer persistierten `Resource`: `SaveSettingsDeferred` (1 s Debounce).
- **Zusätzlich** beim Beenden: `SaveSettingsSync::IfChanged`. Beides zusammen, nicht eines von
beiden — die Bevy-Doku weist ausdrücklich darauf hin, dass ein Exit-Event auf manchen Plattformen
(etwa Cmd-Q auf macOS) nicht abgefangen werden kann.
- **Nicht periodisch, nicht bei Pause.** Während eines Runs ändert sich keine persistierte Quelle
(Punkt 0 im Kontext), und Pause friert ohnehin alles ein. Es gibt schlicht nichts zu speichern.
Damit fällt in der Praxis **ein** Schreibvorgang pro Run an (Run-Ende) plus je einer pro Kauf oder
Loadout-Änderung zwischen den Runs.
**Atomares Schreiben ist nötig und kostet hier nichts**, weil das Framework es bereits tut: Bei
einer Datei unter 2 KB ist der Preis eine zusätzliche `write`- und eine `rename`-Operation, und der
Schutz gegen einen Absturz mitten im Schreiben ist strukturell statt hoffnungsbasiert.
### 8. Die vier Fehlerfälle
| Fall | Was technisch passiert | Was der Spieler sieht |
|---|---|---|
| **Beschädigte Datei** | `decode_toml_file` loggt `Error parsing settings file` und gibt `None`; alle Resources bleiben auf `Default`, also `version == 0` | „Kein Fortschritt geladen. Es wird ein neuer Spielstand angelegt, sobald du fortfährst." — und bis dahin wird **nicht** geschrieben (Punkt 3) |
| **Halb geschriebene Datei nach Absturz** | Kann `progress.toml` nicht erreichen: geschrieben wird `progress.toml.new`, erst `fs::rename` macht es gültig. Ein Absturz hinterlässt den alten Stand plus eine verwaiste `.new` | nichts — der alte Stand lädt normal |
| **Save aus neuerer Programmversion** | Unbekannte Schlüssel werden ignoriert, bekannte angewandt; `version > CURRENT` fängt das ab | „Dieser Spielstand stammt aus einer neueren Version." Alle Schreibvorgänge sind für die Sitzung gesperrt, die Datei bleibt unangetastet |
| **Fehlende Schreibrechte** | `fs::write` bzw. `fs::rename` schlagen fehl, werden geloggt, und die App erfährt es nicht | **Nichts** — und das ist die ehrliche Lücke dieser Entscheidung. Es gibt in 0.19 keinen Rückkanal. Deshalb zeigt der Slice ausdrücklich **keine** „Gespeichert"-Bestätigung: eine Erfolgsmeldung, die das Programm nicht belegen kann, ist schlimmer als gar keine |
Der Fall, der bestehenden Fortschritt aktiv zerstören kann, ist keiner dieser vier, sondern eine
Verkettung: In `store_fs.rs` fehlt nach einem fehlgeschlagenen `fs::write` ein `return`, sodass
`fs::rename` die möglicherweise abgeschnittene Temp-Datei trotzdem über den intakten Save schiebt.
Auslöser wäre eine volle Platte oder ein Quota. Der Schutz ist einseitig: Parst die abgeschnittene
Datei nicht mehr, fängt sie Fall 1 ab und es wird nichts überschrieben. **Ob eine abgeschnittene
TOML-Datei immer ungültig ist, wurde nicht geprüft und ist unwahrscheinlich** — eine Datei, die
nach einer vollständigen Zeile endet, parst tadellos und hat den Rest still auf `Default`. Genau
deshalb steht dieser Punkt unten als Revisions-Auslöser und nicht als gelöstes Problem.
## Belegtiefe
Jede tragende Behauptung mit ihrer Quelle. Alle Dateipfade beziehen sich auf das Bevy-Repo am Tag
**`v0.19.0`**.
| Behauptung | Beleg | Status |
|---|---|---|
| `bevy_settings` existiert im Engine-Repo, erreichbar als `bevy::settings` hinter dem **nicht-default** Feature `bevy_settings` | `crates/bevy_settings/src/lib.rs`; docs.rs `bevy/0.19.0/bevy/settings/`; Feature-Liste docs.rs `crate/bevy/0.19.0/features` | belegt |
| Attribut `#[settings_group(file = "<name>")]`, Default `settings`, ergibt `<name>.toml` | `lib.rs`, Doc-Kommentar am Trait `SettingsGroup`; verwendet im Test `#[settings_group(file = "audio")]` | belegt |
| `preferences_dir()` liefert auf Android und iOS `None` | `crates/bevy_platform/src/dirs/mod.rs`: `#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] pub fn preferences_dir() -> Option<PathBuf> { None }`. `store_fs.rs` setzt daraufhin `base_path: None`; `save` und `load` sind dann No-Ops mit `warn!("Could not find user configuration directories")` | belegt |
| Pfade: Windows `FOLDERID_LocalAppData`, macOS `~/Library/Preferences`, Linux `$XDG_CONFIG_HOME` bzw. `~/.config` | `dirs/windows.rs` (`SHGetKnownFolderPath`), `dirs/macos.rs`, `dirs/linux.rs` | belegt |
| Atomares Schreiben über Temp-Datei plus `rename` | `store_fs.rs`: `let temp_path = base_path.join(format!("{filename}.toml.new"));` … `fs::rename(&temp_path, file_path)` | belegt |
| In `store_fs.rs` fehlt nach fehlgeschlagenem `fs::write` ein `return`, `fs::rename` läuft trotzdem | `store_fs.rs`, in `save` und `save_async`: `if let Err(e) = fs::write(&temp_path, …) { error!(…); }` — ohne `return`, direkt gefolgt vom `fs::rename` | belegt **durch Code-Lesen**; ein tatsächlicher Datenverlust wurde nicht beobachtet und der Upstream-Bugtracker nicht geprüft |
| Kein Rückkanal an die App über Lade- oder Schreiberfolg | `SettingsStore::{save, save_async, load}` sind `pub(crate)` und geben nichts zurück; `load_settings_file` gibt `()` zurück; das Modul exportiert kein Fehler-Event und keine Status-Resource | belegt |
| Fehlerbehandlung ist **`error!`** für Lese- und Parse-Fehler, **`warn!`** für fehlende Datei und fehlendes Verzeichnis | `store_fs.rs`: `error!("Error reading settings file: …")`, `error!("Error parsing settings file: …")`; `lib.rs`: `warn!("Filename {filename}.toml not found")` | belegt — **korrigiert** gegenüber meiner früheren, ungenauen Aussage „nur `warn!`" |
| Fehlende Felder behalten ihren Wert, unbekannte Schlüssel werden ignoriert | `lib.rs`, `load_properties`, Kommentar wörtlich: *„Deserialize matching field names, ignore ones that don't match."* — die Schleife läuft über die Reflect-Feldnamen und schlägt jeden in der TOML-Tabelle nach | belegt |
| Deferred-Save mit 1 s Debounce; `SaveSettingsSync::IfChanged`; Empfehlung, beides zu nutzen | `lib.rs`: `SaveSettingsDeferred(pub Duration)` mit `Duration::from_secs(1)` als `Default`; `SaveSettingsSync::IfChanged`; Doc-Kommentar am `SettingsPlugin` zum Cmd-Q-Fall auf macOS | belegt |
| Persistierte Typen brauchen `#[reflect(Resource, SettingsGroup, Default)]` | `lib.rs` ruft `ty.data::<ReflectDefault>().unwrap()`, wenn die Resource noch nicht existiert — ein fehlendes `ReflectDefault` ist damit ein Panic, kein stiller Fehler | belegt |
| Ob eine abgeschnittene TOML-Datei zuverlässig ungültig ist | — | **nicht geprüft, wird nicht angenommen** |
| Ob `bevy_settings` in einer späteren Bevy-Version einen Rückkanal bekommt | — | **nicht geprüft** |
## Verworfene Alternativen
- **Eigenes Save-Modul mit `ron` und `serde`** (beide laut ADR-0001 bereits im `Cargo.lock`). Der
Rückkanal wäre ein echter Gewinn — man hielte `io::Result` in der Hand. Der Preis ist alles
andere: eine eigene `preferences_dir()`-Entsprechung für drei Betriebssysteme (Bevys Version ist
nach `dirs-sys` modelliert und braucht auf Windows `unsafe` um `SHGetKnownFolderPath`), ein
eigener `localStorage`-Pfad für Web, eigenes Temp-and-Rename und eine eigene Feld-Toleranz — für
sechs Felder Nutzdaten. Dazu bliebe es dauerhaft eigene Wartungslast, während der `store_fs`-Bug
stromaufwärts gefixt wird und beim nächsten Bevy-Bump gratis ankommt.
- **`dirs` (6.0.0) oder `directories` als Dependency plus eigenes Schreiben.** Löst nur den Ort,
nicht Web, nicht Atomarität, nicht die Feld-Toleranz — und `bevy_platform::dirs` ist ohnehin nach
demselben Vorbild gebaut und liegt bereits im Baum.
- **Alles in `settings.toml`, ohne eigene Datei.** Spart ein Attribut und koppelt Fortschritt an
Grafikoptionen. „Einstellungen zurücksetzen" würde den Spielstand mitnehmen.
- **Verschlüsselung oder Prüfsumme gegen Schummeln.** Kauft nichts (Einzelspieler, kein
Vergleichswert verlässt das Gerät) und kostet die Lesbarkeit, die beim Debuggen den Wert ausmacht.
- **Positionsbasierte Listen für Workshop-Level und Cards.** Kompakter in der Datei, aber ein
eingefügtes Upgrade verschiebt still die Bedeutung aller folgenden Werte — die Fehlerklasse, die
Punkt 4 verbietet, nur ohne Symptom.
- **Speichern bei jeder Änderung ohne Debounce.** Unnötig: Zwischen den Runs ändert sich wenig, im
Run nichts, und `SaveSettingsDeferred` fasst Klick-Serien im Workshop ohnehin zusammen.
## Konsequenzen
**Positiv**
- Keine deklarierte Dependency; nur ein Cargo-Feature (siehe unten).
- Der Spielkern sieht keine Plattform-API — nur ein `#[derive]` und eine `Resource`. Die
Plattformfallunterscheidung liegt vollständig in `bevy_settings`.
- Web funktioniert ohne eigenen Code, weil `localStorage` hinter derselben API liegt.
- Atomares Schreiben, Feld-Toleranz und Pfadauflösung sind fremdgewartet.
- Der Save ist im Editor lesbar — ein Fehlerbild lässt sich in Sekunden verifizieren.
**Negativ**
- **Android und iOS persistieren nichts** und melden es nur als `warn!`. Der Mobile-Port erbt ein
stumm kaputtes Save/Load und braucht eigene Arbeit. `CONTEXT.md` nennt Mobile als nächsten Schritt
nach dem Slice.
- Ein fehlgeschlagener Schreibvorgang ist für die App unsichtbar. Deshalb gibt es keine
„Gespeichert"-Anzeige — der Slice verzichtet lieber auf Rückmeldung als auf Wahrheit.
- `version == 0` kann „erster Start" oder „kaputte Datei" heißen. Die Meldung an den Spieler muss
beides abdecken und darf keines von beidem behaupten.
- Ein *Settings*-Framework trägt Save-Daten. Das passt, solange der Fortschritt aus flachen
Skalaren besteht (ADR-0002 Punkt 5). Sobald ein Save Entities oder Weltzustand enthielte, wäre es
das falsche Werkzeug — und ADR-0002 müsste zuerst fallen.
- Die persistierten Typen sind an `Reflect` gebunden; ein vergessenes `#[reflect(… Default)]` ist
ein Panic beim Start statt eines Compile-Fehlers.
**Neue Dependencies**
Keine deklarierte Dependency. Nötig ist das **nicht-default Cargo-Feature `bevy_settings`**:
```toml
bevy = { version = "0.19.0", features = ["bevy_settings"] }
```
Transitiv kommt dadurch genau **eine** Kiste neu in den `Cargo.lock`: die Fassade **`toml`
(^1.1.0)**. Ihr Unterbau liegt bereits dort (`toml_edit` 0.25.13, `toml_parser` 1.1.3,
`toml_datetime` 1.1.1), ebenso `serde`, `thiserror`, `web-sys` und `windows-sys`. Der Zuwachs an
Compile-Zeit ist damit auf eine dünne Fassadenkiste begrenzt — das ist der Grund, warum diese
Entscheidung nicht gegen die Dependency-Zurückhaltung aus ADR-0001 verstößt.
**Folgeaufgaben** (nicht Teil dieser ADR)
- Der Feldname für die Meta-Währung folgt, sobald `CONTEXT.md` den Begriff festlegt.
- Die Reihenfolge der Plugins: `SettingsPlugin` lädt beim Hinzufügen, also vor allem, was die
geladenen Werte lesen will.
## Revisions-Auslöser
Diese Entscheidung wird neu getroffen, sobald **eines** der folgenden Ereignisse eintritt:
1. **Truncated-Promote beobachtet:** Ein Log-Eintrag `Error saving settings file` erscheint, **oder**
eine Datei `progress.toml.new` bleibt im Einstellungsverzeichnis liegen. Beides ist der direkte
Beleg, dass der fehlende `return` in `store_fs.rs` gegriffen hat. Dann wird das Schreiben
übernommen — Eigenbau nur für den Schreibpfad, Laden bleibt beim Framework.
2. **Mobile im Scope:** Sobald ein Android- oder iOS-Build gebaut werden soll. `preferences_dir()`
liefert dort `None`, und der Fortschritt verschwindet lautlos. Kein Grenzwert, ein Build reicht.
3. **Ein Save muss mehr als flache Skalare enthalten** — Entities, Weltzustand, ein laufender Run.
Dann ist nicht dieses Format falsch, sondern zuerst ADR-0002 Punkt 5 neu zu bewerten.
4. **Ein Vergleichswert verlässt das Gerät** (Leaderboard, geteilter Wellen-Rekord, Cloud-Save).
Ab diesem Moment hat Schummeln ein Opfer, und die Klartext-Entscheidung aus Punkt 1 ist neu zu
treffen — nicht vorher.
5. **Eine zweite Bedeutungsänderung innerhalb eines Slice** erzwingt eine `version`-Erhöhung. Einmal
ist eine Notausfahrt; zweimal heißt, dass die Feldnamen den Vertrag nicht mehr tragen, und dann
wird ein echtes Migrationsschema fällig statt einer Umbenennungsregel.

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.

46
docs/agents/lessons.md Normal file
View File

@@ -0,0 +1,46 @@
# Was man sonst zweimal lernt
Projektwissen, das weder aus dem Code noch aus den ADRs hervorgeht. Kurz halten — hier steht nur, was beim ersten Mal Zeit gekostet hat.
## Das Referenz-Wiki ist nicht abrufbar
Das [Fandom-Wiki zu „The Tower — Idle Tower Defense"](https://the-tower-idle-tower-defense.fandom.com/wiki/The_Tower_-_Idle_Tower_Defense_Wiki) antwortet auf **jede** Unterseite mit `HTTP 402`; Mirrors (`game-vault.net`, `en.namu.wiki`, `tower-hub.com`) laufen in Timeout, 403 oder 502. Das gilt für automatisierte Abrufe, nicht für den Browser.
Der Primärtext liegt deshalb von Hand gesammelt in **`docs/WikiData.md`**. Wer mehr braucht, öffnet die Seite im Browser und hängt sie dort an — Suchmaschinen-Snippets sind es nicht wert: die erste Recherche lief nur über sie und hatte vier Fehler, darunter einen Boss-HP-Wert, der in Wahrheit zu den Fleet-Gegnern gehörte.
**Zwei Seiten existieren nicht** — im Browser gegengeprüft, nicht bloß nicht gefunden:
- die HP-/Schadens-Skalierungsformel pro Welle
- die Workshop-Kurven (Level-Zahlen, Kosten, Softcaps)
Beides ist damit **Design-Freiheitsgrad, nicht fehlender Beleg**. Nicht noch einmal danach suchen lassen.
## `examples/perf_spike.rs` ist die beste Bevy-0.19-Referenz im Repo
Bevy 0.19 ist neuer als der Wissensstand gängiger Sprachmodelle, und die API hat sich in mehreren Bereichen geändert. Der Perf-Spike ist verifizierter, laufender Code aus diesem Repo: Fenster, Kamera, Sprites, Spawn, Bewegung, Kollision, Diagnostics.
Wer gegen Bevy baut, schaut zuerst dort nach, dann in `src/`, dann auf docs.rs — und rekonstruiert die API nicht aus dem Gedächtnis. Widersprechen sich Gedächtnis und Compiler, hat der Compiler recht.
Der Spike bleibt deshalb liegen, obwohl seine Messung abgeschlossen ist.
## Die Perf-Zahlen sind eine untere Schranke, kein Versprechen
Gemessen auf einer **Notebook-iGPU** (Intel Core Ultra 7 265U) mit **untexturierten, gleichfarbigen Sprites**, ohne UI und ohne Audio. Bevy batcht das optimal — die 0,25 µs pro Sprite sind ein Best-Case.
Die Obergrenze von 10 000 gleichzeitigen Gegnern hat darauf 3–7× Reserve. **Nach dem Wechsel auf echte Texturen und mit dem HUD ist neu zu messen**, nicht zu hoffen. Der Spike ist genau dafür reproduzierbar gehalten; die Kommandos stehen in seinem Kopfkommentar.
## Werkzeuge
**`tea` muss ≥ 0.15.0 sein.** Ältere Builds kennen weder `--labels` beim Anlegen noch `issues edit`, `comment` oder `close` — damit fällt die halbe Konvention in `issue-tracker.md` aus, und der Fehler sieht aus wie ein Konfigurationsproblem. `tea --version` prüfen, bevor man lange sucht.
**Der Rebuild ist schneller im Release-Profil als im Debug** (9–10 s gegen 22–26 s) — das Linken der Debug-Info dominiert. ADR-0001 stützt sich darauf. Die Zahl gilt für ein **warmes** Target-Verzeichnis; auf einer frischen Maschine kompiliert der erste Build die gesamte Bevy-Toolchain.
## Die Widersprüche in der Spec sind Absicht
`docs/spec-vertical-slice.md` schließt mit einem Abschnitt „Widersprüche und Lücken" — sechzehn Einträge. Das ist kein liegengebliebener Entwurf, sondern das Ergebnis davon, dass beim Schreiben zum ersten Mal alle Quellen gleichzeitig gelesen wurden. Ein Eintrag hat einen Formelfehler in ADR-0002 aufgedeckt, der Crit Chance auf null rechnete; ein anderer, dass das geplante Debug-Panel den Spielstand hätte zerstören können.
Wer dort aufräumt, ohne zu entscheiden, löscht die Fundstelle und nicht das Problem. Einträge werden von *offen* auf *entschieden, siehe X* umgestellt — nicht entfernt.
## Widerspricht der Code einer ADR, gewinnt nicht der Code
Fällt beim Bauen auf, dass eine Entscheidung nicht trägt, wird **die ADR geändert** und der Fund am zuständigen Ticket vermerkt. Der Code wird nicht daran vorbeigebaut. Das steht so in beiden Karten und ist der Grund, warum die ADRs nach der ersten Bauphase noch etwas wert sind.

BIN
docs/design/hud-in-run.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

688
docs/spec-vertical-slice.md Normal file
View File

@@ -0,0 +1,688 @@
# Vertical-Slice-Spec: InfiniTower
- **Status:** Angenommen
- **Datum:** 2026-08-10
- **Betrifft:** Den vollständigen Funktionsumfang des Vertical Slice — was gebaut wird und woran
wir erkennen, dass es fertig ist
- **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-0003](adr/0003-save-format-ort-und-migration.md) (Save-Format),
[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
beiden in Abschnitt „Zwei Grenzwerte, zwei Begriffe" ausgewiesenen.
Diese Spec sagt, **was** gebaut wird. Die ADRs sagen, **warum es so** gebaut wird — ihre Argumente
werden hier nicht wiederholt, sondern verlinkt. Wo eine Zahl noch nicht feststeht, steht sie als
‹Platzhalter›; sie lebt dann in `Balance` (ADR-0001) und wird durch Spielen gefunden, nicht hier.
---
## Der Loop
Der Spieler startet einen Run. Der Turm steht in der Mitte der Arena und schießt von selbst auf
Gegner, die in Wellen radial von außen anlaufen; jeder Kill gibt Coins, und aus Coins kauft der
Spieler jederzeit In-Run-Upgrades, die den Turm für diesen Run stärker machen. Die Wellen wachsen
schneller als das, was er sich leisten kann — nach sechs bis neun Minuten überrennen sie ihn, der
Turm stirbt, und alle In-Run-Upgrades verfallen mit ihm. Was bleibt, ist die Meta-Währung, die die
Gegner unterwegs gedroppt haben, und was der Boss auf Welle 10 fallen gelassen hat. Damit kauft er
zwischen den Runs permanente Workshop-Upgrades und rüstet gedroppte Cards aus. Der nächste Run
beginnt mit einem stärkeren Turm, kommt tiefer, wirft mehr ab — und endet wieder. Das ist das
Spiel: sterben ist der Fortschrittsmechanismus, nicht sein Gegenteil.
---
## Zwei Grenzwerte, zwei Begriffe
Die Karte nennt beides „Cap", meint aber zwei verschiedene Dinge. Diese Spec trennt sie, und der
Code tut es auch:
| Begriff | Code | Bedeutung |
|---|---|---|
| **Harte Obergrenze** | `HARD_ENEMY_CAP` | 10 000 gleichzeitige Gegner. Ein Perf-Grenzwert aus #4, kein Design-Werkzeug. Wird nie überschritten, von nichts. |
| **Soll-Population** | `target_population(wave)` | Die für die aktuelle Welle angestrebte Zahl lebender Gegner. Wächst mit dem Wellenzähler und ist selbst Teil der Schwierigkeitskurve. Bindet ständig — sie ist die Design-Zielgröße. |
Ein Effekt, der „mehr Gegner" verspricht, hebt die **Soll-Population** an, nie eine Spawn-Zahl
(Begründung: Cap-Analyse in Issue #1 — bei bindender Soll-Population wäre ein Spawn-Multiplikator
exakt wirkungslos). Die harte Obergrenze bleibt davon unberührt.
---
## Feststehende Zahlen
Alles hier ist entschieden. Alles andere ist ‹Platzhalter› oder steht unter „Offen".
| Größe | Wert | Quelle |
|---|---|---|
| Wellen-Takt | ‹26 s› Spawn-Phase + ‹9 s› Cooldown-Phase = ‹35 s› bei Normalgeschwindigkeit | Primärtext, als Startwert übernommen (#8) |
| Wellen pro Spielzeit | 10 min ≈ 17 Wellen · 20 min ≈ 34 · 30 min ≈ 51 | Folge des Takts (#8) |
| Boss-Welle | jede 10. Welle | Primärtext (#3), übernommen |
| Ziel für Run 1 | Tod zwischen **Welle 10 und 15** — genau ein Boss gesehen, kurz danach tot; 6–9 Minuten | #8 |
| Wachstumsform | konstant exponentiell pro Welle, plus ein Multiplikator auf Boss-Wellen | #8 |
| Kosten- vs. HP-Verlauf | Kosten wachsen anfangs langsamer als Gegner-HP, ab ca. zwei Dritteln der Runlänge überholt die HP-Kurve. Entsteht von selbst, wenn der Kosten-Exponent das Einkommenswachstum übersteigt — **kein separater Knick** | #8 |
| Meta-Ertrag | Drop-basiert: Chance je Normalgegner, **garantiert je Boss**; gedroppte Menge wellenabhängig | #8 |
| Workshop-Kosten | exponentiell pro Level | #8 |
| Tuning-Zielgröße im Run | Zeit zwischen zwei bezahlbaren Käufen: früh ‹10–20 s›, später seltener | #8 |
| Harte Gegner-Obergrenze | 10 000 gleichzeitig (gemessener Bruch bei ~70 000, ohne Texturen/UI/Audio) | #4, Issue #1 |
| Perf-Budget bei 10 000 | Gameplay-Logik 0,114 ms von 16,67 ms; Rendering 96–98 % | #4 |
| In-Run-Shop-Struktur | vier Tabs: **Attack Shop · Defence Shop · Utility Shop · Ultimates** | HUD-Mockup, #10 |
| Shop-Kapazität | ~8 Zeilen je Tab ohne Scrollen → Größenordnung **~24 In-Run-Upgrades** | HUD-Mockup, #10 |
| Kauf-Modus | Buy x1, Kosten pro Kauf, **jederzeit** im Run — kein Wellenende, keine Freigabe | Issue #1, #8 |
| Card-Seltenheiten | drei: **Common / Rare / Epic** | #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-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 |
| 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 |
| 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):
1. Run 1 endet zwischen Welle 10 und 15.
2. Jeder Run trägt mindestens ein Workshop-Level ein.
3. Die Run-Tiefe wächst über die ersten zehn Runs monoton, ohne dass ein Run mehr als etwa das
Doppelte des vorigen erreicht.
---
## Was im Slice ist
### 1. Arena, Turm und Kamera
2D, `bevy_sprite`, orthographische Kamera. Ein `Tower` fest in der Mitte, Programmer-Art. Kein
Scrollen, kein Zoom, keine Turm-Platzierung.
**Akzeptanz**
- Nach Programmstart und Run-Start ist der Turm sichtbar in der Bildmitte des Gameplay-Fensters, und
die gesamte Spawn-Peripherie liegt innerhalb des sichtbaren Bereichs.
- Die Turm-Entity wird beim Run-Start gespawnt und beim Run-Ende despawnt (ADR-0002 §6); zwischen
zwei Runs existiert keine Entity mit `EffectiveStats`.
### 2. Wellen-Takt
Eine `Wave` besteht aus zwei separat getakteten Phasen. Die nächste Welle beginnt, wenn **beide**
Timer abgelaufen sind — nicht wenn alle Gegner tot sind. Eine einzige endlose Kurve, keine Tiers.
**Akzeptanz**
- Während der Spawn-Phase erscheinen Gegner; nach ‹26 s› hören Spawns auf, während bereits
vorhandene Gegner weiter laufen und angreifen; nach weiteren ‹9 s› beginnt Welle N+1.
- Wird Welle N+1 gestartet, während noch Gegner aus Welle N leben, laufen diese unverändert weiter
und werden nicht entfernt.
- Der aktuelle Wellenzähler ist im HUD ablesbar und stimmt mit dem Telemetrie-Log überein.
- Auf jeder Welle mit `wave % 10 == 0` spawnt genau ein Boss.
### 3. Gegner und Population
Drei bis vier Normal-Archetypen plus ein Boss (#3): ein Tempo/HP-Tradeoff-Paar, ein Fernkämpfer und
**eine** Sondermechanik, die Ziel-Priorisierung erzwingt (Splitter oder Schutz-Aura). Der Boss ist
ein einzelner schwerer, langsamer Meilenstein-Gegner. Keine Elite-Klasse, keine Fleet-Klasse, keine
Immunitätsmatrix.
Der Spawner ist **cap-getrieben, nicht plan-getrieben**: er füllt bis zur `target_population(wave)`
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.
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**
- 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.
- Die Zahl lebender Gegner überschreitet **nie** `HARD_ENEMY_CAP` — auch nicht bei einem
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
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.
### 4. Turm-Dauerfeuer und geteilter Reichweiten-Scan
Der Turm schießt selbstständig auf Gegner in Reichweite. Genau **ein** System ermittelt pro Frame
den Reichweiten-Zustand; `tower_fire` und **alle** Auslöse-Bedingungen (Feature 7) lesen dessen
Ergebnis. Das ist keine Optimierung für später, sondern die Bedingung, unter der die
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**
- Bei mindestens einem Gegner in Reichweite feuert der Turm ohne jede Eingabe innerhalb von
`EffectiveStats.AttackInterval` Sekunden.
- Ohne Gegner in Reichweite feuert er nicht.
- Es existiert genau ein System, das die Gegnermenge in Turm-Reichweite bestimmt, und es fragt das
`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
Vollständig nach [ADR-0002](adr/0002-stat-und-modifier-pipeline.md): drei Akkumulatoren (`flat`,
`points`, `mult`), ein Modus-Feld je Stat mit den drei Varianten `Direct` / `Interval` /
`Fraction`, Clamp einmal auf die Prozentpunkt-Summe, `EffectiveStats` als `Component` an der
Turm-Entity, Neuberechnung bei Änderung. `type Stat = f32`. Alle Zahlen aus `Res<Balance>`
(ADR-0001).
**Akzeptanz**
- 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. 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
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`
am Ende des Abgleichs ist vorhanden und greift im Debug-Build.
- Jeder Modifikator, der in den Slice aufgenommen wird, hat die Sichtbarkeitsanforderung aus
ADR-0002 §11 vor dem Merge durchlaufen — inklusive Punkt 3 (beobachtbare Folge in der
Run-Telemetrie).
### 6. Coins und In-Run-Upgrades
`Coins` fallen bei jedem Kill an und werden **beim Tod gutgeschrieben** — kein einsammelbares Objekt,
keine Entity (Cap-Analyse, Issue #1: bei tausenden Kills pro Welle wäre das ein Entity-Sturm). Damit
kauft der Spieler In-Run-Upgrades in drei Tabs (Attack / Defence / Utility), jederzeit, einzeln
(Buy x1). Alle verfallen am Run-Ende, weil sie als `RunUpgrades`-Component an der Turm-Entity hängen
(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**
- Ein Kauf ist zu jedem Zeitpunkt eines laufenden Runs möglich, auch mitten in der Spawn-Phase, ohne
dass die Simulation anhält.
- Ein Klick auf „Buy x1" zieht genau die angezeigten Kosten ab, erhöht das Upgrade um eine Stufe,
und der betroffene Wert in `EffectiveStats` ändert sich im selben Frame ablesbar.
- Ist der Coin-Stand kleiner als die Kosten, ist die Zeile nicht klickbar und als solche erkennbar.
- Nach einem Run-Ende und Run-Start stehen alle In-Run-Upgrades wieder auf Stufe 0, ohne dass
irgendein System sie zurücksetzt.
- 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
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
**Ein** System wertet die Auslöse-Bedingungen aller aktiven Fähigkeiten aus — der Ultimate und jeder
Fähigkeits-Card. Eine Bedingung ist **Cooldown UND ein Weltzustand** (z. B. „‹N› Gegner in
Reichweite"), je Fähigkeit fest verdrahtet und nicht vom Spieler einstellbar. Die Werte kommen wie
alle Zahlen aus `Balance`. Feuern mehrere im selben Frame, feuern alle — keine Sperre, keine
Priorität.
**Akzeptanz**
- Eine Fähigkeit feuert erst, wenn beide Teile der Bedingung erfüllt sind; ein abgelaufener Cooldown
allein löst nachweislich nicht aus (prüfbar, indem man die Arena leerräumt und wartet).
- 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
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 — weder
über die Gegner-Query noch als eigene Abfrage am `SpatialGrid` (ADR-0004 §6).
### 8. Ultimate: Death Wave
Ein radialer Sweep von der Turmmitte nach außen, automatisch ausgelöst über Feature 7. Kein
Zielsystem, kein Knopf, kein Eingabepfad. Die Systemanforderung, die sie aufdeckt, ist der
**gebatchte Massentod**: ein Sweep kann in einem Tick tausende Gegner treffen.
**Akzeptanz**
- Bei erfüllter Bedingung startet der Sweep ohne Eingabe; ein sichtbarer Ring wandert von der
Turmmitte nach außen und trifft Gegner in der Reihenfolge ihres Abstands.
- Ein Sweep, der ‹5 000› Gegner gleichzeitig tötet, erzeugt keinen Frame über 16,67 ms — der
Massentod wird gebatcht, nicht als tausende Einzelereignisse abgearbeitet.
- Der verbleibende Cooldown ist im Ultimates-Tab ablesbar.
- Flächenwirkung ist über ein **Ziel-Limit pro Auslösung** gedeckelt, nicht über einen schrumpfenden
Radius (Cap-Analyse, Issue #1).
### 9. Cards, Slots und Loadout
Cards fallen als Drop aus Fortschritt: **Boss-Kill** oder **neuer Wellen-Rekord**. Sie sind nicht
kaufbar und haben keine eigene Währung. Eine Card ist entweder ein Stat-Modifikator oder eine
**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.
**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
einsehbar, nicht wechselbar (Issue #1). Damit wird im laufenden Run nie eine Fähigkeits-Component
entfernt (ADR-0002 §10, Nachtrag zu #6).
**Akzeptanz**
- Nach einem Boss-Kill oder einem neuen Wellen-Rekord ist die Card-Ausbeute des Runs auf dem
Meta-Screen sichtbar, spätestens beim ersten Betreten nach dem Run-Ende.
- Der Spieler kann vor dem Run so viele Cards ausrüsten, wie er Slots hat, und keine mehr.
- Eine ausgerüstete Fähigkeits-Card erzeugt beim Run-Start genau eine Fähigkeits-Component am Turm;
eine abgelegte erzeugt keine.
- 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
**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
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
Gegners, kein Pickup. Der `Workshop` kauft davon permanente Stat-Upgrades mit exponentiell
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**
- Eine Ausgabe von `Coins` im Run verringert den Meta-Währungs-Stand nicht, und umgekehrt.
- Ein Boss-Kill schreibt garantiert Meta-Währung gut; die gutgeschriebene Menge auf Welle 20 ist
höher als auf Welle 10.
- Ein Workshop-Kauf erhöht den `flat`- bzw. `mult`-Beitrag des betroffenen Stats sofort und
ü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).
- 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
Fällt die Turm-HP auf 0, endet der Run. **Genau ein System** überführt am Run-Ende die aus dem Run
abgeleiteten Meta-Werte (`highest_wave`, verdiente Meta-Währung, Card-Ausbeute) in die persistierten
Resources (ADR-0003 §6). Danach wird die Turm-Entity despawnt; alles Run-gebundene verschwindet mit
ihr, ohne Aufräum-Code.
**Akzeptanz**
- Bei HP ≤ 0 endet der Run innerhalb eines Frames; danach existiert keine Entity mit
`EffectiveStats`, `RunUpgrades` oder einer Fähigkeits-Component mehr.
- `highest_wave` wächst nur, wenn die erreichte Welle den bisherigen Stand übersteigt, und wird
genau einmal pro Run geschrieben.
- Kein anderes System als dieses eine schreibt aus Run-Zustand in eine persistierte `Resource`
(prüfbar über die Schreibstellen der `SettingsGroup`-Resources).
- Der Spieler landet nach dem Run-Ende auf dem Meta-Screen, ohne Zwischenschritt und ohne dass ein
weiterer Run automatisch startet.
### 12. In-Run-HUD und Pause
Links das Gameplay-Fenster, rechts ein vertikales Panel über die volle Höhe. Das Panel trägt oben
den Namen des aktiven Tabs, darunter bis zu ~8 Zeilen im Schema `<Stat Name>` / `<Stat Value>` links
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
**gesamte** Simulation ein und gibt Einsicht in die ausgerüsteten Cards.
Das Mockup (`docs/design/hud-in-run.png`) legt Panel und Tabs fest, zeigt aber weder Turm-HP,
Wellenzähler, Währungsstände noch den Pause-Button. Diese vier gehören ins Gameplay-Fenster oder
dessen Rand; ihre Platzierung ist frei, ihre Existenz nicht.
**Akzeptanz**
- Die vier Tabs sind vorhanden und schaltbar; ein Tab-Wechsel hält die Simulation nicht an.
- Turm-HP, aktuelle Welle, `Coins`-Stand und Meta-Währungs-Stand sind während des Runs jederzeit
ohne Tab-Wechsel ablesbar.
- Der Pause-Button friert Gegnerbewegung, Turmfeuer, Wellen-Timer und Fähigkeits-Cooldowns
gleichzeitig ein; nach dem Fortsetzen laufen alle vier von genau ihrem Stand weiter.
- Im Pause-Zustand sind die ausgerüsteten Cards einsehbar und **nicht** wechselbar.
- Es gibt **keine** „Gespeichert"-Bestätigung — das Programm kann sie nicht belegen (ADR-0003 §8).
### 13. Meta-Screen zwischen den Runs
Der Zustand zwischen zwei Runs. Er trägt Workshop-Käufe, das Card-Loadout, den Slot-Fortschritt und
den Start-Knopf für den nächsten Run. **Sein Aufbau ist offen** (siehe „Offen"); was hier steht, ist
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**
- Ein neuer Run ist nur von hier aus startbar.
- 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,
verdiente Meta-Währung, gedroppte Cards.
- Steht `version` nach dem Laden auf `0`, erscheint hier die Meldung aus ADR-0003 §8 — und bis zur
ersten bewussten Aktion des Spielers wird nicht geschrieben.
### 14. Save/Load
Vollständig nach [ADR-0003](adr/0003-save-format-ort-und-migration.md): `progress.toml` über
`bevy_settings`, `version` als Sentinel, Feldnamen als Vertrag, namensbasierte Ablage. Persistiert
werden nur Quellen, nie berechnete Werte (ADR-0002 §5). Kein laufender Run, kein `Balance`, kein
Zeitstempel.
**Akzeptanz**
- Nach einem Programmneustart stehen Workshop-Level, Card-Bestand, Loadout, Slot-Zahl,
Meta-Währungsstand und `highest_wave` unverändert da.
- Ein von Hand beschädigtes `progress.toml` führt zur Meldung aus ADR-0003 §8 und **nicht** zu einem
Schreibvorgang, bevor der Spieler etwas tut. Die beschädigte Datei liegt danach unverändert da.
- Ein `progress.toml` mit `version` größer als `CURRENT` sperrt alle Schreibvorgänge für die Sitzung
und lässt die Datei unangetastet.
- Ein Umbenennen eines Feldes lässt den restlichen Spielstand intakt und setzt genau einen Wert auf
`Default`.
- Die Datei ist in einem Texteditor lesbar und von Hand editierbar — Schummeln ist ausdrücklich
akzeptiert, nicht übersehen.
### 15. Debug-Panel und Run-Telemetrie
**Kein Nice-to-have.** ADR-0001 und #8 benennen dieses Werkzeug als den eigentlichen Hebel, um die
vier freien Zahlen überhaupt finden zu können: ohne Sprung-zu-Welle kostet jeder Datenpunkt einen
Run von Welle 1 an, bei ‹35 s› pro Welle also Minuten. Das Panel mutiert `Balance` im laufenden
Prozess und nutzt dieselbe Naht wie der spätere Asset-Ladepfad (ADR-0001 §3).
Umfang: Sprung zu einer beliebigen Welle · Live-Mutation der `Balance`-Werte · Anzeige von
`EffectiveStats` samt der **ungeclampten** Summen (ADR-0002 §2) · Herkunfts-Aufschlüsselung pro Stat
(ADR-0002 §6) · Run-Telemetrie: Zeit bis Tod, erreichte Welle, Coins pro Minute, Kills pro Welle,
DPS-Verlauf, Zeit zwischen zwei bezahlbaren Käufen.
**Akzeptanz**
- Ein Sprung zu Welle N versetzt den laufenden Run in Welle N mit der für N vorgesehenen
Soll-Population und Gegner-Skalierung, ohne Programmneustart, in unter ‹2 s›.
- Eine Änderung an einem `Balance`-Wert im Panel wirkt ohne Neustart auf die nächste Neuberechnung
von `EffectiveStats`.
- Am Run-Ende liegt ein Telemetrie-Datensatz vor, der alle oben genannten Größen enthält und sich
maschinenlesbar exportieren lässt.
- **Ein Run, in dem das Debug-Panel benutzt wurde, schreibt nichts in `progress.toml`.** Weder
`highest_wave` noch Meta-Währung noch Card-Drops. Ein Sprung zu Welle 500 darf keinen
Wellen-Rekord und keinen Card-Drop erzeugen.
- Das Panel ist im Release-Build nicht erreichbar, ohne dass ein Schalter dafür umgelegt wird.
---
## Was ausdrücklich nicht drin ist
| Nicht im Slice | Warum |
|---|---|
| Pfadfindung, Turm-Platzierung, Maze-Building | die Arena ist radial und hat genau einen Turm — es gibt nichts zu platzieren und keinen Pfad zu finden. |
| Tiers | wir haben uns bewusst für **eine** endlose Kurve entschieden; Tier-Resets sind das Langzeit-Muster des Originals, nicht unser Slice-Problem. |
| Labs, Modules, Perks | dritte, vierte und fünfte Meta-Achse neben Workshop und Cards — der Slice soll zeigen, dass **eine** Progressionsschleife trägt, nicht dass fünf sich addieren. |
| Offline-Progress | braucht einen Zeitstempel im Save, und ein Feld ohne Leser ist genau der Fehler, den ADR-0002 §5 verbietet. |
| Elite- und Fleet-Klasse | ihre Mechanik im Original ist eine Anti-Cheese-Antwort auf ein Fähigkeiten-Arsenal, das wir gar nicht haben (#3). |
| Immunitätsmatrix | dieselbe Begründung — sie schützt vor Fähigkeiten, die der Slice nicht kennt. |
| Wall als zweiter HP-Pool | ein zweiter HP-Pool verdoppelt die Schadenslogik, ohne eine neue Frage zu beantworten (#3). |
| Coin- und Card-Pickups als Entities | bei tausenden Kills pro Welle ist ein einsammelbares Objekt pro Drop ein Entity-Sturm (Cap-Analyse #1). |
| Coin Decay, Heat-up, Enemy-Mass-Wachstum | Coin Decay und Mass sind dichte-neutral, aber ohne Nutzen im Slice; Heat-up ist bei 13-facher Angreiferzahl schlicht nicht übertragbar und müsste neu erfunden statt übernommen werden (Cap-Analyse #1). |
| Gacha-Kopien, Card-Mastery, Pity-System | das Original kauft Cards mit einer Währung, die es bei uns nicht gibt (Ads, IAP) — Drop-Verteilung ersetzt beides ersatzlos (#11). |
| Manuelle Ultimate-Auslösung | ein Knopf wäre der einzige Moment aktiver Kontrolle im ganzen Spiel und würde das Kern-Gefühl „Idle" brechen statt bestätigen (#9). |
| Einstellbare Auslöse-Bedingungen | die Bedingungen liegen bereits als Daten in `Balance`; sie einstellbar zu machen ist reine UI-Arbeit und kauft im Slice nichts (#12). |
| Buy x10 / Buy Max | das HUD-Schema lässt Platz dafür, ohne das Layout zu ändern — der Slice braucht es nicht (#10). |
| Spielgeschwindigkeits-Multiplikator | nicht entschieden und nicht im HUD; das Debug-Panel deckt den Zeitraffer-Bedarf beim Tunen ab (siehe „Lücken"). |
| Bots, Wave Skip, Recovery Packages | Systeme, die eine bereits laufende Schleife garnieren — der Slice muss erst zeigen, dass die Schleife läuft. |
| Guilds, Tournaments, Cloud-Saves, Accounts, IAP, Leaderboards | alles Server-seitige ist out of scope; ein Vergleichswert, der das Gerät verlässt, würde außerdem ADR-0003 §1 (Klartext-Save) neu aufmachen. |
| Mobile- und Web-Port | `bevy_settings` persistiert auf Android und iOS **nichts** und meldet es nur als `warn!` — der Port braucht eigene Arbeit (ADR-0003, Revisions-Auslöser 2). |
| Art-Direction, Audio | Programmer-Art ist eine Entscheidung, kein Zwischenstand; ein Art-Strang beantwortet keine der offenen Fragen. |
| Hot-Reload für Balancing-Daten | der Edit-to-see-Loop kostet gemessene ~11 s, und der teure Teil eines Tuning-Zyklus ist das Spielen (ADR-0001). |
| „Gespeichert"-Bestätigung | das Programm kann sie nicht belegen — es gibt in Bevy 0.19 keinen Rückkanal (ADR-0003 §8). |
---
## Abnahmekriterium für den Slice
Genau eines, überprüfbar, ohne Debug-Panel:
> **In einer ununterbrochenen Sitzung von höchstens 60 Minuten auf einem frisch angelegten
> Spielstand spielt ein Spieler Runs, bis er einen zweiten Card-Slot besitzt und zwischen
> mindestens zwei Cards wählen kann, startet danach einen weiteren Run und erreicht darin eine
> höhere Welle als in Run 1; nach einem Neustart des Programms steht derselbe Meta-Stand wieder da.**
Warum genau dieses: Es prüft alle vier Takte des Loops in einer Beobachtung — Run starten, sterben,
aufwerten, stärker starten — und bindet zusätzlich die Randbedingung aus #11 ein, dass der Slice das
Card-System nicht nur enthalten, sondern **zeigen** muss. Ein Slice, in dem Slot 2 ein Fernziel
bleibt, hat Cards gebaut und nie vorgeführt. Der Neustart am Ende prüft Feature 14 dort, wo es zählt.
---
## Offen — bewusst nicht entschieden
Für keinen dieser Punkte wird hier eine Antwort erfunden.
| Punkt | Wer entscheidet | Wann |
|---|---|---|
| **Die vier freien Zahlen**: Wachstumsfaktor pro Welle, Boss-Multiplikator, Drop-Chance mal Wellenwert, Kosten-Exponent des Workshops | Der Entwickler beim Tunen. Form und Prüfkriterien stehen in #8, die Werte findet nur Spielen | Nach Feature 15 (Debug-Panel), vor dem Abnahmekriterium |
| **Name der Meta-Währung** | `CONTEXT.md` — der Glossar-Eintrag ist der Vertrag, der Save-Feldname folgt ihm | Bevor der erste Workshop-Preis geschrieben wird. `Coins` ist an die In-Run-Währung vergeben; ADR-0003 lässt das Feld unbenannt, weil ein vorläufiger Name nach der Migrationsregel (§4) teuer zu korrigieren wäre |
| **Aufbau des Meta-Screens** (Workshop, Cards, Slot-Fortschritt) | Ein eigenes Design-Ticket, analog zum HUD-Mockup | Vor Feature 13. Nur das In-Run-HUD ist entworfen |
| **`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 |
| **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-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 |
---
## Widersprüche und Lücken
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.
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
1. **Card-Drops überqueren die Run-Grenze, aber ADR-0003 kennt sie nicht.** ADR-0003 §6 nennt
ausdrücklich **genau eine** Ausnahme von der Regel „während eines Runs ändert sich keine
persistierte Quelle": `highest_wave` und die verdiente Meta-Währung, überführt genau einmal am
Run-Ende. `card_levels` steht nicht in dieser Ausnahme — obwohl #11 (entschieden 12:48, also vor
ADR-0003 um 13:46) Cards aus **Boss-Kills und neuen Wellen-Rekorden** droppen lässt, beides
Ereignisse *im* Run. Die Spec löst das oben nach ADR-0003 auf: Card-Drops werden im Run gepuffert
und zusammen mit den übrigen Meta-Werten am Run-Ende überführt. Wer das anders bauen will, muss
zuerst die Ausnahmeliste in ADR-0003 §6 erweitern — nicht stillschweigend ein zweites System
schreiben lassen.
2. **Die Slot-Kurve gehört niemandem, ist aber Abnahmebedingung.** — **Entschieden, siehe Feature 10
und Feature 9.** *Der Befund bleibt stehen, weil er zeigt, wo die Lücke herkam.* #11 entscheidet
„Start bei 1, wächst auf 5, die Slot-Zahl ist selbst Progression" und bindet Slot 2 ausdrücklich
ans Abnahmekriterium des Slice. **Wodurch** ein Slot freigeschaltet wird, sagte niemand: nicht
#11 (schiebt es an #8/#10), nicht #8 (die vier freien Zahlen enthalten keine Slot-Kurve), nicht
die Karte (der Fog nennt nur den Meta-Screen-Aufbau). `card_slots: u8` ist in ADR-0003
persistiert, aber es gab keinen Mechanismus, der ihn erhöht.
**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
heißt „Wellen- und Economy-Kurve für einen **10–20-Minuten-Run**", sein erster Kommentar
verlangt Tod „um Welle 20–40", und die Entscheidung im zweiten Kommentar setzt Run 1 auf **Welle
10–15 = 6–9 Minuten**. Die Karte hat die letzte Fassung übernommen, die frühere steht unwidersprochen
daneben. Wahrscheinlich gemeint: 10–20 Minuten gilt für den **eingespielten** Run nach einigen
Workshop-Leveln, 6–9 Minuten für Run 1. Das steht so aber nirgends, und die Kurve wird gegen die
Zahl getunt, die man liest. Diese Spec folgt der Entscheidung (Welle 10–15) und hält fest, dass
für Run *n* keine Ziel-Runlänge existiert.
4. **Die Cap-Analyse balanciert Sub-Caps für Gegnerklassen, die #3 abgeschafft hat.** Der
#1-Kommentar teilt die Sub-Caps sorgfältig in Modifikator-Caps (Protector 8, **Elite pro Typ 8**)
und Event-Caps (Boss 10, **Elite gesamt 20**). #3 wirft Elite und Fleet aber komplett aus dem
Slice. Damit ist die Hälfte der Analyse gegenstandslos: übrig bleiben genau **ein** mitskalierendes
Modifikator-Cap (die eine Sondermechanik-Einheit, Protector-artig) und **ein** absolutes
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.
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
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
Attack 5 (`Damage`, `AttackInterval`, `Range`, `CritChance`, `CritFactor`), Defence 4
(`MaxHealth`, `DefensePercent`, `DefenseFlat`, `HealthRegen`) und **Utility 1** (`CoinGain`). Ein
Utility-Tab mit einer Zeile ist kein Tab. Entweder das Panel füllt nur die Hälfte der Zeilen —
dann trägt die Folgerung „kann nicht alles kaufen" nicht mehr —, oder es braucht In-Run-Upgrades,
die keine Stats aus ADR-0002 sind, und dann ist ADR-0002 §3 unvollständig. Beides ist eine
Entscheidung, keine Detailfrage.
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 war damit der schwächste
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
HUD-Kommentar (13:38) rechnet mit 12–17 Kaufmomenten aus einer 20-Minuten-Session, #8 (13:51)
verwirft die Rechnung ausdrücklich („die trägt nicht mehr", weil jederzeit gekauft wird) **und**
verkürzt Run 1 auf 10–15 Wellen. Was aus dem HUD-Kommentar überlebt, ist allein die Zahl 24 —
sie kommt aus dem Layout, nicht aus der Session-Rechnung. Die Tuning-Zielgröße ist die **Zeit
zwischen zwei bezahlbaren Käufen** (‹10–20 s› früh), nicht eine Kaufzahl pro Run.
### Lücken, die niemandem gehören
7. **Das Debug-Panel kann den echten Spielstand zerstören, und niemand hat es benannt.** Feature 15
springt zu Welle N. Feature 11 schreibt am Run-Ende `highest_wave` und die Meta-Währung in die
persistierte Resource, und #11 macht „neuer Wellen-Rekord" zu einer **Card-Drop-Quelle**. Ein
Sprung zu Welle 500 beendet einen Run also mit `highest_wave = 500`, einem gewaltigen Card-Drop
und einem Meta-Währungsstand, der den ganzen Slice trivialisiert. Weder ADR-0001 noch #8 noch
ADR-0003 erwähnen das. Diese Spec zieht die Konsequenz oben als Akzeptanzkriterium ein: ein Run
mit benutztem Debug-Panel schreibt **nichts**. Wer das Panel baut, muss die Schreibsperre
zuerst bauen.
8. **Die Card-Nachschubquelle „neuer Wellen-Rekord" versiegt eingebaut.** Beide Card-Quellen hängen
an Fortschritt: Boss-Kill und neuer Wellen-Rekord. Solange die Run-Tiefe monoton wächst (#8,
Prüfkriterium 3, gilt für die **ersten zehn** Runs), fließt Nachschub. Danach ist unbestimmt, was
passiert — ein Spieler, der zehn Runs lang auf derselben Welle hängen bleibt, bekommt keine neue
Card mehr und hat gleichzeitig keinen anderen Hebel, tiefer zu kommen als den Workshop. Ob das
ein Feature ist (Cards folgen dem Fortschritt) oder eine Sackgasse (Fortschritt braucht Cards),
hat niemand entschieden. Bei einem Run 1, der auf Welle 10–15 endet, ist außerdem der
**eine** Boss-Kill pro Run in der Frühphase der einzige garantierte Drop überhaupt — er trägt
Meta-Währung und Card-Chance gleichzeitig.
9. **Gegner-gegen-Gegner-Kollision ist seit #4 offen und steht auf keiner Liste.** —
**Entschieden, siehe [ADR-0004](adr/0004-spatial-grid-fuer-gegner-nachbarschaft.md).** *Der
Befund bleibt stehen.* #4 und die Cap-Analyse nannten sie als „offene Architekturfrage": Stapeln
zulassen oder Spatial Grid mit weicher Separation. Bei tausenden radial konvergierenden Körpern
kommen O(n²) und ein Stau am Turmring zusammen, den keine DPS-Menge auflöst. Sie stand **nicht**
im Fog der Karte, in keiner ADR und in keinem der offenen Punkte, die diese Spec zugewiesen
bekommen hat.
*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
ordnet beides ausdrücklich „der Architektur" zu, aber ADR-0002 regelt Stats und
Fähigkeits-Components, nicht Massenwirkung. Death Wave (#9) trifft beide Punkte sofort. Diese
Spec hat sie deshalb als Akzeptanzkriterien in Feature 8 aufgenommen — das ersetzt keine ADR,
macht sie aber wenigstens prüfbar.
11. **Ein Spielgeschwindigkeits-Multiplikator ist weder entschieden noch verworfen.** Die
Cap-Analyse wirft ihn auf („ein Idle-Spiel mit 35-Sekunden-Wellen will fast sicher einen") und
entschärft ihn nur perf-seitig; das Referenzspiel hat ihn; das HUD-Mockup zeigt ihn nicht.
Diese Spec listet ihn oben unter „nicht drin", weil das Debug-Panel den Zeitraffer-Bedarf beim
Tunen abdeckt — aber das ist eine Entscheidung, die hier zum ersten Mal getroffen wird und
jederzeit umkehrbar ist.
12. **`WaveCooldown` hat einen Cap, aber im Slice keine Quelle.** ADR-0002 §3 führt den Stat samt
`caps.wave_cooldown_reduction`; im Original liefert ihn die Epic-Card „Wave Accelerator". Enthält
die noch offene Card-Liste keine entsprechende Card, sind Stat und Cap toter Code. Kein Fehler,
aber ein Punkt, der bei der Card-Liste mitentschieden werden sollte.
13. **Die Fehlermeldung aus ADR-0003 hat keinen Ort.** ADR-0003 §3 und §8 verlangen bei `version == 0`
und bei `version > CURRENT` eine Meldung an den Spieler, bevor irgendetwas geschrieben wird. Der
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
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.

480
examples/perf_spike.rs Normal file
View File

@@ -0,0 +1,480 @@
//! Throwaway performance spike for InfiniTower.
//!
//! Answers one question only: how many enemies and projectiles does Bevy 0.19 carry
//! in 2D before the frame budget of 16.6 ms (60 fps) is gone?
//!
//! Deliberately minimal: no gameplay, no UI, no assets. Enemies walk radially towards
//! the centre, a single tower fires at the nearest one, projectiles fly straight, a hit
//! removes enemy and projectile. The enemy population is held constant so the load
//! does not drift during a run.
//!
//! Run (release profile, otherwise the numbers are meaningless):
//! cargo run --release --example perf_spike -- 2000
//! PERF_N=2000 cargo run --release --example perf_spike
//!
//! Knobs (env vars, CLI arg 1 overrides PERF_N):
//! PERF_N enemy population held constant (default 1000)
//! PERF_FIRE_RATE tower shots per second (default 20)
//! PERF_RENDER 1 = window + sprites, 0 = headless logic (default 1)
//! PERF_SECONDS length of the measurement window (default 12)
//! PERF_WARMUP seconds discarded before measuring (default 4)
//!
//! Every run prints a `=== PERF SPIKE RESULT ===` block with medians (not first-frame
//! values) and then exits on its own.
use std::time::Instant;
use bevy::{
diagnostic::{
Diagnostic, DiagnosticPath, Diagnostics, DiagnosticsPlugin, DiagnosticsStore,
FrameTimeDiagnosticsPlugin, LogDiagnosticsPlugin, RegisterDiagnostic,
},
platform::collections::HashSet,
prelude::*,
};
use core::{f32::consts::TAU, time::Duration};
/// Radius of the ring the enemies spawn on. Chosen so the whole arena fits into a
/// 1920x1080 window: nothing is frustum-culled, so the render cost is honest.
const ARENA_RADIUS: f32 = 500.0;
const ENEMY_SPEED: f32 = 50.0;
const ENEMY_SIZE: f32 = 8.0;
const PROJECTILE_SPEED: f32 = 700.0;
const PROJECTILE_SIZE: f32 = 4.0;
/// A projectile within this distance of an enemy counts as a hit.
const HIT_RADIUS: f32 = 10.0;
/// An enemy this close to the centre has "arrived" and is recycled to the rim.
const CENTER_RADIUS: f32 = 24.0;
/// Enough history to hold every frame of a long run at high fps.
const HISTORY: usize = 20_000;
const D_MAINTAIN: DiagnosticPath = DiagnosticPath::const_new("spike/maintain_us");
const D_MOVE_ENEMIES: DiagnosticPath = DiagnosticPath::const_new("spike/move_enemies_us");
const D_FIRE: DiagnosticPath = DiagnosticPath::const_new("spike/tower_fire_us");
const D_MOVE_PROJ: DiagnosticPath = DiagnosticPath::const_new("spike/move_projectiles_us");
const D_COLLIDE: DiagnosticPath = DiagnosticPath::const_new("spike/collide_us");
const D_ENEMIES: DiagnosticPath = DiagnosticPath::const_new("spike/enemies");
const D_PROJECTILES: DiagnosticPath = DiagnosticPath::const_new("spike/projectiles");
const D_CHECKS: DiagnosticPath = DiagnosticPath::const_new("spike/pair_checks");
#[derive(Resource, Clone, Copy)]
struct SpikeConfig {
enemy_count: usize,
fire_rate: f32,
render: bool,
warmup_secs: f64,
measure_secs: f64,
}
impl SpikeConfig {
fn from_env() -> Self {
let cli_n = std::env::args().nth(1).and_then(|a| a.parse::<usize>().ok());
Self {
enemy_count: cli_n.unwrap_or_else(|| env_num("PERF_N", 1000.0) as usize),
fire_rate: env_num("PERF_FIRE_RATE", 20.0) as f32,
render: env_flag("PERF_RENDER", true),
warmup_secs: env_num("PERF_WARMUP", 4.0),
measure_secs: env_num("PERF_SECONDS", 12.0),
}
}
}
fn env_num(key: &str, default: f64) -> f64 {
std::env::var(key)
.ok()
.and_then(|v| v.parse::<f64>().ok())
.unwrap_or(default)
}
fn env_flag(key: &str, default: bool) -> bool {
std::env::var(key)
.ok()
.map(|v| !matches!(v.as_str(), "0" | "false" | "off"))
.unwrap_or(default)
}
#[derive(Component)]
struct Enemy;
#[derive(Component)]
struct Projectile {
velocity: Vec2,
}
#[derive(Resource)]
struct TowerGun {
timer: Timer,
}
/// Monotonic counter, used as the seed for respawn angles so runs are deterministic.
#[derive(Resource, Default)]
struct SpawnSeed(u32);
#[derive(Resource)]
struct Harness {
elapsed: f64,
measuring: bool,
done: bool,
}
fn main() {
let cfg = SpikeConfig::from_env();
let mut app = App::new();
if cfg.render {
app.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "InfiniTower perf spike".into(),
// Uncapped: with vsync the frame time would just be the monitor's.
present_mode: bevy::window::PresentMode::AutoNoVsync,
resolution: bevy::window::WindowResolution::new(1920, 1080)
.with_scale_factor_override(1.0),
..default()
}),
..default()
}))
.insert_resource(bevy::winit::WinitSettings::continuous());
} else {
// Headless: same simulation, no renderer at all. The delta against the
// rendered run is the render cost.
app.add_plugins((MinimalPlugins, bevy::log::LogPlugin::default(), DiagnosticsPlugin));
}
app.add_plugins((
FrameTimeDiagnosticsPlugin::new(HISTORY),
LogDiagnosticsPlugin {
wait_duration: Duration::from_secs(2),
..default()
},
))
.insert_resource(cfg)
.init_resource::<SpawnSeed>()
.insert_resource(TowerGun {
timer: Timer::from_seconds(1.0 / cfg.fire_rate.max(0.01), TimerMode::Repeating),
})
.insert_resource(Harness {
elapsed: 0.0,
measuring: false,
done: false,
});
for path in [
&D_MAINTAIN,
&D_MOVE_ENEMIES,
&D_FIRE,
&D_MOVE_PROJ,
&D_COLLIDE,
&D_ENEMIES,
&D_PROJECTILES,
&D_CHECKS,
] {
app.register_diagnostic(Diagnostic::new(path.clone()).with_max_history_length(HISTORY));
}
app.add_systems(Startup, setup)
.add_systems(
Update,
(
maintain_population,
move_enemies,
tower_fire,
move_projectiles,
collide,
)
.chain(),
)
.add_systems(Last, harness)
.run();
}
fn setup(mut commands: Commands, cfg: Res<SpikeConfig>) {
if cfg.render {
commands.spawn(Camera2d);
// The tower itself. Purely cosmetic, the firing logic lives in a resource.
commands.spawn((
Sprite::from_color(Color::srgb(0.9, 0.9, 0.2), Vec2::splat(24.0)),
Transform::default(),
));
}
// Initial population: angles evenly spread over the circle, radii staggered.
// Without the stagger every enemy would reach the centre in the same frame and
// the frame time would oscillate instead of settling.
let n = cfg.enemy_count.max(1);
for i in 0..cfg.enemy_count {
let angle = (i as f32 / n as f32) * TAU;
let radius = ARENA_RADIUS * (0.2 + 0.8 * hash01(i as u32));
spawn_enemy(&mut commands, angle, radius, cfg.render);
}
}
/// Keeps the enemy population at exactly `PERF_N`, so the load stays constant while
/// hits and centre-arrivals despawn entities. Also the place where spawn cost shows up.
fn maintain_population(
mut commands: Commands,
cfg: Res<SpikeConfig>,
enemies: Query<(), With<Enemy>>,
mut seed: ResMut<SpawnSeed>,
mut diagnostics: Diagnostics,
) {
let t0 = Instant::now();
let alive = enemies.iter().len();
for _ in alive..cfg.enemy_count {
seed.0 = seed.0.wrapping_add(1);
spawn_enemy(&mut commands, hash01(seed.0) * TAU, ARENA_RADIUS, cfg.render);
}
let us = t0.elapsed().as_secs_f64() * 1e6;
diagnostics.add_measurement(&D_MAINTAIN, || us);
diagnostics.add_measurement(&D_ENEMIES, || alive as f64);
}
fn move_enemies(
mut commands: Commands,
time: Res<Time>,
mut enemies: Query<(Entity, &mut Transform), With<Enemy>>,
mut diagnostics: Diagnostics,
) {
let t0 = Instant::now();
let dt = time.delta_secs();
for (entity, mut transform) in &mut enemies {
let pos = transform.translation.truncate();
let dist = pos.length();
if dist <= CENTER_RADIUS {
// Reached the tower: despawn, `maintain_population` puts it back on the rim.
commands.entity(entity).despawn();
continue;
}
let step = pos / dist * (-ENEMY_SPEED * dt);
transform.translation.x += step.x;
transform.translation.y += step.y;
}
let us = t0.elapsed().as_secs_f64() * 1e6;
diagnostics.add_measurement(&D_MOVE_ENEMIES, || us);
}
/// Single tower, fixed rate, nearest target. The target scan is a full O(n) sweep
/// and only runs on frames where the timer actually fires.
fn tower_fire(
mut commands: Commands,
time: Res<Time>,
cfg: Res<SpikeConfig>,
mut gun: ResMut<TowerGun>,
enemies: Query<&Transform, With<Enemy>>,
mut diagnostics: Diagnostics,
) {
let t0 = Instant::now();
gun.timer.tick(time.delta());
let shots = gun.timer.times_finished_this_tick();
if shots > 0 {
let mut best: Option<(f32, Vec2)> = None;
for transform in &enemies {
let pos = transform.translation.truncate();
let d2 = pos.length_squared();
if best.is_none_or(|(best_d2, _)| d2 < best_d2) {
best = Some((d2, pos));
}
}
if let Some((_, target)) = best {
let dir = target.normalize_or_zero();
for i in 0..shots {
// Fan a multi-shot tick out slightly so the projectiles do not overlap.
let offset = (i as f32 - (shots as f32 - 1.0) * 0.5) * 0.04;
let velocity = Vec2::from_angle(offset).rotate(dir) * PROJECTILE_SPEED;
spawn_projectile(&mut commands, velocity, cfg.render);
}
}
}
let us = t0.elapsed().as_secs_f64() * 1e6;
diagnostics.add_measurement(&D_FIRE, || us);
}
fn move_projectiles(
mut commands: Commands,
time: Res<Time>,
mut projectiles: Query<(Entity, &mut Transform, &Projectile)>,
mut diagnostics: Diagnostics,
) {
let t0 = Instant::now();
let dt = time.delta_secs();
let mut alive = 0usize;
for (entity, mut transform, projectile) in &mut projectiles {
transform.translation.x += projectile.velocity.x * dt;
transform.translation.y += projectile.velocity.y * dt;
if transform.translation.truncate().length() > ARENA_RADIUS * 1.2 {
commands.entity(entity).despawn();
} else {
alive += 1;
}
}
let us = t0.elapsed().as_secs_f64() * 1e6;
diagnostics.add_measurement(&D_MOVE_PROJ, || us);
diagnostics.add_measurement(&D_PROJECTILES, || alive as f64);
}
/// Deliberately naive O(n*m) broad phase: every projectile against every enemy.
/// The whole point of the spike is to find out where this stops being good enough.
fn collide(
mut commands: Commands,
projectiles: Query<(Entity, &Transform), With<Projectile>>,
enemies: Query<(Entity, &Transform), With<Enemy>>,
mut diagnostics: Diagnostics,
) {
let t0 = Instant::now();
let hit_radius_sq = HIT_RADIUS * HIT_RADIUS;
let mut killed: HashSet<Entity> = HashSet::default();
let mut checks = 0u64;
for (projectile, projectile_tf) in &projectiles {
let p = projectile_tf.translation.truncate();
for (enemy, enemy_tf) in &enemies {
checks += 1;
if killed.contains(&enemy) {
continue;
}
if p.distance_squared(enemy_tf.translation.truncate()) < hit_radius_sq {
killed.insert(enemy);
commands.entity(enemy).despawn();
commands.entity(projectile).despawn();
break;
}
}
}
let us = t0.elapsed().as_secs_f64() * 1e6;
diagnostics.add_measurement(&D_COLLIDE, || us);
diagnostics.add_measurement(&D_CHECKS, || checks as f64);
}
fn spawn_enemy(commands: &mut Commands, angle: f32, radius: f32, render: bool) {
let pos = Vec2::from_angle(angle) * radius;
let transform = Transform::from_xyz(pos.x, pos.y, 0.0);
if render {
commands.spawn((
Enemy,
Sprite::from_color(Color::srgb(0.85, 0.25, 0.25), Vec2::splat(ENEMY_SIZE)),
transform,
));
} else {
commands.spawn((Enemy, transform));
}
}
fn spawn_projectile(commands: &mut Commands, velocity: Vec2, render: bool) {
let transform = Transform::from_xyz(0.0, 0.0, 1.0);
if render {
commands.spawn((
Projectile { velocity },
Sprite::from_color(Color::srgb(0.3, 0.8, 1.0), Vec2::splat(PROJECTILE_SIZE)),
transform,
));
} else {
commands.spawn((Projectile { velocity }, transform));
}
}
/// Cheap deterministic hash into [0, 1). Avoids pulling in `rand` for a throwaway example.
fn hash01(n: u32) -> f32 {
let mut x = n.wrapping_mul(0x9E37_79B9).wrapping_add(0x1234_5678);
x ^= x >> 16;
x = x.wrapping_mul(0x85EB_CA6B);
x ^= x >> 13;
(x >> 8) as f32 / (1u32 << 24) as f32
}
/// Drives warmup -> measurement -> summary -> exit, so a run is scriptable and the
/// reported numbers are medians of the settled phase, never the first frame.
fn harness(
time: Res<Time<Real>>,
cfg: Res<SpikeConfig>,
mut state: ResMut<Harness>,
mut store: ResMut<DiagnosticsStore>,
mut exit: MessageWriter<AppExit>,
) {
if state.done {
return;
}
state.elapsed += time.delta_secs_f64();
if !state.measuring {
if state.elapsed >= cfg.warmup_secs {
// Throw away everything from startup, shader compilation and the first
// population wave.
for diagnostic in store.iter_mut() {
diagnostic.clear_history();
}
state.measuring = true;
info!("warmup done, measuring for {} s", cfg.measure_secs);
}
return;
}
if state.elapsed < cfg.warmup_secs + cfg.measure_secs {
return;
}
state.done = true;
let frame_ms = sorted(&store, &FrameTimeDiagnosticsPlugin::FRAME_TIME);
let frames = frame_ms.len();
let median_ms = percentile(&frame_ms, 0.5);
let p95_ms = percentile(&frame_ms, 0.95);
let p99_ms = percentile(&frame_ms, 0.99);
let m = |path: &DiagnosticPath| percentile(&sorted(&store, path), 0.5);
let maintain = m(&D_MAINTAIN);
let move_enemies_us = m(&D_MOVE_ENEMIES);
let fire = m(&D_FIRE);
let move_proj = m(&D_MOVE_PROJ);
let collide_us = m(&D_COLLIDE);
let logic_us = maintain + move_enemies_us + fire + move_proj + collide_us;
println!("=== PERF SPIKE RESULT ===");
println!(
"mode {}",
if cfg.render { "render" } else { "headless" }
);
println!("N (enemies) {}", cfg.enemy_count);
println!("fire rate {} shots/s", cfg.fire_rate);
println!("frames measured {frames}");
println!(
"frame time median {median_ms:.3} ms | p95 {p95_ms:.3} ms | p99 {p99_ms:.3} ms"
);
println!(
"fps median {:.1} | p95 {:.1}",
1000.0 / median_ms.max(f64::EPSILON),
1000.0 / p95_ms.max(f64::EPSILON)
);
println!("enemies alive median {:.0}", m(&D_ENEMIES));
println!("projectiles median {:.0}", m(&D_PROJECTILES));
println!("pair checks median {:.0} /frame", m(&D_CHECKS));
println!(
"system medians maintain {maintain:.1} us | move_enemies {move_enemies_us:.1} us | \
tower_fire {fire:.1} us | move_projectiles {move_proj:.1} us | collide {collide_us:.1} us"
);
println!(
"logic total {:.3} ms ({:.1} % of the median frame)",
logic_us / 1000.0,
(logic_us / 1000.0) / median_ms.max(f64::EPSILON) * 100.0
);
println!("=========================");
exit.write(AppExit::Success);
}
fn sorted(store: &DiagnosticsStore, path: &DiagnosticPath) -> Vec<f64> {
let mut values: Vec<f64> = store
.get(path)
.map(|d| d.values().copied().collect())
.unwrap_or_default();
values.sort_by(|a, b| a.total_cmp(b));
values
}
/// Returns 0.0 for an empty history, which is what a disabled system should report.
fn percentile(sorted_values: &[f64], q: f64) -> f64 {
if sorted_values.is_empty() {
return 0.0;
}
let idx = ((sorted_values.len() - 1) as f64 * q).round() as usize;
sorted_values[idx]
}