Gemeldet als "Ratten vergiften zu 100 %". Dahinter lagen drei Fehler: 1. Creature::from_def parste Attributnamen mit toml::from_str, das eine ganze TOML-Datei erwartet. Der Aufruf schlug fuer jeden Namen fehl, der Zweig hiess Err(_) => continue — jeder Gegner hatte seit M3 still die Standardwerte. Die Riesenratte: 100 Leben statt 14, der Knochenfuerst war eine grosse Ratte. Kampf gegen den ersten Gegner: 22 Zuege statt 4. 2. attack_hit feuerte bei allen Traegern statt nur beim Angreifer, also vergiftete die Ratte auch bei eigenen Treffern. EventKind:: bound_to_source benennt die zwei seitengebundenen Ereignisse. 3. Action::Damage wuerfelte die Trefferformel auch bei Selbstschaden — ein flinker Held wich seinem eigenen Gift aus. Dazu ein Datenfehler: Das Gift stand in der Aktionsliste von rat_bite, und Aktionen haben kein chance. Es haengt jetzt am Trigger mit 25 %. Drei Regressionstests, darunter der, den es seit M3 haette geben muessen: ein Gegner bekommt die Werte aus seiner Definition.
19 KiB
14 — Kampfformeln und Rundenablauf
Type: grilling Status: resolved Blocked by: 01
Question
Wie läuft ein Kampf ab, und mit welchen Formeln?
Der Attributkatalog aus 01 liefert die Zutaten (damage, hit_chance, critical_chance,
critical_factor, resistance, dexterity, luck). Wie sie zusammenwirken, ist offen:
- Reihenfolge: Was bestimmt, wer zuerst handelt —
dexterity, ein Initiativwurf, oder immer der Spieler zuerst? Ändert sich das während des Kampfes? - Trefferformel: Wie berechnet sich, ob ein Angriff trifft? Ist
hit_chancegegen Ausweichen gerechnet, oder trifft immer unddexteritymindert nur den Schaden? - Schadensformel: In welcher Reihenfolge greifen Waffe,
damage-Modifikatoren, Krit undresistance? Ist Resistenz prozentual oder flach, und gibt es eine Ober- oder Untergrenze? - Kritische Treffer: Wie speist
luckincritical_chance? Multipliziertcritical_factorden Rohschaden oder den geminderten? - Aktionen pro Zug: Genau eine, oder gibt es Ausdauer als Budget? Wozu dient
max_staminasonst? - Fähigkeiten (nachgetragen aus Ticket 02): Was kostet eine Fähigkeit — Ausdauer, ein eigener Ressourcenpool, Ladungen? Gibt es Abklingzeiten, und werden sie in Zügen gezählt? Wann ist eine Fähigkeit gesperrt, und woran sieht der Spieler das? Ticket 02 hat Fähigkeiten als Registry festgelegt, ihre Kampfsemantik aber ausdrücklich hierher delegiert.
- Flucht: Möglich, mit welcher Chance, mit welchen Kosten?
- Zufall: Schadensstreuung ja oder nein, und woher kommt der Zufallsgenerator — braucht es einen Seed im Speicherstand?
Ergebnis: die Formeln als Pseudocode plus der Ablauf eines Zuges von Eingabe bis Ergebnis.
Vorgabe aus Ticket 07
Der Aktionskatalog und die Ziel-Auflösung (self / target / all_enemies aus 01) gehören hierher.
Alles läuft durch Game::apply(action, source: CreatureRef, target: CreatureRef, reg), gelesen wird
über Game::attribute(who, attr, reg).
Eine Frage fällt zusätzlich an, die 07 nur benennen konnte: Wann werden tote Gegner aufgeräumt?
CreatureRef::Enemy(i) ist ein Index in Game.enemies und überlebt das Entfernen aus der Vec
nicht — eine Aktion, die mitten im Zug einen Gegner tötet, verschiebt die Adressen aller
nachfolgenden. Der Rundenablauf muss festlegen, ob am Zugende, am Rundenende oder gar nicht
kompaktiert wird (Alternative: Tote bleiben mit einem Flag stehen).
Answer
Begriffe im Glossar. Zahlen gehören nicht hierher — die Map hält Balancing getrennt; hier stehen Formeln und Ablauf.
Rundenablauf
Ein Zug = Spielerhandlung plus alle Gegnerreaktionen. Nicht jede Handlung ein eigener Zug: Bei drei Gegnern tickte ein Gift-Debuff sonst viermal pro Runde, und ein Tastendruck erzeugte vier Züge — das bräche die Zusage aus 11, die Ticket 12 gerade gemessen hat.
Reihenfolge über Initiative, einmal beim Betreten des Kampfes aus dexterity plus Wurf gewürfelt
und als i32 am Wesen gehalten. Ausdrücklich nicht als Reihenfolgeliste aus CreatureRef: Die
wäre eine zweite Indexliste, die beim Tod eines Gegners genauso ungültig wird wie die erste. Ein
Zahlenwert am Wesen verschwindet mit ihm, die Sortierung stimmt weiter. Gleichstand geht an den
Spieler — deterministisch und zu seinen Gunsten.
dexterity speist damit Initiative und Ausweichen. Absicht, aber 15 sollte es beim Balancing wissen.
Game::execute(cmd, reg):
1. turn_started (+ combat_started, falls der Kampf gerade beginnt) in die Warteschlange
2. befristete Modifikatoren ticken, Ausdauer regeneriert, Abklingzeiten sinken
3. Teilnehmer handeln in Initiativreihenfolge, Spieler-Command an seinem Platz
-> Abbruch, sobald der Held tot ist
4. nach JEDER einzelnen Handlung die Warteschlange leerarbeiten (bis leer oder 100)
5. retain auf tote Gegner, danach ggf. combat_ended
6. turn += 1
Schritt 4 nach jeder Handlung statt einmal am Zugende: Sonst schlägt ein Gegner zu, den ein
Dornenpanzer eine Handlung zuvor getötet hat. Schritt 5 erst nach dem Leeren: Sonst zeigt ein
Trigger auf creature_died auf einen bereits entfernten Gegner. Innerhalb eines Zuges müssen Indizes
stabil bleiben — über Zuggrenzen hinweg hält ohnehin niemand ein CreatureRef, die UI liest neu.
Genau eine Handlung pro Zug, kein Ausdauer-Budget. max_stamina ist die Ressource für
Fähigkeiten — das beantwortet die Frage „wozu sonst" aus dem Ticketkörper.
Stirbt der Held mitten im Zug, bricht der Zug ab (laufende Handlung samt Warteschlange noch zu Ende). Zwei weitere Gegner, die auf eine Leiche einschlagen, erzeugen nur Kampflog und Trigger auf ein Wesen, das die Runde nicht mehr erlebt.
Formeln
p_treffer = clamp(hit_chance(A, gegen=B) - dexterity(B) * AUSWEICH, 0.05, 0.95)
roh = attribute(damage, A, gegen=B) # 01-Stapelung, bedingte Modifikatoren inklusive
krit = rand() < clamp(critical_chance(A), 0.0, 1.0)
roh = roh * critical_factor(A) falls krit
r = clamp(resistance(B, typ), -1.0, 0.8)
final = max(1, round(roh * (1.0 - r)))
p_flucht = clamp(0.5 + (dexterity(Held) - dexterity(schnellster Gegner)) * FLUCHT, 0.1, 0.9)
Die Frage aus dem Ticketkörper löst sich auf. Ob critical_factor den Rohschaden oder den
geminderten multipliziert, ist mathematisch belanglos: roh × kf × (1−r) ist dasselbe wie
roh × (1−r) × kf. Festzulegen war nur, dass beide multiplikativ bleiben.
Befund aus M3: die Trefferformel war toter Code. hit_chance existierte, war getestet — und wurde
nie aufgerufen. Jeder Angriff traf. Gemeldet hat es nur der Compiler
(warning: function 'hit_chance' is never used); kein Test schlug an, weil alle Tests die Formel
direkt prüften statt über einen Angriff.
Lehre für die Formeln unten: Eine Formel, die nur von ihrem eigenen Unit-Test aufgerufen wird, ist nicht verdrahtet. Der Test, der das jetzt abfängt, führt 200 Angriffe gegen einen absurd flinken Gegner und verlangt, dass mindestens einer verfehlt und mindestens einer trifft.
Drei Klemmbereiche, jeder mit Grund:
- Resistenz bei
0.8— bei1.0wäre ein Wesen immun, und Immunität ist keine Zahl, sondern ein Spielabbruch. Nach unten bis-1.0, damit Verwundbarkeit ausdrückbar bleibt („Untote nehmen doppelten heiligen Schaden"). max(1, …)— ein Treffer macht nie null, sonst gibt es Kämpfe, die rechnerisch nie enden.0.05 / 0.95beim Treffen — sonst wird ein hochgezüchteter Verteidiger unangreifbar und ein hochgezüchteter Angreifer unfehlbar. Beides Sackgassen, die man erst auf Etage 5 bemerkt.
Keine Schadensstreuung für 1.0: Der Krit liefert die Varianz bereits, Streuung wäre ein zweiter Zufallsknopf ohne eigene Entscheidung dahinter.
Zufall
Eigener xorshift64* — acht Zeilen statt einer Dependency. Der Seed liegt als Feld in Game und damit
im Speicherstand.
Das ist keine Implementierungsfrage, sondern eine Spielentscheidung: Nachladen würfelt nicht neu. Derselbe Angriff führt nach dem Laden zum selben Ergebnis, Save-Scumming läuft ins Leere. Ein Abstiegs-Roguelike lebt von Entscheidungen mit Konsequenzen.
Fähigkeiten — die Bestellung aus Ticket 02
[[abilities]]
id = "core:power_strike"
name = "Wuchtschlag"
symbol = "!"
color = "red"
cost = 15 # Ausdauer
cooldown = 3 # Zuege
condition = [ … ] # optional, Grammatik aus 08
actions = [ { action = "damage", scale = 1.5, damage_type = "blunt", target = "target" } ]
Nur Ausdauer, kein zweiter Ressourcenpool. Mana wäre ein zweites Attribut, eine zweite Regeneration, eine zweite Anzeige und eine zweite Sperrregel. Die Decke: Ein Mod, der Mana will, bräuchte ein Engine-Attribut — ein Nachtrag an 01, kein Problem, das ein Modder selbst lösen kann.
Abklingzeit in Zügen, als cooldowns: HashMap<AbilityId, u32> am Creature. Sie als befristete
Modifikatoren zu tarnen wäre clever und genau die Sorte Cleverness, die man um drei Uhr nachts
entziffert.
Gesperrt heißt: Ausdauer < cost, oder cooldown > 0, oder condition unerfüllt. Auslösen ergibt
dann Outcome::Rejected("Nicht genug Ausdauer") aus Ticket 12 und kostet keinen Zug. Die Regel
steht im Spielkern, nicht in der UI — sonst kennt ein Script sie nicht.
Ausdauer regeneriert pro Zug um einen festen Betrag. Das gibt Wait aus Ticket 11 seinen Zweck und
macht Ausdauer zu einer Entscheidung statt zu einem Countdown.
Eine misslungene Flucht ist übrigens TurnPassed, keine Zurückweisung: Der Versuch hat stattgefunden,
die Gegner haben gehandelt. Rejected bleibt dem Fall vorbehalten, dass gar nichts passiert ist.
Gegner — die achte Registry
Beim Aufschreiben der KI fiel eine Lücke auf, die seit 02 offen lag: Wesen stehen in keiner Registry. 02 hatte fünf, 07 ergänzte Affixe, 08 Quests — woher Gegnerdefinitionen kommen, hat nie jemand gesagt, obwohl 10 sie in Räume setzen und 16 Beute an sie hängen muss.
[[creatures]]
id = "core:skeleton"
name = "Skelett"
symbol = "s"
color = "white"
tags = ["core:undead"]
base = { max_health = 30, damage = 6, hit_chance = 0.7, dexterity = 3 }
abilities = ["core:attack", "core:bone_throw"]
ai = [
{ condition = [ { check = "attribute", who = "self", attribute = "health", below = 0.3 } ],
ability = "core:flee" },
{ ability = "core:attack" }, # ohne condition = greift immer
]
ai ist eine geordnete Liste, der erste passende und verfügbare Eintrag gewinnt — dieselbe
Bedingungsgrammatik wie überall sonst, kein neues Vokabular. Passt nichts, setzt der Gegner aus;
einer, der die Runde blockiert, wäre schlimmer als einer, der nichts tut.
Eine ai-Liste ohne bedingungslosen letzten Eintrag gibt eine Ladewarnung über die Maschinerie aus
04. Das ist fast immer ein Versehen, und zur Laufzeit erkennt man es nur daran, dass ein Gegner
gelegentlich stillsteht — die teuerste Sorte Fehler.
Boss-Phasen sind damit nicht gelöst, aber vorbereitet: Die Liste trägt sie, was fehlt, ist der Phasenbegriff.
Balance als Daten — ausdrückliche Ausnahme zu Ticket 03
Fünf Stellknöpfe entstehen hier: AUSWEICH, FLUCHT, Ausdauer-Regeneration, Resistenz-Deckel und
Treffer-Klemmbereich. Sie liegen als [balance]-Tabelle in TOML und verschmelzen feldweise über
die Ladereihenfolge.
Das widerspricht der Ganz-oder-gar-nicht-Regel aus 03, und zwar bewusst: Ein Mod, der nur AUSWEICH
dreht, müsste sonst alle fünf Werte abschreiben, und ein sechster Knopf in der nächsten Engine-Version
bräche ihn stillschweigend.
Die Ausnahme bleibt eng, weil ihre Begründung eng ist: [balance] hat ein festes, der Engine
bekanntes Schema. Genau das fehlte bei Inhalt mit offenen Feldern — Modifikator- und Aktionslisten
bräuchten einen Merge-Algorithmus, und das war 03s Argument gegen feldweises Patchen. Hier greift es
nicht.
Standardwerte liegen in der Engine, nicht in core. Dann ist ein Mod, der einen Knopf dreht, eine
Zeile lang.
Nachtrag aus Ticket 15: Zwei weitere Knöpfe kommen dazu — exp_base und exp_exponent, die die
Erfahrungskurve bilden. Eine Tabelle je Level wäre die Alternative gewesen; zwei Zahlen schlagen
fünfzig Zeilen. Dazu bekommt die creatures-Registry ein Feld exp_reward.
Ausdrücklich nicht dazugekommen ist points_per_level: Ein Aufstieg gibt genau einen Punkt, das
ist eine Regel und kein Stellknopf.
Nachtrag aus Ticket 16: LUCK_FACTOR kommt dazu — luck gewährt eine Chance auf einen
zusätzlichen Zug auf jeder Beutetabelle. Damit hat [balance] acht Felder.
Vier Nachträge an geschlossene Tickets
| Ticket | Nachtrag |
|---|---|
| 01 | damage-Aktion: menge = amount + scale * damage-Attribut(Verursacher), beide Felder optional (Standard 0). Deckt Basisangriff (scale = 1.0), Feuerball (amount = 20) und Wuchtschlag (scale = 1.5) mit einer Formel. Dazu flee als achte Aktion |
| 07 | initiative: i32 und cooldowns: HashMap<AbilityId, u32> am Creature; RNG-Seed in Game; enemies ist der laufende Kampf, nicht die Etage |
| 08 | combat_started und combat_ended { outcome: Victory | Fled } — die Eventliste wächst auf elf. Der Tod des Helden braucht keine dritte Variante, creature_died mit source = Player deckt ihn ab |
| 11 | GameOver als elfter Screen, Vollbild. Bewusst nicht die lazy Variante mit einer Meldungszeile im Hauptmenü: Ein Abstiegs-Roguelike lebt davon, dass der Lauf am Ende erzählt wird — Etage, Zug, Gold, Todesursache. Ticket 10 hat daraus GameOver { outcome: Died | Won } gemacht, weil der Abstieg von der letzten Etage den Lauf gewinnt |
Auflagen an andere Tickets
- 10 Raum-Prefab-Format: füllt
Game.enemies; Kampfbeginn ist ein Prefab-Ereignis. - 15 Progression: was bei Sieg passiert (EXP), und die Zahlen hinter allen Formeln inklusive der
fünf
[balance]-Knöpfe. - 16 Loot-Tabellen: Beute hängt an der
creatures-Registry, die hier entstanden ist.
Befund aus M4 — die Initiativreihenfolge ist ein Schnappschuss
Der Zugablauf legt die Reihenfolge einmal vor dem Zug fest und arbeitet sie danach ab. Das war
richtig gedacht — sonst könnte ein Gegner sich durch Handeln in der Reihenfolge nach vorn schieben.
Nur passt es nicht zu einer Zusage aus demselben Ticket: Flucht beendet den Kampf sofort, leert
also enemies mitten in der Schleife. Die Reihenfolge zeigt danach auf Indizes, die es nicht mehr
gibt.
Im Spiel sichtbar als Absturz nach mehrfachem Fluchtversuch — die Flucht muss glücken, bevor ein Gegner in der Reihenfolge an der Reihe war, sonst fällt es nicht auf. Kein Test hatte den Fall, weil alle Kampftests die Flucht am Ende der Reihenfolge auslösten.
Entschieden: Ein leerer enemies bricht den Zug ab, statt still übersprungen zu werden. Kein
Gegner mehr da heißt, es gibt nichts mehr zu handeln — ein continue würde denselben Absturz nur
verschieben, wenn später jemand die Liste wieder füllt.
Damit ist die Regel dieses Tickets vollständig: Die Reihenfolge steht vor dem Zug fest, und sie endet, sobald ihre Grundlage weg ist.
Nachtrag — die Ansage (Taktik-Ebene, nach M4)
Der Zugablauf bekommt einen siebten Schritt: Am Ende jedes Zuges legt jeder lebende Gegner fest,
was er im nächsten tun wird. Ausgewertet wird dieselbe ai-Liste nach derselben Regel — erster
passender und verfügbarer Eintrag —, nur eine Runde früher. Das Ergebnis steht als intent am
Creature und wird im Kampf-Screen unter dem Lebensbalken angezeigt.
Verbindlich, nicht als Vorschau. Der Gegner führt genau das aus, was dasteht. Die Alternative wäre
gewesen, die ai-Liste bei jedem Bild neu auszuwerten und das Ergebnis als Vermutung anzuzeigen — das
hätte keinen Zustand gekostet, aber auch keine Taktik erzeugt: Eine Anzeige, die sich mit der Lage
mitdreht, sagt nur, was jetzt gälte, und ist genau dann falsch, wenn der Spieler etwas unternimmt.
Der Gewinn liegt im Fenster zwischen Ansage und Ausführung. Vor dem Ausführen wird ability_block
erneut geprüft; schlägt es an, verliert der Gegner den Zug („X kommt nicht dazu — Nicht genug Ausdauer."). Damit wird jede Wirkung auf Ausdauer, Abklingzeit oder Bedingung zu einem Konter, und
die Sperrgründe, die Ticket 14 ohnehin schon berechnet, bekommen einen zweiten Nutzen.
Eine Ansage wird beim Ausführen verbraucht (take). Wer ohne Ansage an die Reihe kommt, tut nichts —
das trifft nur Gegner, die mitten im Zug auftauchen, und die stehen ohnehin nicht in der
Initiativreihenfolge dieses Zuges. Ein Ansagepunkt, kein zweiter beim Kampfbeginn.
Ausdrücklich nicht dazugekommen ist ein Ziel an der Ansage: Die ai greift ausschließlich den
Helden an, intent trägt deshalb nur die Fähigkeits-ID. Ein Zielfeld käme mit dem ersten Gegner, der
seine Verbündeten heilt.
Befunde aus der zweiten Balancing-Runde — drei Fehler, ein Symptom
Gemeldet wurde „Ratten vergiften zu 100 %, kaum zu überleben". Dahinter lagen drei unabhängige Fehler, und der größte hatte mit Gift nichts zu tun.
1. Jeder Gegner hatte seit M3 die Standardwerte. Creature::from_def las die Attributnamen mit
toml::from_str::<Attribute>("\"max_health\"") — und toml::from_str erwartet eine ganze TOML-Datei,
kein nacktes Skalar. Der Aufruf schlug für jeden Namen fehl, der Zweig hieß Err(_) => continue,
und damit behielt jedes Wesen still die Voreinstellung: Die Riesenratte hatte 100 Leben statt 14 und
2 Schaden statt 3. Der Knochenfürst war eine große Ratte. Ein Kampf gegen den ersten Gegner dauerte 22
Züge.
Kein Test hat das gesehen, weil alle Kampftests ihre Zahlen selbst setzen und nie eine
Gegnerdefinition nachschlugen. Der Fehler ist ein stiller continue — die Sorte, gegen die Ticket 04
„jeder Verlust bekommt eine Zeile" beschlossen hat, nur eben in game/ statt im Lader.
2. Seitengebundene Ereignisse feuerten bei allen. Dieses Ticket und 08 trennen attack_hit
(feuert beim Angreifer) von damage_taken (beim Getroffenen), ausdrücklich damit „kein Trigger erst
prüfen muss, auf welcher Seite er steht". Die Trägerschleife in drain_events legte aber jedes
Ereignis jedem Beteiligten vor — der Gift-Trigger der Ratte feuerte deshalb auch, wenn der Held
zuschlug. Dieselbe Lücke hätte den Dornenpanzer des Angreifers reflektieren lassen.
EventKind::bound_to_source() benennt die beiden. Alle übrigen bleiben offen: turn_started muss bei
jedem ticken, sonst vergiftet sich nur der Spieler.
3. Man verfehlte sich selbst. Der M3-Befund „die Trefferformel war toter Code" wurde behoben, indem
Action::Damage sie immer würfelt. Ein Gift-Tick ist aber kein Angriff, und die Formel liest die
Geschicklichkeit des Ziels — ein flinker Held wich also seinem eigenen Gift aus. Im Log stand
„M verfehlt M". Neue Regel, eine Zeile: Man verfehlt sich nicht selbst.
Dazu ein Datenfehler, kein Codefehler: rat_bite legte core:poisoned in seiner Aktionsliste ab,
und Aktionen haben kein chance — das gibt es nur am Trigger (Ticket 08). Jeder Biss vergiftete also,
und weil erneutes Auflegen die Dauer auffrischt, war der Held ab dem ersten Treffer dauerhaft vergiftet.
Das Gift hängt jetzt am Trigger der Ratte mit chance = 0.25 — genau der Fall, für den Ticket 08 das
Feld erfunden hat („20 % Chance, Brennen auszulösen, braucht sonst ein Script").
Danach gemessen, Elf-Dieb auf Stufe 1, je 40 Kämpfe:
| Gegner | Züge | Lebensverlust | Tode |
|---|---|---|---|
| Riesenratte | 4,2 | 6 | 0/40 |
| Skelett | 6,6 | 18 | 0/40 |
| Ghul | 15,8 | 94 | 24/40 |
| Knochenfürst | 9,9 | 110 | 40/40 |
Die Kurve existiert damit zum ersten Mal — vorher war jeder Gegner derselbe. Ob Etage 3 bis 5 fair sind, sagt diese Tabelle nicht: Sie vergleicht einen Helden auf Stufe 1 mit Startdolch gegen Gegner, denen er erst mit mehreren Aufstiegen und besserer Ausrüstung begegnet. Das entscheidet Spielen.