feat(handel): NPC-Registry, Dialog, Laden und Stapelregel

Vierzehnte Registry: NPCs sind keine Wesen — kein Kampfzustand, keine
Initiative, keine Attribute. Ihre Antwortliste ist flach; was erst nach
einer Antwort erscheint, ist eine Option mit einer Bedingung.

- open_shop als elfte Aktion (game/ setzt eine Absicht, die UI schiebt)
- Talk/Choose/Buy/Sell, Talk wirkt auf das eigene Feld wie PickUp
- Preisspanne statt Goldkonto beim Haendler, Charisma in der Preisfunktion
- Warenkorb einmal bei der Etagenerzeugung gezogen
- Stapelregel aus Ticket 07 endlich gebaut: 50-120 Gold war 120 Eintraege
- Dialog- und Shop-Screen, NPC-Symbol im Grid
This commit is contained in:
2026-08-12 20:31:35 +02:00
parent 197f62ce33
commit 7ed15afaf9
22 changed files with 818 additions and 26 deletions

View File

@@ -48,7 +48,12 @@ pub struct Game {
pub last: String,
/// Kampflog, die letzten Zeilen.
pub journal: Vec<String>,
// TODO(M4): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an
/// Mit wem gerade gesprochen wird — Index in `Room.npcs`.
pub talking_to: Option<usize>,
/// 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`).
}
@@ -73,6 +78,8 @@ impl Game {
events: std::collections::VecDeque::new(),
last: String::from("—"),
journal: Vec::new(),
talking_to: None,
open_shop: false,
})
}
@@ -372,6 +379,10 @@ impl Game {
self.creature_mut(victim).modifiers.retain(|m| m.id != *modifier);
}
// Der Laden ist reine UI-Folge: Die Aktion setzt eine Absicht,
// die Schleife schiebt den Screen. `game/` kennt ratatui nicht.
Action::OpenShop => self.open_shop = true,
Action::Flee => {
let own = self.attribute(source, Attribute::Dexterity, None, reg);
// Gerechnete Werte, nicht Basiswerte — sonst ignoriert die
@@ -556,10 +567,85 @@ impl Game {
Command::Unequip(slot) => {
(!self.player.equipment.contains_key(slot)).then_some("Dort ist nichts")
}
Command::Talk if in_combat => Some("Nicht im Kampf"),
Command::Talk => {
let (x, y) = self.player_pos;
self.npc_here(x, y).is_none().then_some("Hier steht niemand")
}
Command::Choose(i) => {
(*i >= self.dialog_options(reg).len()).then_some("Keine solche Antwort")
}
Command::Buy(i) => {
let Some(stock) = self.shop_stock() else {
return Some("Kein Laden offen");
};
let Some(inst) = stock.get(*i) else { return Some("Nicht im Angebot") };
let preis = self.buy_price(inst, reg);
(self.gold() < preis).then_some("Zu wenig Gold")
}
Command::Sell(i) if *i >= self.player.inventory.len() => {
Some("Kein solcher Gegenstand")
}
// Sein eigenes Gold zurueckzuverkaufen ergibt keinen Sinn.
Command::Sell(i) => (self.player.inventory[*i].def == "core:gold")
.then_some("Gold verkauft man nicht"),
Command::Wait => None,
}
}
pub fn npc_here(&self, x: u16, y: u16) -> Option<usize> {
let room = self.floor.player_room?;
self.floor.rooms[room].npcs.iter().position(|n| n.pos == (x, y))
}
fn npc_instance(&self) -> Option<&crate::game::floor::NpcInstance> {
self.floor.rooms[self.floor.player_room?].npcs.get(self.talking_to?)
}
/// Die **erfuellbaren** Optionen des laufenden Gesprraechs. Filtern gehoert
/// hierhin und nicht in den Screen — sonst wertete die Anzeige Bedingungen
/// aus (Ticket 11).
pub fn dialog_options<'r>(&self, reg: &'r Registries) -> Vec<&'r crate::data::DialogOption> {
let Some(npc) = self.talking_npc(reg) else { return Vec::new() };
npc.options
.iter()
.filter(|o| self.conditions_hold(&o.condition, CreatureRef::Player, None, reg))
.collect()
}
pub fn talking_npc<'r>(&self, reg: &'r Registries) -> Option<&'r crate::data::Npc> {
reg.npcs.get(&self.npc_instance()?.def)
}
pub fn shop_stock(&self) -> Option<&[ItemInstance]> {
Some(&self.npc_instance()?.stock)
}
/// `kauf = item_price × (1 − price_modifier/100)`.
///
/// Die Charisma-Formel lebt **hier**, nicht in `Game::attribute` — dort
/// waere `price_modifier` das einzige Attribut mit einem Sonderfall, und ein
/// generischer Pfad mit einer Ausnahme ist die Stelle, an der spaeter jemand
/// raet.
pub fn price_modifier(&self, reg: &Registries) -> f32 {
let p = CreatureRef::Player;
self.attribute(p, Attribute::PriceModifier, None, reg)
+ self.attribute(p, Attribute::Charisma, None, reg) * reg.balance.charisma_preis
}
pub fn buy_price(&self, inst: &ItemInstance, reg: &Registries) -> u32 {
let roh = self.item_price(inst, reg) as f32;
(roh * (1.0 - self.price_modifier(reg) / 100.0)).max(1.0).round() as u32
}
/// `verkauf = item_price × VERKAUF × (1 + price_modifier/100)` — die
/// **Spanne** verhindert die Geldmaschine, deshalb braucht der Haendler
/// keinen Goldvorrat.
pub fn sell_price(&self, inst: &ItemInstance, reg: &Registries) -> u32 {
let roh = self.item_price(inst, reg) as f32 * reg.balance.verkauf;
(roh * (1.0 + self.price_modifier(reg) / 100.0)).max(1.0).round() as u32
}
/// Alle Faehigkeiten eines Wesens, in stabiler Reihenfolge.
///
/// `core:attack` und `core:flee` sind **jedem Wesen implizit gewaehrt**
@@ -646,6 +732,10 @@ impl Game {
Command::UseItem(i) => self.use_item(*i, reg),
Command::Equip(i) => self.equip(*i, reg),
Command::Unequip(slot) => self.unequip(&slot.clone()),
Command::Talk => self.talk(reg),
Command::Choose(i) => self.choose(*i, reg),
Command::Buy(i) => self.buy(*i, reg),
Command::Sell(i) => self.sell(*i, reg),
Command::Drop(i) => {
let inst = self.player.inventory.remove(*i);
let name = self.item_label(&inst, reg);
@@ -677,11 +767,74 @@ impl Game {
}
let namen: Vec<String> = beute.iter().map(|i| self.item_label(i, reg)).collect();
self.player.inventory.extend(beute);
for inst in beute {
self.give(inst, reg);
}
self.log(format!("Aufgehoben: {}", namen.join(", ")));
self.events.push_back(Event::new(EventKind::ItemPickedUp, CreatureRef::Player));
}
fn talk(&mut self, reg: &Registries) {
let (x, y) = self.player_pos;
let Some(i) = self.npc_here(x, y) else { return };
let room = self.floor.rooms.get(self.floor.player_room.unwrap()).unwrap();
let id = room.npcs[i].def.clone();
self.talking_to = Some(i);
if let Some(npc) = reg.npcs.get(&id)
&& !npc.greeting.is_empty()
{
self.log(format!("{}: {}", npc.name, npc.greeting));
}
}
fn choose(&mut self, index: usize, reg: &Registries) {
let Some(option) = self.dialog_options(reg).get(index).copied() else { return };
let text = option.text.clone();
let actions = option.actions.clone();
self.log(format!("> {text}"));
for action in &actions {
self.apply(action, CreatureRef::Player, CreatureRef::Player, reg);
}
}
fn buy(&mut self, index: usize, reg: &Registries) {
let Some(stock) = self.shop_stock() else { return };
let inst = stock[index].clone();
let preis = self.buy_price(&inst, reg);
// Wird erst genommen, dann gegeben: Ein Fehlschlag hier waere ein
// Gegenstand aus dem Nichts. `rejects` hat die Deckung schon geprueft.
if !self.take("core:gold", preis) {
return;
}
let name = self.item_label(&inst, reg);
self.give(inst, reg);
let room = self.floor.player_room.unwrap();
self.floor.rooms[room].npcs[self.talking_to.unwrap()].stock.remove(index);
self.log(format!("{name} gekauft fuer {preis} Gold"));
}
fn sell(&mut self, index: usize, reg: &Registries) {
// Ein Stapel wird stueckweise verkauft — sonst gaebe es keinen Weg,
// fuenf von fuenfzig Traenken loszuwerden.
let inst = ItemInstance { count: 1, ..self.player.inventory[index].clone() };
let preis = self.sell_price(&inst, reg);
let name = self.item_label(&inst, reg);
// Ueber den Index, nicht ueber `take`: Zwei Traenke mit verschiedenen
// Affixen teilen sich die `def`, und `take` naehme den erstbesten.
let slot = &mut self.player.inventory[index];
slot.count -= 1;
if slot.count == 0 {
self.player.inventory.remove(index);
}
self.give(ItemInstance::stack("core:gold", preis), reg);
// Der Haendler hat kein Gold-Konto: Die Spanne, nicht ein Vorrat,
// begrenzt den Handel.
self.log(format!("{name} verkauft fuer {preis} Gold"));
}
fn use_item(&mut self, index: usize, reg: &Registries) {
let inst = self.player.inventory[index].clone();
let Some(def) = reg.items.get(&inst.def) else { return };
@@ -724,6 +877,49 @@ impl Game {
}
}
/// Legt einen Stapel ins Inventar und **verschmilzt ihn**, wenn die
/// Stapelregel aus Ticket 07 hält: `stackable` und gleiche `def` und
/// gleiche `affixes`.
pub fn give(&mut self, inst: ItemInstance, reg: &Registries) {
let stapelbar = reg.items.get(&inst.def).is_some_and(|d| d.stackable);
if stapelbar
&& let Some(vorhanden) =
self.player.inventory.iter_mut().find(|i| i.stacks_with(&inst))
{
vorhanden.count += inst.count;
return;
}
self.player.inventory.push(inst);
}
/// Nimmt `menge` Stueck eines Gegenstands aus dem Inventar. Gibt zurueck,
/// ob genug da war — Gold ist ein gewoehnlicher Gegenstand (Ticket 09),
/// also laeuft Bezahlen hier durch.
pub fn take(&mut self, def: &str, menge: u32) -> bool {
if self.count_of(def) < menge {
return false;
}
let mut rest = menge;
self.player.inventory.retain_mut(|i| {
if rest == 0 || i.def != def {
return true;
}
let weg = rest.min(i.count);
i.count -= weg;
rest -= weg;
i.count > 0
});
true
}
pub fn count_of(&self, def: &str) -> u32 {
self.player.inventory.iter().filter(|i| i.def == def).map(|i| i.count).sum()
}
pub fn gold(&self) -> u32 {
self.count_of("core:gold")
}
/// Der Anzeigename eines Exemplars: Affixe werden vorangestellt —
/// „Flammendes Eisenschwert" (Ticket 07).
pub fn item_label(&self, inst: &ItemInstance, reg: &Registries) -> String {
@@ -734,7 +930,8 @@ impl Game {
.filter_map(|a| reg.affixes.get(a).map(|d| d.name.as_str()))
.collect();
teile.push(base);
teile.join(" ")
let name = teile.join(" ");
if inst.count > 1 { format!("{name} ×{}", inst.count) } else { name }
}
/// Preis eines Exemplars: `base_price × Π price_factor` der Affixe
@@ -1275,4 +1472,91 @@ mod tests {
assert!(matches!(game.execute(Command::Wait, &reg), Outcome::TurnPassed));
assert_eq!(game.turn, 1);
}
/// Ticket 07 beschrieb die Stapelregel, aber niemand hatte sie umgesetzt —
/// der Beutezieher schob fuer "50-120 Gold" bis zu 120 Eintraege ins
/// Inventar. Verschiedene Affixe stapeln bewusst **nicht**.
#[test]
fn gleiche_gegenstaende_stapeln_verschiedene_affixe_nicht() {
let (mut game, reg) = zwergenkrieger();
game.give(ItemInstance::stack("core:gold", 30), &reg);
game.give(ItemInstance::stack("core:gold", 12), &reg);
assert_eq!(game.gold(), 42);
assert_eq!(game.player.inventory.len(), 1, "Gold muss ein Stapel sein");
let mut scharf = ItemInstance::new("core:health_potion");
scharf.affixes = vec![String::from("core:sharp")];
game.give(ItemInstance::new("core:health_potion"), &reg);
game.give(scharf, &reg);
assert_eq!(
game.player.inventory.iter().filter(|i| i.def == "core:health_potion").count(),
2,
"verschiedene Affixe duerfen nicht stapeln"
);
assert!(game.take("core:gold", 40));
assert_eq!(game.gold(), 2);
assert!(!game.take("core:gold", 5), "zu wenig muss fehlschlagen");
assert_eq!(game.gold(), 2, "der Fehlschlag darf nichts abziehen");
}
/// Die **Spanne** ist der ganze Grund, warum der Haendler kein Goldkonto
/// braucht: Kaufen und sofort zurueckverkaufen muss Verlust machen.
#[test]
fn handel_kostet_die_spanne() {
let (game, reg) = zwergenkrieger();
let schwert = ItemInstance::new("core:iron_sword");
let kauf = game.buy_price(&schwert, &reg);
let verkauf = game.sell_price(&schwert, &reg);
assert!(verkauf < kauf, "Ankauf {verkauf} muss unter Verkauf {kauf} liegen");
}
/// Der ganze Weg: ansprechen, Laden oeffnen, kaufen. Prueft nebenbei, dass
/// der Warenkorb aus der Erzeugung stammt und nicht beim Oeffnen entsteht.
#[test]
fn kaufen_zieht_gold_ab_und_leert_das_regal() {
let (mut game, reg) = zwergenkrieger();
// Den Schmied auf irgendeiner Etage suchen — welches Prefab wo landet,
// entscheidet der Wurf.
let mut gefunden = None;
for _ in 0..40 {
if let Some((r, pos)) = game
.floor
.rooms
.iter()
.enumerate()
.find_map(|(r, room)| room.npcs.first().map(|n| (r, n.pos)))
{
gefunden = Some((r, pos));
break;
}
let def = reg.floor_number(1).expect("Etage 1");
game.floor = generate::generate(def, &reg, &mut game.rng);
}
let (raum, pos) = gefunden.expect("mindestens ein Marktraum in 40 Etagen");
game.player_pos = pos;
game.floor.player_room = Some(raum);
assert!(matches!(game.execute(Command::Talk, &reg), Outcome::TurnPassed));
assert_eq!(game.talking_to, Some(0));
let laden = game.dialog_options(&reg);
assert!(laden.len() >= 3, "die bedingte Option faellt raus, drei bleiben");
assert!(matches!(game.execute(Command::Choose(0), &reg), Outcome::TurnPassed));
assert!(game.open_shop, "die erste Option oeffnet den Laden");
let ware = game.shop_stock().expect("Warenkorb").to_vec();
assert!(!ware.is_empty(), "der Korb wird bei der Erzeugung gezogen");
// Ohne Gold ist jeder Kauf eine Abweisung, mit Gold geht er durch.
assert!(matches!(game.execute(Command::Buy(0), &reg), Outcome::Rejected(_)));
let preis = game.buy_price(&ware[0], &reg);
game.give(ItemInstance::stack("core:gold", preis), &reg);
assert!(matches!(game.execute(Command::Buy(0), &reg), Outcome::TurnPassed));
assert_eq!(game.gold(), 0, "der Preis muss abgezogen sein");
assert_eq!(game.shop_stock().expect("Korb").len(), ware.len() - 1);
assert!(game.player.inventory.iter().any(|i| i.def == ware[0].def));
}
}