Merge branch 'feat/m5-progression'

This commit is contained in:
2026-08-12 21:47:50 +02:00
13 changed files with 492 additions and 45 deletions

View File

@@ -272,3 +272,21 @@ vorbeigeht, testet den Spielablauf nicht.
**Immer mitgebaut**, nicht hinter `cfg(debug_assertions)`. Das wäre ein `#[cfg]` an der Variante *und*
an jedem `match` darüber — für ein Spiel, das niemand ausliefert. Ein `#[cfg]` reicht später nach,
wenn es je einen Release-Build gibt.
### Befund aus M5 — die Naht zwischen Screen-Zustand und Spielzustand
Ticket 11 trennt sauber: Ein Screen fasst `Game` nicht an, er gibt ein `Command` zurück. Der
Charakterbogen hält seit M5 aber selbst Zustand — die **vorgemerkten** Punkte, die erst mit `Enter`
abgeschickt werden. Beim Abschicken zieht das Spiel sie ab, der Screen behielt sie: `unspent_points`
stand auf 0, `pending` auf 1, und die Anzeige rechnete `0 - 1` auf `u32`. Absturz.
Das ist keine Schwäche der Trennung, sondern ihr Preis: Wer Zustand doppelt hält, muss sagen, wann er
abgeglichen wird. Die Antwort hier ist die einfachste mögliche — **vorgemerkt wird beim Abschicken
verbraucht**, der Screen leert sein Feld, während er das Kommando übergibt. Dazu `saturating_sub` an
den zwei Lesestellen, weil `unspent_points` sich auch unter dem offenen Screen ändern kann (ein
Aufstieg im selben Zug).
Dabei ist der **erste UI-Test** des Projekts entstanden. Bis M5 war `ui/` ungetestet, weil dort
angeblich nur Anzeige liegt — der Absturz zeigt, dass das nicht stimmt: Sobald ein Screen Zustand hält,
ist die Naht zum Spielzustand eine Fehlerquelle wie jede andere. `handle_key` ist ohne Terminal
aufrufbar, der Test kostet zwanzig Zeilen.

View File

@@ -146,3 +146,34 @@ Blocker gewesen.
Die Beute-Skalierung darf **nicht gegen fünf feste Etagen fest verdrahtet** werden — der Endlosmodus
kommt später, und eine Skalierung, die nur bis Etage 5 denkt, wäre dann ein Umbau statt einer
Erweiterung.
### Befunde aus M5
**Zwei der fünf verteilbaren Grundwerte waren Fallen.** Das Ticket verteilt Punkte auf `strength`,
`dexterity`, `constitution`, `charisma` und `luck` — und begründet sauber, warum abgeleitete Größen
ausgeschlossen bleiben. Nur las `strength` und `constitution` bis M5 **niemand**: `damage` und
`max_health` sind eigene Attribute ohne Herleitung, `dexterity` steckt in Flucht und Initiative,
`charisma` seit M4 im Preis, `luck` jetzt in der Beute. Ein Punkt in Stärke hätte nichts getan.
Entschieden: `damage_per_strength` und `health_per_constitution` als `[balance]`-Knöpfe, angewandt
**beim Verteilen** und nicht beim Auslesen. Damit hebt ein Punkt in Konstitution den *Grundwert*
`max_health` — genau das, was der Satz „`max_health` steigt erst, wenn `constitution` tatsächlich
erhöht wird, also beim Verteilen" ohnehin voraussetzt. In `Game::attribute` gehört die Umrechnung
nicht: Dort wäre sie ein Sonderfall im generischen Pfad, dieselbe Begründung, mit der M4 die
Charisma-Formel in die Preisfunktion gelegt hat.
**Von den „zwei Stellen" ist nur eine echt.** Das Ticket nennt Aufstieg *und* `AssignPoints` als
Stellen, an denen der Lebensstand einem `max_health`-Zuwachs folgt. Im Code ist die erste beweisbar ein
No-op: Der Aufstieg hebt `base.level` und `unspent_points`, sonst nichts, und `per_level` hat dasselbe
Ticket ersatzlos gestrichen. Die vorsorgliche Vorher/Nachher-Klammer im Aufstieg ist raus — sie
suggerierte eine Regel, die es nicht gibt.
**`Rng::chance(0.0)` verbrauchte eine Zahl.** Aufgefallen, weil die Glücksregel aus Ticket 16 auch beim
Erzeugen einer Etage greift (Händlerregal, `luck = 0`) und damit den ganzen Zufallsstrom verschob — ein
bestehender Kampftest schlug fehl, weil sein Angriff plötzlich danebenging. `chance` kürzt jetzt bei
0 und 1 ab. Ohne das erzeugte derselbe Seed je nach Glück des Helden eine andere Etage, und die Zusage
aus Ticket 14 („Seed im Save ⇒ Nachladen würfelt nicht neu") wäre stillschweigend gebrochen gewesen.
**`Cheat::ClearRoom` ging am Todesweg vorbei.** Die Werkbank setzte den Lebensstand auf null und ließ
das Aufräumen am Zugende erledigen — ohne `check_death`, also ohne Todes-Trigger und ohne Erfahrung.
Genau der Fehler, gegen den ihr eigener Kommentar argumentierte. Sie ruft jetzt `check_death`.

View File

@@ -132,6 +132,35 @@ die Tickets zurückgetragen:
Der Zugablauf aus **14** hat damit einen siebten Schritt, `Creature` ein Feld `intent`, und die
Sperrgründe, die `ability_block` ohnehin berechnete, haben einen zweiten Nutzen bekommen.
**M5 — Progression** (`feat/m5-progression`, 65 Tests)
| Zusage | Aus | Befund |
|---|---|---|
| `current_exp`/`unspent_points` an `Game`, nicht am `Creature` | 15 | **bestätigt** — der Save schreibt sie nicht für jeden Gegner mit |
| `exp_reward` als Kurzform für einen Todes-Trigger | 15 | **bestätigt**, stand seit M3 ungelesen an vier Gegnern |
| Aufstieg in einer Schleife, drei Stufen = drei Punkte | 15 | **bestätigt** |
| Zwei Zahlen statt fünfzig Tabellenzeilen für die Kurve | 15 | **bestätigt** — `exp_base × level^exp_exponent` |
| `AssignPoints` als **ein** Kommando, **ein** Zug | 15 | **bestätigt** |
| Verteilen im Charakterbogen, kein zwölfter Screen | 11/15 | **bestätigt** |
| Punkte nur auf die fünf Grundwerte | 15 | **zwei davon waren Fallen** — siehe unten |
| Lebensstand folgt dem Zuwachs an **zwei** Stellen | 15 | **es ist eine** — der Aufstieg ändert `max_health` gar nicht |
| `luck` = eine Chance auf einen Zusatzzug, auf jeder Tabelle | 16 | **bestätigt**, wirkt dreifach aus einer Formel |
Ein fünfter kam beim Spielen dazu: **11** — der Charakterbogen hält seit M5 selbst Zustand (die
vorgemerkten Punkte). Beim Abschicken zog das Spiel sie ab, der Screen behielt sie, und die Anzeige
rechnete `0 - 1` auf `u32`. Daraus der **erste UI-Test** des Projekts: Sobald ein Screen Zustand hält,
ist die Naht zum Spielzustand eine Fehlerquelle wie jede andere.
Vier Befunde nachgetragen: **15** (`strength` und `constitution` las niemand — zwei der fünf
Wahlmöglichkeiten taten nichts), **15** (von den zwei Zuwachs-Stellen ist eine beweisbar ein No-op),
**14/16** (`Rng::chance(0.0)` verbrauchte eine Zahl und verschob den Zufallsstrom — derselbe Seed
erzeugte je nach Glück des Helden eine andere Etage) und **11** (`Cheat::ClearRoom` ging am Todesweg
vorbei, also ohne Trigger und ohne Erfahrung).
Der dritte ist der interessanteste: Gefunden hat ihn ein **bestehender** Kampftest, dessen Angriff
plötzlich danebenging. Kein neuer Test, keine Überlegung — eine Zusage aus Ticket 14 wäre sonst
stillschweigend gebrochen gewesen.
**Drei Befunde aus M1, die nur der Compiler liefern konnte** — alle in die Tickets zurückgetragen:
- **11**: Der Grenztest in seiner wörtlichen Fassung schlägt beim eigenen Doc-Kommentar an. Muss Kommentarzeilen ausnehmen.

View File

@@ -218,8 +218,25 @@ pub struct Balance {
pub verkauf: f32,
/// Wie stark `charisma` auf `price_modifier` wirkt (Ticket 02).
pub charisma_preis: f32,
// TODO(M5): `exp_base`, `exp_exponent` (Ticket 15) und `luck_factor`
// (Ticket 16) — sie kommen mit ihren Lesern, nicht davor.
/// Die Erfahrungskurve: `exp_base × level^exp_exponent`. **Zwei Zahlen
/// statt fuenfzig Tabellenzeilen** (Ticket 15) — eine Tabelle liesse einen
/// Modder Level 13 zum Sprung machen, und das ist der Ausbau, nicht der
/// Anfang.
pub exp_base: f32,
pub exp_exponent: f32,
/// `p_extra = clamp(luck × luck_factor, 0, 0.9)` — eine Chance auf einen
/// zusaetzlichen Zug auf **jeder** Tabelle (Ticket 16).
pub luck_factor: f32,
/// Was ein Punkt in `strength` bzw. `constitution` tatsaechlich bewirkt.
///
/// **Befund aus M5:** Ticket 15 verteilt Punkte auf fuenf Grundwerte, aber
/// `strength` und `constitution` las bis dahin *niemand* — zwei der fuenf
/// Wahlmoeglichkeiten waren Fallen. Die Umrechnung sitzt hier und nicht in
/// `Game::attribute`, weil sie beim **Verteilen** einmal passiert und nicht
/// bei jedem Auslesen: Ein Punkt in Konstitution hebt den Grundwert
/// `max_health`, wie es Ticket 15 fuer die Zuwachs-Regel voraussetzt.
pub damage_per_strength: f32,
pub health_per_constitution: f32,
}
impl Default for Balance {
@@ -233,6 +250,11 @@ impl Default for Balance {
hit_ceiling: 0.95,
verkauf: 0.4,
charisma_preis: 1.5,
exp_base: 20.0,
exp_exponent: 1.5,
luck_factor: 0.02,
damage_per_strength: 1.0,
health_per_constitution: 8.0,
}
}
}

View File

@@ -66,8 +66,7 @@ pub enum Target {
/// Der Aktionskatalog. Zehn Eintraege sind entschieden (01, 08, 13, 14, 15);
/// M3 braucht fuenf davon.
///
// TODO(M5+): `give_item`, `remove_item`, `start_quest` (08), `give_exp` (15),
// `run_script` (13).
// TODO(M6+): `give_item`, `remove_item`, `start_quest` (08), `run_script` (13).
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum Action {
@@ -112,6 +111,12 @@ pub enum Action {
/// Elfte Aktion (M4): Eine Dialogoption oeffnet den Laden. Als Aktion steht
/// sie in derselben Liste wie `start_quest`, statt als Sonderfeld daneben.
OpenShop,
/// Zwoelfte Aktion (Ticket 15): Erfahrung fuer Questbelohnungen. Der
/// zweite Weg neben `exp_reward` — das ist die Kurzform fuer einen
/// Todes-Trigger, den sonst jede Gegnerdefinition mitschleppen muesste.
GiveExp {
amount: u32,
},
}
/// Die beiden Zustandsgroessen, die eine Aktion aendern darf. Bewusst **kein**
@@ -130,7 +135,7 @@ impl Action {
| Action::ChangeAttribute { target, .. }
| Action::ApplyModifier { target, .. }
| Action::RemoveModifier { target, .. } => *target,
Action::Flee | Action::OpenShop => Target::Zelf,
Action::Flee | Action::OpenShop | Action::GiveExp { .. } => Target::Zelf,
}
}
}

View File

@@ -53,6 +53,17 @@ impl Attribute {
Attribute::Resistance,
];
/// Worauf ein Aufstiegspunkt gesetzt werden darf (Ticket 15). Abgeleitete
/// Groessen wie `damage` fehlen bewusst: Punkte direkt dorthin zu setzen
/// umginge das Modifikatorsystem aus Ticket 01.
pub const ASSIGNABLE: [Attribute; 5] = [
Attribute::Strength,
Attribute::Dexterity,
Attribute::Constitution,
Attribute::Charisma,
Attribute::Luck,
];
pub fn name(self) -> &'static str {
match self {
Attribute::Strength => "Stärke",

View File

@@ -13,7 +13,6 @@ pub enum Direction {
/// Ticket 11 hat zwoelf Commands festgelegt; M1 braucht zwei davon.
///
// TODO(M5+): `AssignPoints` aus Ticket 15.
#[derive(Debug, Clone)]
pub enum Command {
Move(Direction),
@@ -44,6 +43,10 @@ pub enum Command {
ability: String,
target: super::creature::CreatureRef,
},
/// **Alle** Punkte auf einmal und damit **ein** Zug (Ticket 15). Ein
/// Kommando je Punkt hiesse bei drei Stufen drei Zuege, in denen ein Gift
/// tickt, waehrend man ein Menue bedient.
AssignPoints(Vec<(super::attributes::Attribute, u32)>),
/// Werkbank. **Ein** Kommando mit einer Variantenliste statt fuenf
/// Kommandos, die sich durch `rejects` und `perform` ziehen — ein Cheat
/// wird nie abgewiesen, das ist sein ganzer Zweck.

View File

@@ -165,7 +165,7 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor {
.npcs
.get(&id)
.and_then(|n| n.shop.as_deref())
.map(|t| crate::game::loot::roll(t, reg, rng))
.map(|t| crate::game::loot::roll(t, 0.0, reg, rng))
.unwrap_or_default();
Some(super::floor::NpcInstance { pos, def: id, stock })
})

View File

@@ -15,14 +15,26 @@ use super::rng::Rng;
/// Zieht aus einer Tabelle. Rekursion ueber Tabelle-in-Tabelle ist mit einer
/// Tiefe gedeckelt — eine Tabelle, die sich selbst einbindet, wuerde sonst
/// endlos laufen, und das ist ein Datenfehler, kein Absturzgrund.
pub fn roll(table: &str, reg: &Registries, rng: &mut Rng) -> Vec<ItemInstance> {
/// `luck` ist der **gerechnete** Wert des Ziehenden — 0.0, wenn niemand zieht
/// (ein Haendlerregal hat kein Glueck).
pub fn roll(table: &str, luck: f32, reg: &Registries, rng: &mut Rng) -> Vec<ItemInstance> {
let mut out = Vec::new();
draw(table, reg, rng, 0, &mut out);
draw(table, luck, reg, rng, 0, &mut out);
out
}
/// `luck` hat genau **eine** Regel (Ticket 16): eine Chance auf einen
/// zusaetzlichen Zug — auf jeder Tabelle, und deshalb auch auf jeder
/// Affixtabelle. Sie wirkt dreifach, ohne dreimal geschrieben zu werden: mehr
/// Gegenstaende, mehr Affixe, und haeufiger die staerkere Stufe, weil die als
/// eigener Eintrag mit kleinem Gewicht danebensteht.
fn extra_roll(luck: f32, reg: &Registries, rng: &mut Rng) -> u32 {
u32::from(rng.chance((luck * reg.balance.luck_factor).clamp(0.0, 0.9)))
}
fn draw(
table_id: &str,
luck: f32,
reg: &Registries,
rng: &mut Rng,
depth: u8,
@@ -35,7 +47,7 @@ fn draw(
// `guaranteed` nimmt an der Gewichtung nicht teil und kommt immer.
for entry in table.entries.iter().filter(|e| e.guaranteed) {
emit(entry, reg, rng, depth, out);
emit(entry, luck, reg, rng, depth, out);
}
let pool: Vec<&crate::data::LootEntry> =
@@ -46,16 +58,17 @@ fn draw(
let weights: Vec<u32> = pool.iter().map(|e| e.weight).collect();
let (lo, hi) = parse_range(table.rolls.as_ref());
let rolls = rng.range(lo, hi);
let rolls = rng.range(lo, hi) + extra_roll(luck, reg, rng);
for _ in 0..rolls {
if let Some(i) = rng.weighted(&weights) {
emit(pool[i], reg, rng, depth, out);
emit(pool[i], luck, reg, rng, depth, out);
}
}
}
fn emit(
entry: &crate::data::LootEntry,
luck: f32,
reg: &Registries,
rng: &mut Rng,
depth: u8,
@@ -67,7 +80,7 @@ fn emit(
}
if let Some(sub) = &entry.table {
draw(sub, reg, rng, depth + 1, out);
draw(sub, luck, reg, rng, depth + 1, out);
return;
}
@@ -83,7 +96,7 @@ fn emit(
}
let affixes = match &entry.affixes {
Some(table) => affixes_for(table, reg, rng),
Some(table) => affixes_for(table, luck, reg, rng),
None => Vec::new(),
};
@@ -111,13 +124,13 @@ fn emit(
/// Zieht Affixe aus derselben Tabellenmaschinerie. Abstufungen sind eigene
/// Eintraege mit kleinerem Gewicht — deshalb erhoeht ein zusaetzlicher Zug
/// (Glueck) auch die Chance auf die staerkere Stufe.
fn affixes_for(table_id: &str, reg: &Registries, rng: &mut Rng) -> Vec<String> {
fn affixes_for(table_id: &str, luck: f32, reg: &Registries, rng: &mut Rng) -> Vec<String> {
let Some(table) = reg.loot_tables.get(table_id) else { return Vec::new() };
let weights: Vec<u32> = table.entries.iter().map(|e| e.weight).collect();
let (lo, hi) = parse_range(table.rolls.as_ref());
let mut out = Vec::new();
for _ in 0..rng.range(lo, hi) {
for _ in 0..rng.range(lo, hi) + extra_roll(luck, reg, rng) {
let Some(i) = rng.weighted(&weights) else { continue };
let entry = &table.entries[i];
if entry.nothing {
@@ -143,7 +156,7 @@ mod tests {
let (reg, _) = load_mods().expect("core");
let mut rng = Rng::new(1);
// Eine Tabelle, die es nicht gibt, ist kein Absturzgrund.
assert!(roll("gibtsnicht", &reg, &mut rng).is_empty());
assert!(roll("gibtsnicht", 0.0, &reg, &mut rng).is_empty());
}
#[test]
@@ -151,7 +164,7 @@ mod tests {
let (reg, _) = load_mods().expect("core");
for seed in 1..30u64 {
let mut rng = Rng::new(seed);
let beute = roll("core:boss_table", &reg, &mut rng);
let beute = roll("core:boss_table", 0.0, &reg, &mut rng);
assert!(
beute.iter().any(|i| i.def == "core:bone_blade"),
"seed {seed}: das Einzelstueck muss fallen"
@@ -165,7 +178,7 @@ mod tests {
let mut mit_affix = 0;
for seed in 1..80u64 {
let mut rng = Rng::new(seed);
for inst in roll("core:floor_5_table", &reg, &mut rng) {
for inst in roll("core:floor_5_table", 0.0, &reg, &mut rng) {
if !inst.affixes.is_empty() {
mit_affix += 1;
// Jeder gezogene Affix muss existieren.
@@ -179,8 +192,8 @@ mod tests {
#[test]
fn derselbe_seed_liefert_dieselbe_beute() {
let (reg, _) = load_mods().expect("core");
let a = roll("core:floor_3_table", &reg, &mut Rng::new(77));
let b = roll("core:floor_3_table", &reg, &mut Rng::new(77));
let a = roll("core:floor_3_table", 0.0, &reg, &mut Rng::new(77));
let b = roll("core:floor_3_table", 0.0, &reg, &mut Rng::new(77));
let ids = |v: &[ItemInstance]| {
v.iter().map(|i| (i.def.clone(), i.affixes.clone())).collect::<Vec<_>>()
};
@@ -217,6 +230,6 @@ mod tests {
reg.loot_tables.insert(String::from("b"), tabelle("b", "a"));
// Terminiert, statt den Stack zu sprengen.
assert!(roll("a", &reg, &mut Rng::new(5)).is_empty());
assert!(roll("a", 0.0, &reg, &mut Rng::new(5)).is_empty());
}
}

View File

@@ -53,8 +53,13 @@ pub struct Game {
/// Absicht aus `open_shop`. Die Schleife liest sie und schiebt den Screen;
/// `game/` kennt keine Screens.
pub open_shop: bool,
// TODO(M5): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an
// `Game`, nicht am `Creature`).
/// Erfahrung und unverteilte Punkte stehen an `Game`, **nicht am
/// `Creature`** (Ticket 15): Nur der Held hat beides, und ein Feld, das
/// fuer 99 % der Exemplare bedeutungslos ist, waere ein Kommentar in
/// Feldform — den der Speicherstand fuer jeden Gegner mitschriebe.
pub current_exp: u32,
pub unspent_points: u32,
// TODO(M6): `quests` (Ticket 08).
}
impl Game {
@@ -80,6 +85,8 @@ impl Game {
journal: Vec::new(),
talking_to: None,
open_shop: false,
current_exp: 0,
unspent_points: 0,
})
}
@@ -335,7 +342,7 @@ impl Game {
self.events.push_back(
Event::new(EventKind::DamageTaken, victim).to(source).with_damage(*damage_type),
);
self.check_death(victim, source);
self.check_death(victim, source, reg);
}
Action::ChangeAttribute { attribute, amount, .. } => {
@@ -350,7 +357,7 @@ impl Game {
} else {
c.stamina = (c.stamina + amount).clamp(0.0, max);
}
self.check_death(victim, source);
self.check_death(victim, source, reg);
}
Action::ApplyModifier { modifier, .. } => {
@@ -383,6 +390,10 @@ impl Game {
// die Schleife schiebt den Screen. `game/` kennt ratatui nicht.
Action::OpenShop => self.open_shop = true,
// Der zweite Weg zur Erfahrung — fuer Questbelohnungen. Immer an
// den Helden: Ein Gegner hat keine Erfahrung, die ihm nuetzt.
Action::GiveExp { amount } => self.current_exp += amount,
Action::Flee => {
let own = self.attribute(source, Attribute::Dexterity, None, reg);
// Gerechnete Werte, nicht Basiswerte — sonst ignoriert die
@@ -405,12 +416,29 @@ impl Game {
}
}
fn check_death(&mut self, who: CreatureRef, killer: CreatureRef) {
fn check_death(&mut self, who: CreatureRef, killer: CreatureRef, reg: &Registries) {
if self.creature(who).is_alive() {
return;
}
let name = self.creature(who).name.clone();
self.log(format!("{name} fällt."));
// `exp_reward` ist die **Kurzform** fuer einen Todes-Trigger, den sonst
// jede Gegnerdefinition mitschleppen muesste — samt einer
// `is_self`-Pruefung, die es gar nicht gibt (Ticket 15).
if who != CreatureRef::Player {
let lohn = self
.creature(who)
.def
.as_ref()
.and_then(|id| reg.creatures.get(id))
.map_or(0, |d| d.exp_reward);
if lohn > 0 {
self.current_exp += lohn;
self.log(format!("{lohn} Erfahrung"));
}
}
self.events.push_back(Event::new(EventKind::CreatureDied, who).to(killer));
if who == CreatureRef::Player {
@@ -523,13 +551,17 @@ impl Game {
self.drain_events(reg);
}
// 6. Ansagen fuer den naechsten Zug. **Nach** dem Entfernen der Toten,
// 6. Aufstieg — **nach** dem Leerarbeiten, damit eine Questbelohnung
// noch im selben Zug wirkt (Ticket 15).
self.level_up(reg);
// 7. Ansagen fuer den naechsten Zug. **Nach** dem Entfernen der Toten,
// sonst sagt eine Leiche an.
for i in 0..self.enemies.len() {
self.choose_intent(i, reg);
}
// 7.
// 8.
self.turn += 1;
Outcome::TurnPassed
}
@@ -599,6 +631,19 @@ impl Game {
// Sein eigenes Gold zurueckzuverkaufen ergibt keinen Sinn.
Command::Sell(i) => (self.player.inventory[*i].def == "core:gold")
.then_some("Gold verkauft man nicht"),
Command::AssignPoints(punkte) => {
let summe: u32 = punkte.iter().map(|(_, n)| n).sum();
if summe == 0 {
return Some("Nichts zu verteilen");
}
if summe > self.unspent_points {
return Some("Nicht genug Punkte");
}
punkte
.iter()
.any(|(a, _)| !Attribute::ASSIGNABLE.contains(a))
.then_some("Darauf nicht verteilbar")
}
// Ein Cheat wird nie abgewiesen — das ist sein Zweck.
Command::Debug(_) => None,
Command::Wait => None,
@@ -748,6 +793,7 @@ impl Game {
Command::Choose(i) => self.choose(*i, reg),
Command::Buy(i) => self.buy(*i, reg),
Command::Sell(i) => self.sell(*i, reg),
Command::AssignPoints(punkte) => self.assign_points(punkte, reg),
Command::Debug(cheat) => self.cheat(cheat, reg),
Command::Drop(i) => {
let inst = self.player.inventory.remove(*i);
@@ -770,7 +816,8 @@ impl Game {
let table = self.floor.rooms[room].loot_table.clone();
self.floor.rooms[room].loot.retain(|p| *p != (x, y));
let beute = loot::roll(&table, reg, &mut self.rng);
let glueck = self.attribute(CreatureRef::Player, Attribute::Luck, None, reg);
let beute = loot::roll(&table, glueck, reg, &mut self.rng);
if beute.is_empty() {
// Faellt der Wurf auf eine Niete, verschwindet der Haufen mit einer
// Meldung. Kein Neuwuerfeln — das haengt sich bei einer reinen
@@ -840,6 +887,73 @@ impl Game {
self.log(format!("{name} verkauft fuer {preis} Gold"));
}
/// Was der naechste Aufstieg kostet: `exp_base × level^exp_exponent`.
pub fn exp_needed(&self, reg: &Registries) -> u32 {
let level = self.player.base.get(Attribute::Level).max(1.0);
(reg.balance.exp_base * level.powf(reg.balance.exp_exponent)).round() as u32
}
/// Steigt auf, **solange** die Erfahrung reicht: Drei Stufen geben drei
/// Punkte. Eine Stufe je Ereignis erzeugte die Frage, wann der Rest
/// nachgeholt wird, plus einen Zustand „aufstiegsbereit" (Ticket 15).
///
/// Keine Obergrenze — fuenfzig Stufen ueber fuenf Etagen waeren bedeutungslos,
/// und eine harte Grenze waere der einzige Teil der Progression, der einen
/// spaeteren Endlosmodus blockierte.
/// Der Aufstieg selbst hebt **keinen** Lebensstand: Ticket 15 nennt zwar
/// zwei Stellen, aber es hat `per_level` gestrichen — eine Stufe aendert
/// `max_health` nicht, das tut erst der Punkt in Konstitution.
fn level_up(&mut self, reg: &Registries) {
loop {
let noetig = self.exp_needed(reg);
if self.current_exp < noetig {
return;
}
self.current_exp -= noetig;
let neu = self.player.base.get(Attribute::Level) + 1.0;
self.player.base.set(Attribute::Level, neu);
self.unspent_points += 1;
self.log(format!("Stufe {neu:.0}! Ein Punkt zu verteilen."));
}
}
/// Die **einzige** Stelle, an der der Lebensstand einem Zuwachs folgt
/// (Ticket 15). Keine allgemeine Regel: Sonst heilte ein Ring beim Anlegen.
/// `rejects` hat Deckung und Zielattribute geprueft.
fn assign_points(&mut self, punkte: &[(Attribute, u32)], reg: &Registries) {
let p = CreatureRef::Player;
let vorher = self.attribute(p, Attribute::MaxHealth, None, reg);
for (attr, n) in punkte {
let menge = *n as f32;
let alt = self.player.base.get(*attr);
self.player.base.set(*attr, alt + menge);
self.unspent_points -= n;
// Zwei Grundwerte wirken nicht von allein — siehe `Balance`.
match attr {
Attribute::Strength => {
let d = self.player.base.get(Attribute::Damage);
self.player
.base
.set(Attribute::Damage, d + menge * reg.balance.damage_per_strength);
}
Attribute::Constitution => {
let h = self.player.base.get(Attribute::MaxHealth);
self.player.base.set(
Attribute::MaxHealth,
h + menge * reg.balance.health_per_constitution,
);
}
_ => {}
}
}
let nachher = self.attribute(p, Attribute::MaxHealth, None, reg);
self.player.health += (nachher - vorher).max(0.0);
self.log(String::from("Punkte verteilt"));
}
/// Die Werkbank. Steht im Spielkern und nicht in der UI, weil sie durch
/// dieselbe Tuer muss wie alles andere: `Command` ist der einzige Weg
/// hinein (Ticket 11).
@@ -863,10 +977,15 @@ impl Game {
self.descend(reg);
}
Cheat::ClearRoom => {
for e in &mut self.enemies {
e.health = 0.0;
}
self.log(String::from("[debug] Raum geleert"));
for i in 0..self.enemies.len() {
self.enemies[i].health = 0.0;
// Ueber `check_death`, nicht nur ueber den Lebensstand:
// Sonst faellt der Gegner still um, ohne Todes-Trigger und
// ohne Erfahrung — und der Cheat testete den Weg nicht,
// den er testen soll.
self.check_death(CreatureRef::Enemy(i), CreatureRef::Player, reg);
}
}
}
}
@@ -1771,4 +1890,76 @@ mod tests {
assert_eq!(game.turn, vorher + 1, "und er kostet den Zug");
assert_eq!(game.count_of("core:health_potion"), 1);
}
/// Der ganze Kreis aus Ticket 15: toeten → Erfahrung → Aufstieg →
/// verteilen → staerker. Und die Zuwachs-Regel: `max_health` steigt erst,
/// wenn `constitution` **verteilt** wird, nicht schon beim Aufstieg.
#[test]
fn erfahrung_steigt_auf_und_punkte_wirken() {
let (mut game, reg) = zwergenkrieger();
let noetig = game.exp_needed(&reg);
// Drei Stufen auf einmal: Es wird in einer Schleife aufgestiegen, nicht
// eine Stufe je Ereignis (Ticket 15).
game.apply(
&Action::GiveExp { amount: noetig * 10 },
CreatureRef::Player,
CreatureRef::Player,
&reg,
);
game.execute(Command::Wait, &reg);
let stufe = game.player.base.get(Attribute::Level);
assert!(stufe >= 3.0, "zehnfache Erfahrung muss mehrere Stufen geben, war {stufe}");
assert_eq!(game.unspent_points as f32, stufe - 1.0, "je Stufe genau ein Punkt");
// Der Aufstieg allein hebt `max_health` nicht — das tut erst der Punkt.
let p = CreatureRef::Player;
let max_vorher = game.attribute(p, Attribute::MaxHealth, None, &reg);
let leben_vorher = game.player.health;
let punkte = game.unspent_points;
let cmd = Command::AssignPoints(vec![(Attribute::Constitution, punkte)]);
assert!(matches!(game.execute(cmd, &reg), Outcome::TurnPassed));
let max_nachher = game.attribute(p, Attribute::MaxHealth, None, &reg);
assert!(
max_nachher > max_vorher,
"Konstitution muss wirken — sonst ist der Punkt eine Falle"
);
assert_eq!(
game.player.health - leben_vorher,
max_nachher - max_vorher,
"der Lebensstand folgt dem Zuwachs"
);
assert_eq!(game.unspent_points, 0);
}
/// `rejects` haelt die Grenzen: mehr Punkte als vorhanden, und Attribute,
/// die gar nicht verteilbar sind.
#[test]
fn punkte_lassen_sich_nicht_erfinden() {
let (mut game, reg) = zwergenkrieger();
game.unspent_points = 1;
let zuviel = Command::AssignPoints(vec![(Attribute::Strength, 2)]);
assert!(matches!(game.execute(zuviel, &reg), Outcome::Rejected(_)));
// `damage` ist abgeleitet — ein Punkt dorthin umginge das
// Modifikatorsystem aus Ticket 01.
let falsch = Command::AssignPoints(vec![(Attribute::Damage, 1)]);
assert!(matches!(game.execute(falsch, &reg), Outcome::Rejected(_)));
assert_eq!(game.unspent_points, 1, "eine Abweisung darf nichts verbrauchen");
}
/// `exp_reward` stand seit M3 an jeder Gegnerdefinition und las es niemand.
#[test]
fn ein_toter_gegner_zahlt_erfahrung() {
let (mut game, reg) = zwergenkrieger();
let ratte = reg.creatures.get("core:rat").expect("Riesenratte");
assert!(ratte.exp_reward > 0, "die Testdaten muessen etwas zahlen");
game.enemies = vec![Creature::from_def(ratte)];
game.execute(Command::Debug(Cheat::ClearRoom), &reg);
assert_eq!(game.current_exp, ratte.exp_reward);
}
}

View File

@@ -50,7 +50,16 @@ impl Rng {
}
pub fn chance(&mut self, p: f32) -> bool {
(self.next_u64() % 10_000) < (p.clamp(0.0, 1.0) * 10_000.0) as u64
// Eine Chance von null ist keine Frage an den Wuerfel. Ohne das
// verbraucht ein `chance(0.0)` eine Zahl und verschiebt den ganzen
// Strom — ein Held ohne Glueck erzeugte eine andere Etage als einer mit.
if p <= 0.0 {
return false;
}
if p >= 1.0 {
return true;
}
(self.next_u64() % 10_000) < (p * 10_000.0) as u64
}
/// Zieht einen Index gewichtet. Gewichte statt Prozente — sie komponieren,

View File

@@ -136,7 +136,9 @@ fn draw(frame: &mut Frame, app: &App) {
draw_combat(frame, area, app, *pick, *target, *targeting)
}
Screen::Inventory { pick } => draw_inventory(frame, area, app, *pick),
Screen::CharacterSheet => draw_sheet(frame, area, app),
Screen::CharacterSheet { pick, pending } => {
draw_sheet(frame, area, app, *pick, pending)
}
Screen::Dialog { pick } => draw_dialog(frame, area, app, *pick),
Screen::Shop { pick, selling } => draw_shop(frame, area, app, *pick, *selling),
Screen::Debug { pick } => draw_debug(frame, area, app, *pick),
@@ -530,7 +532,7 @@ fn draw_game_over(frame: &mut Frame, area: Rect, app: &App) {
/// Der Charakterbogen ist eine **Schleife ueber Registries**, kein gemauertes
/// Layout — Modder koennen Slots und Wahlkategorien ergaenzen (Ticket 02).
fn draw_sheet(frame: &mut Frame, area: Rect, app: &App) {
fn draw_sheet(frame: &mut Frame, area: Rect, app: &App, pick: usize, pending: &[u32; 5]) {
let block = Block::bordered().title(" Charakterbogen ");
let inner = block.inner(area);
frame.render_widget(block, area);
@@ -554,7 +556,16 @@ fn draw_sheet(frame: &mut Frame, area: Rect, app: &App) {
} else {
format!("{total:.0} ({base:.0} Basis)")
};
Row::new(vec![a.name().to_string(), cell])
// Verteilbare Grundwerte tragen Cursor und Vormerkung — der Bogen
// ist zugleich der Verteil-Screen (Ticket 15).
let Some(i) = Attribute::ASSIGNABLE.iter().position(|x| x == a) else {
return Row::new(vec![format!(" {}", a.name()), cell]);
};
let zeiger = if i == pick { "›" } else { " " };
let vorgemerkt = if pending[i] > 0 { format!(" +{}", pending[i]) } else { String::new() };
Row::new(vec![format!("{zeiger} {} {vorgemerkt}", a.name()), cell])
.style(Style::new().fg(if i == pick { Color::Yellow } else { Color::Reset }))
})
.collect();
@@ -566,6 +577,21 @@ fn draw_sheet(frame: &mut Frame, area: Rect, app: &App) {
let mut lines = Vec::new();
// Erfahrung und Punkte stehen an `Game`, nicht am `Creature` (Ticket 15).
let offen = app.game.unspent_points.saturating_sub(pending.iter().sum::<u32>());
lines.push(Line::from(format!(
"Erfahrung {}/{}",
app.game.current_exp,
app.game.exp_needed(&app.registries)
)));
if app.game.unspent_points > 0 {
lines.push(
Line::from(format!("{offen} Punkte offen · ←→ verteilen · Enter bestätigen"))
.style(Style::new().fg(Color::Yellow)),
);
}
lines.push(Line::from(""));
lines.push(Line::from("Herkunft".bold()));
for id in &app.game.player.options {
let name = app.registries.options.get(id).map(|o| o.name.as_str()).unwrap_or(id);

View File

@@ -3,7 +3,7 @@
use ratatui::crossterm::event::KeyCode;
use crate::data::Registries;
use crate::game::{Cheat, Command, CreatureRef, Direction, Game};
use crate::game::{Attribute, Cheat, Command, CreatureRef, Direction, Game};
/// Popups sind gewoehnliche Screens, die sich beim Rendern als Overlay
/// ausweisen — ein gemeinsamer `Vec<Screen>` statt `current` plus
@@ -25,7 +25,15 @@ pub enum Screen {
Inventory {
pick: usize,
},
CharacterSheet,
/// Zugleich der Verteil-Screen — **kein zwoelfter Screen** (Ticket 15).
CharacterSheet {
/// Cursor in `Attribute::ASSIGNABLE`.
pick: usize,
/// Vorgemerkte Punkte, noch nicht abgeschickt. Ein festes Feld statt
/// eines `Vec`, weil die fuenf verteilbaren Grundwerte eine feste
/// Liste sind.
pending: [u32; 5],
},
/// Vollbild, kein Overlay. Bewusst nicht die lazy Variante mit einer
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
/// Lauf am Ende *erzaehlt* wird (Ticket 14).
@@ -64,6 +72,10 @@ pub enum Response {
}
impl Screen {
pub fn sheet() -> Screen {
Screen::CharacterSheet { pick: 0, pending: [0; 5] }
}
pub fn is_overlay(&self) -> bool {
// TODO(M2): `Confirm` ist das erste echte Overlay. Bis dahin haelt die
// Regel „ein Command schliesst alle Overlays" nur die Luft.
@@ -104,7 +116,7 @@ impl Screen {
KeyCode::Char('t') => Response::Command(Command::Talk),
KeyCode::F(12) => Response::Push(Screen::Debug { pick: 0 }),
// Screen-Navigation ist reine UI und fasst `Game` nie an.
KeyCode::Char('c') => Response::Push(Screen::CharacterSheet),
KeyCode::Char('c') => Response::Push(Screen::sheet()),
KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }),
KeyCode::Esc => Response::Pop,
_ => Response::None,
@@ -161,7 +173,7 @@ impl Screen {
})
}
KeyCode::Char('.') => Response::Command(Command::Wait),
KeyCode::Char('c') => Response::Push(Screen::CharacterSheet),
KeyCode::Char('c') => Response::Push(Screen::sheet()),
// Derselbe Screen wie draussen. `rejects` verbietet
// `UseItem` im Kampf nicht — es fehlte nur die Tuer.
KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }),
@@ -206,10 +218,46 @@ impl Screen {
}
}
Screen::CharacterSheet => match key {
KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => Response::Pop,
Screen::CharacterSheet { pick, pending } => {
// `saturating_sub`, weil `unspent_points` sich unter dem
// offenen Screen aendern kann — ein Aufstieg im selben Zug.
let offen = game.unspent_points.saturating_sub(pending.iter().sum::<u32>());
match key {
KeyCode::Up | KeyCode::Char('k') => {
*pick = pick.saturating_sub(1);
Response::None
}
KeyCode::Down | KeyCode::Char('j') => {
*pick = (*pick + 1).min(Attribute::ASSIGNABLE.len() - 1);
Response::None
}
KeyCode::Right | KeyCode::Char('+' | 'l') if offen > 0 => {
pending[*pick] += 1;
Response::None
}
KeyCode::Left | KeyCode::Char('-' | 'h') if pending[*pick] > 0 => {
pending[*pick] -= 1;
Response::None
}
// **Alle** Punkte in einem Kommando und damit in einem Zug
// (Ticket 15).
KeyCode::Enter => {
let punkte = Attribute::ASSIGNABLE
.iter()
.zip(pending.iter())
.filter(|(_, n)| **n > 0)
.map(|(a, n)| (*a, *n))
.collect();
// **Vorgemerktes wird beim Abschicken verbraucht.** Ohne
// das behaelt der Screen Punkte, die das Spiel schon
// abgezogen hat — und rechnet `0 - 1` auf `u32`.
*pending = [0; 5];
Response::Command(Command::AssignPoints(punkte))
}
KeyCode::Esc | KeyCode::Char('c' | 'q') => Response::Pop,
_ => Response::None,
},
}
}
Screen::Dialog { pick } => {
let options = game.dialog_options(reg);
@@ -300,3 +348,44 @@ impl Screen {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::load_mods;
use crate::game::{Creature, Rng};
fn spiel() -> (Game, Registries) {
let (reg, _) = load_mods().expect("core muss laden");
let game = Game::new(Creature::new("Test"), &reg, Rng::new(1)).expect("Etage 1");
(game, reg)
}
/// Gefunden im Spiel: Nach dem Abschicken stand `unspent_points` auf 0,
/// waehrend der Screen den Punkt noch vorgemerkt hielt — `0 - 1` auf `u32`.
/// Der erste Test, der **Screen-Zustand gegen Spielzustand** prueft; die
/// Trennung aus Ticket 11 macht genau diese Naht zur Fehlerquelle.
#[test]
fn vorgemerkte_punkte_werden_beim_abschicken_verbraucht() {
let (mut game, reg) = spiel();
game.unspent_points = 1;
let mut screen = Screen::sheet();
screen.handle_key(KeyCode::Char('+'), &game, &reg);
let Screen::CharacterSheet { pending, .. } = &screen else { panic!("falscher Screen") };
assert_eq!(pending[0], 1, "der Punkt ist vorgemerkt");
let antwort = screen.handle_key(KeyCode::Enter, &game, &reg);
let Response::Command(cmd) = antwort else { panic!("Enter muss abschicken") };
assert!(matches!(game.execute(cmd, &reg), crate::game::Outcome::TurnPassed));
assert_eq!(game.unspent_points, 0);
let Screen::CharacterSheet { pending, .. } = &screen else { panic!("falscher Screen") };
assert_eq!(pending[0], 0, "und im Screen nicht mehr vorgemerkt");
// Der Zustand, der abgestuerzt ist: kein offener Punkt, keine Vormerkung.
screen.handle_key(KeyCode::Char('+'), &game, &reg);
let Screen::CharacterSheet { pending, .. } = &screen else { panic!("falscher Screen") };
assert_eq!(pending[0], 0, "ohne offene Punkte laesst sich nichts vormerken");
}
}