diff --git a/.scratch/rustkeep-1.0/issues/15-progression-und-levelup.md b/.scratch/rustkeep-1.0/issues/15-progression-und-levelup.md index d176196..98f3061 100644 --- a/.scratch/rustkeep-1.0/issues/15-progression-und-levelup.md +++ b/.scratch/rustkeep-1.0/issues/15-progression-und-levelup.md @@ -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`. diff --git a/.scratch/rustkeep-1.0/map.md b/.scratch/rustkeep-1.0/map.md index 4146766..f66ef39 100644 --- a/.scratch/rustkeep-1.0/map.md +++ b/.scratch/rustkeep-1.0/map.md @@ -132,6 +132,30 @@ 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`, 64 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 | + +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. diff --git a/src/data/registries.rs b/src/data/registries.rs index 1381a98..d7ce2c1 100644 --- a/src/data/registries.rs +++ b/src/data/registries.rs @@ -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, } } } diff --git a/src/game/action.rs b/src/game/action.rs index 64e711d..15bda12 100644 --- a/src/game/action.rs +++ b/src/game/action.rs @@ -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, } } } diff --git a/src/game/attributes.rs b/src/game/attributes.rs index a1a3dc8..5e0770c 100644 --- a/src/game/attributes.rs +++ b/src/game/attributes.rs @@ -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", diff --git a/src/game/command.rs b/src/game/command.rs index e133d1a..f6f28ee 100644 --- a/src/game/command.rs +++ b/src/game/command.rs @@ -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. diff --git a/src/game/generate.rs b/src/game/generate.rs index 235b4eb..20534e5 100644 --- a/src/game/generate.rs +++ b/src/game/generate.rs @@ -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 }) }) diff --git a/src/game/loot.rs b/src/game/loot.rs index 6b96afc..69455ce 100644 --- a/src/game/loot.rs +++ b/src/game/loot.rs @@ -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 { +/// `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 { 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 = 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 { +fn affixes_for(table_id: &str, luck: f32, reg: &Registries, rng: &mut Rng) -> Vec { let Some(table) = reg.loot_tables.get(table_id) else { return Vec::new() }; let weights: Vec = 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", ®, &mut rng).is_empty()); + assert!(roll("gibtsnicht", 0.0, ®, &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", ®, &mut rng); + let beute = roll("core:boss_table", 0.0, ®, &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", ®, &mut rng) { + for inst in roll("core:floor_5_table", 0.0, ®, &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", ®, &mut Rng::new(77)); - let b = roll("core:floor_3_table", ®, &mut Rng::new(77)); + let a = roll("core:floor_3_table", 0.0, ®, &mut Rng::new(77)); + let b = roll("core:floor_3_table", 0.0, ®, &mut Rng::new(77)); let ids = |v: &[ItemInstance]| { v.iter().map(|i| (i.def.clone(), i.affixes.clone())).collect::>() }; @@ -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", ®, &mut Rng::new(5)).is_empty()); + assert!(roll("a", 0.0, ®, &mut Rng::new(5)).is_empty()); } } diff --git a/src/game/mod.rs b/src/game/mod.rs index a72ab60..3c0ad8c 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -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(®); + + // 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, + ®, + ); + game.execute(Command::Wait, ®); + 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, ®); + let leben_vorher = game.player.health; + + let punkte = game.unspent_points; + let cmd = Command::AssignPoints(vec![(Attribute::Constitution, punkte)]); + assert!(matches!(game.execute(cmd, ®), Outcome::TurnPassed)); + + let max_nachher = game.attribute(p, Attribute::MaxHealth, None, ®); + 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, ®), 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, ®), 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), ®); + assert_eq!(game.current_exp, ratte.exp_reward); + } } diff --git a/src/game/rng.rs b/src/game/rng.rs index 20c448f..d2085c7 100644 --- a/src/game/rng.rs +++ b/src/game/rng.rs @@ -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, diff --git a/src/ui/app.rs b/src/ui/app.rs index 4a36e23..49b3077 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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 - pending.iter().sum::(); + 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); diff --git a/src/ui/screen.rs b/src/ui/screen.rs index 2178c04..ac559fa 100644 --- a/src/ui/screen.rs +++ b/src/ui/screen.rs @@ -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` 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,39 @@ impl Screen { } } - Screen::CharacterSheet => match key { - KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => Response::Pop, - _ => Response::None, - }, + Screen::CharacterSheet { pick, pending } => { + let offen = game.unspent_points - pending.iter().sum::(); + 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 => Response::Command(Command::AssignPoints( + Attribute::ASSIGNABLE + .iter() + .zip(pending.iter()) + .filter(|(_, n)| **n > 0) + .map(|(a, n)| (*a, *n)) + .collect(), + )), + KeyCode::Esc | KeyCode::Char('c' | 'q') => Response::Pop, + _ => Response::None, + } + } Screen::Dialog { pick } => { let options = game.dialog_options(reg);