feat(debug): Werkbank auf F12

Gegenstandsliste mit Cursor, dazu heilen, absteigen und Raum leeren.
Laeuft ueber ein Kommando Debug(Cheat) statt am Spielkern vorbei — die
Zusage aus Ticket 11, dass Response::Command der einzige Weg nach game/
ist, gilt auch fuer Cheats.

ClearRoom toetet die Gegner, statt die Liste zu leeren: So laeuft der
normale Weg samt Todes-Triggern und "Der Raum ist geraeumt".
This commit is contained in:
2026-08-12 20:58:35 +02:00
parent 036f60d463
commit 26d9533a93
6 changed files with 175 additions and 2 deletions

View File

@@ -252,3 +252,23 @@ Alternative gewesen — weniger Tastendrücke, aber eine Bedienung, die es sonst
Dazu zeigt die Gegnerliste unter jedem Lebensbalken die **Ansage** aus Ticket 14 (`↳ Wuchtschlag →
dich`, sonst `↳ zögert`). Sie steht bewusst als eigene Zeile und nicht als Kürzel hinter dem Namen:
Sie ist die Information, auf der die Entscheidung dieses Zuges beruht.
### Nachtrag — die Werkbank (Debug-Screen, nach M4)
Ein zwölfter Screen, `F12` aus Erkundung und Kampf: Gegenstandsliste mit Cursor, dazu vier Tasten für
heilen, absteigen, Raum leeren.
**Sie geht durch dieselbe Tür wie alles andere.** Es wäre kürzer gewesen, den Screen direkt auf
`Game` schreiben zu lassen — er hat den `&mut` ohnehin nicht, aber man hätte ihn ihm geben können. Das
hätte die Zusage aus Ticket 11 gebrochen, dass `Response::Command` der einzige Weg nach `game/` ist,
und zwar ausgerechnet an der Stelle, die am seltensten gelesen wird. Stattdessen ein Kommando
`Debug(Cheat)` mit vier Varianten — **eins**, nicht vier, sonst zögen sich Cheats durch `rejects` und
`perform` wie echte Kommandos. In `rejects` steht genau eine Zeile: Ein Cheat wird nie abgewiesen.
`ClearRoom` setzt die Lebenspunkte auf null, statt `enemies` zu leeren. So läuft der normale Weg samt
Todes-Triggern, Erfahrung (später) und „Der Raum ist geräumt" — ein Cheat, der am Spielablauf
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.

View File

@@ -459,6 +459,15 @@ pub struct Registries {
}
impl Registries {
/// Alle Gegenstaende in stabiler Reihenfolge — fuer die Werkbank. Ohne das
/// haengt die Liste an der HashMap-Iterationsreihenfolge und der Cursor
/// zeigt bei jedem Start woanders hin.
pub fn items_ordered(&self) -> Vec<&Item> {
let mut v: Vec<&Item> = self.items.values().collect();
v.sort_by(|a, b| a.id.cmp(&b.id));
v
}
/// Slots in Anzeigereihenfolge. Der Charakterbogen schleift darueber,
/// statt ein Layout zu mauern (Ticket 02).
pub fn slots_ordered(&self) -> Vec<&Slot> {

View File

@@ -44,6 +44,26 @@ pub enum Command {
ability: String,
target: super::creature::CreatureRef,
},
/// 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.
Debug(Cheat),
}
/// Was die Werkbank kann. Bewusst klein: Was sich mit zwei vorhandenen
/// Kommandos nachstellen laesst, gehoert nicht hierher.
#[derive(Debug, Clone)]
pub enum Cheat {
/// Gibt den Gegenstand — zehn Stueck, wenn er stapelbar ist. Sonst muesste
/// man fuer hundert Gold hundertmal druecken.
Give(String),
/// Leben und Ausdauer voll.
Heal,
/// Eine Etage tiefer, ohne Treppe.
Descend,
/// Setzt alle Gegner auf null Leben. **Nicht** `enemies.clear()`: So laeuft
/// der normale Weg samt Todes-Triggern und „Der Raum ist geräumt".
ClearRoom,
}
/// Ergebnis eines Kommandos — der Befund aus dem Durchstich in Ticket 12:

View File

@@ -14,7 +14,7 @@ pub mod rng;
pub use action::{Action, ActiveModifier, DamageType, StateField, Target};
pub use attributes::{Attribute, Modifier};
pub use command::{Command, Direction, Outcome};
pub use command::{Cheat, Command, Direction, Outcome};
pub use condition::{Check, Condition, Who};
pub use creature::{Creature, CreatureRef, ItemInstance};
pub use event::{Event, EventKind, Trigger};
@@ -599,6 +599,8 @@ 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"),
// Ein Cheat wird nie abgewiesen — das ist sein Zweck.
Command::Debug(_) => None,
Command::Wait => None,
}
}
@@ -746,6 +748,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::Debug(cheat) => self.cheat(cheat, reg),
Command::Drop(i) => {
let inst = self.player.inventory.remove(*i);
let name = self.item_label(&inst, reg);
@@ -837,6 +840,37 @@ impl Game {
self.log(format!("{name} verkauft fuer {preis} Gold"));
}
/// 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).
fn cheat(&mut self, cheat: &Cheat, reg: &Registries) {
match cheat {
Cheat::Give(id) => {
let stapelbar = reg.items.get(id).is_some_and(|d| d.stackable);
let inst = ItemInstance::stack(id.clone(), if stapelbar { 10 } else { 1 });
let name = self.item_label(&inst, reg);
self.give(inst, reg);
self.log(format!("[debug] {name}"));
}
Cheat::Heal => {
let p = CreatureRef::Player;
self.player.health = self.attribute(p, Attribute::MaxHealth, None, reg);
self.player.stamina = self.attribute(p, Attribute::MaxStamina, None, reg);
self.log(String::from("[debug] geheilt"));
}
Cheat::Descend => {
self.log(String::from("[debug] abgestiegen"));
self.descend(reg);
}
Cheat::ClearRoom => {
for e in &mut self.enemies {
e.health = 0.0;
}
self.log(String::from("[debug] Raum geleert"));
}
}
}
/// Nimmt **ein Stueck** aus dem Eintrag an `index` und gibt es zurueck.
/// Ueber den Index, nicht ueber die `def`: Zwei Traenke mit verschiedenen
/// Affixen teilen sich die `def`, und `take` naehme den erstbesten.
@@ -1688,4 +1722,31 @@ mod tests {
"der leere Eintrag muss verschwinden"
);
}
/// Die Werkbank laeuft durch dieselbe Tuer wie alles andere — sie ist ein
/// `Command`, kein Seiteneingang in `Game`. Der Test haelt genau das fest.
#[test]
fn werkbank_gibt_stapelbares_im_stapel() {
let (mut game, reg) = zwergenkrieger();
let cheat = Command::Debug;
game.execute(cheat(Cheat::Give(String::from("core:gold"))), &reg);
game.execute(cheat(Cheat::Give(String::from("core:iron_sword"))), &reg);
assert_eq!(game.gold(), 10, "stapelbares kommt zu zehnt");
assert_eq!(game.count_of("core:iron_sword"), 1, "der Rest einzeln");
game.player.health = 1.0;
game.player.stamina = 0.0;
game.execute(cheat(Cheat::Heal), &reg);
let voll = game.attribute(CreatureRef::Player, Attribute::MaxHealth, None, &reg);
assert_eq!(game.player.health, voll);
// Gegner werden getoetet, nicht entfernt — so laeuft der normale Weg
// samt „Der Raum ist geräumt".
let ratte = reg.creatures.get("core:rat").expect("Riesenratte");
game.enemies = vec![Creature::from_def(ratte)];
game.execute(cheat(Cheat::ClearRoom), &reg);
assert!(game.enemies.is_empty());
assert!(game.journal.iter().any(|z| z.contains("geräumt")), "{:?}", game.journal);
}
}

View File

@@ -139,6 +139,7 @@ fn draw(frame: &mut Frame, app: &App) {
Screen::CharacterSheet => draw_sheet(frame, area, app),
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),
Screen::GameOver => draw_game_over(frame, area, app),
}
}
@@ -344,6 +345,34 @@ fn draw_shop(frame: &mut Frame, area: Rect, app: &App, pick: usize, selling: boo
);
}
/// Die Werkbank. Eine Liste, vier Tasten — was sich mit zwei vorhandenen
/// Kommandos nachstellen laesst, steht hier nicht.
fn draw_debug(frame: &mut Frame, area: Rect, app: &App, pick: usize) {
// ponytail: kein mitlaufender Ausschnitt wie im Grid — mehr Gegenstaende
// als Zeilen fallen stumm hinten raus. `core` hat sieben; der Ausschnitt
// kommt, wenn der erste Mod das sprengt.
let mut zeilen = Vec::new();
for (i, def) in app.registries.items_ordered().iter().enumerate() {
let zeiger = if i == pick { "›" } else { " " };
let menge = if def.stackable { "×10" } else { " " };
zeilen.push(
Line::from(format!("{zeiger} {:<26} {menge} {}", def.name, def.id))
.style(Style::new().fg(if i == pick { Color::Yellow } else { Color::Reset })),
);
}
zeilen.push(Line::from(""));
zeilen.push(
Line::from("↑↓ wählen · Enter geben · h heilen · > abstiegen · x Raum leeren · Esc")
.style(Style::new().fg(Color::DarkGray)),
);
frame.render_widget(
Paragraph::new(zeilen)
.block(Block::bordered().title(" Werkbank ").title_bottom(" F12 ")),
area,
);
}
/// Menue-Kampf (Charter: Dungeon Master / Etrian Odyssey). Links die Gegner mit
/// Ziffern, rechts die Faehigkeiten mit Kosten und **Sperrgrund** — der kommt
/// aus dem Spielkern, nicht aus der UI (Ticket 14).

View File

@@ -3,7 +3,7 @@
use ratatui::crossterm::event::KeyCode;
use crate::data::Registries;
use crate::game::{Command, CreatureRef, Direction, Game};
use crate::game::{Cheat, Command, CreatureRef, Direction, Game};
/// Popups sind gewoehnliche Screens, die sich beim Rendern als Overlay
/// ausweisen — ein gemeinsamer `Vec<Screen>` statt `current` plus
@@ -30,6 +30,12 @@ pub enum Screen {
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
/// Lauf am Ende *erzaehlt* wird (Ticket 14).
GameOver,
/// Werkbank. Immer mit gebaut statt hinter `cfg(debug_assertions)`: Das
/// waere ein `#[cfg]` an der Variante *und* an jedem `match` darueber, fuer
/// ein Spiel, das niemand ausliefert. Ein `#[cfg]` reicht spaeter nach.
Debug {
pick: usize,
},
/// **Flache Optionsliste**, kein Knotengraph (M4): Was erst nach einer
/// Antwort erscheint, ist eine Option mit einer Bedingung.
Dialog {
@@ -96,6 +102,7 @@ impl Screen {
KeyCode::Char('>') => Response::Command(Command::Descend),
KeyCode::Char('g') => Response::Command(Command::PickUp),
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('i') => Response::Push(Screen::Inventory { pick: 0 }),
@@ -155,6 +162,7 @@ impl Screen {
}
KeyCode::Char('.') => Response::Command(Command::Wait),
KeyCode::Char('c') => Response::Push(Screen::CharacterSheet),
KeyCode::F(12) => Response::Push(Screen::Debug { pick: 0 }),
_ => Response::None,
}
}
@@ -259,6 +267,32 @@ impl Screen {
}
}
Screen::Debug { pick } => {
let items = reg.items_ordered();
if !items.is_empty() {
*pick = (*pick).min(items.len() - 1);
}
match key {
KeyCode::Up | KeyCode::Char('k') => {
*pick = pick.saturating_sub(1);
Response::None
}
KeyCode::Down | KeyCode::Char('j') => {
*pick = (*pick + 1).min(items.len().saturating_sub(1));
Response::None
}
KeyCode::Enter if !items.is_empty() => {
Response::Command(Command::Debug(Cheat::Give(items[*pick].id.clone())))
}
KeyCode::Char('h') => Response::Command(Command::Debug(Cheat::Heal)),
KeyCode::Char('>') => Response::Command(Command::Debug(Cheat::Descend)),
KeyCode::Char('x') => Response::Command(Command::Debug(Cheat::ClearRoom)),
KeyCode::Esc | KeyCode::F(12) | KeyCode::Char('q') => Response::Pop,
_ => Response::None,
}
}
Screen::GameOver => Response::Quit,
}
}