diff --git a/.scratch/rustkeep-1.0/issues/11-ui-geruest-screen-modell.md b/.scratch/rustkeep-1.0/issues/11-ui-geruest-screen-modell.md index c79955b..edf6665 100644 --- a/.scratch/rustkeep-1.0/issues/11-ui-geruest-screen-modell.md +++ b/.scratch/rustkeep-1.0/issues/11-ui-geruest-screen-modell.md @@ -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. diff --git a/.scratch/rustkeep-1.0/map.md b/.scratch/rustkeep-1.0/map.md index f66ef39..795199e 100644 --- a/.scratch/rustkeep-1.0/map.md +++ b/.scratch/rustkeep-1.0/map.md @@ -132,7 +132,7 @@ 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) +**M5 — Progression** (`feat/m5-progression`, 65 Tests) | Zusage | Aus | Befund | |---|---|---| @@ -146,6 +146,11 @@ Sperrgründe, die `ability_block` ohnehin berechnete, haben einen zweiten Nutzen | 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 diff --git a/src/ui/app.rs b/src/ui/app.rs index 49b3077..a45f934 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -578,7 +578,7 @@ fn draw_sheet(frame: &mut Frame, area: Rect, app: &App, pick: usize, pending: &[ 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::(); + let offen = app.game.unspent_points.saturating_sub(pending.iter().sum::()); lines.push(Line::from(format!( "Erfahrung {}/{}", app.game.current_exp, diff --git a/src/ui/screen.rs b/src/ui/screen.rs index ac559fa..a52f9e9 100644 --- a/src/ui/screen.rs +++ b/src/ui/screen.rs @@ -219,7 +219,9 @@ impl Screen { } Screen::CharacterSheet { pick, pending } => { - let offen = game.unspent_points - pending.iter().sum::(); + // `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::()); match key { KeyCode::Up | KeyCode::Char('k') => { *pick = pick.saturating_sub(1); @@ -239,14 +241,19 @@ impl Screen { } // **Alle** Punkte in einem Kommando und damit in einem Zug // (Ticket 15). - KeyCode::Enter => Response::Command(Command::AssignPoints( - Attribute::ASSIGNABLE + KeyCode::Enter => { + let punkte = Attribute::ASSIGNABLE .iter() .zip(pending.iter()) .filter(|(_, n)| **n > 0) .map(|(a, n)| (*a, *n)) - .collect(), - )), + .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, } @@ -341,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"), ®, 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, ®); + 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, ®); + let Response::Command(cmd) = antwort else { panic!("Enter muss abschicken") }; + assert!(matches!(game.execute(cmd, ®), 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, ®); + let Screen::CharacterSheet { pending, .. } = &screen else { panic!("falscher Screen") }; + assert_eq!(pending[0], 0, "ohne offene Punkte laesst sich nichts vormerken"); + } +}