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:
290
src/game/mod.rs
290
src/game/mod.rs
@@ -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, ®), 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), ®);
|
||||
game.give(ItemInstance::stack("core:gold", 12), ®);
|
||||
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"), ®);
|
||||
game.give(scharf, ®);
|
||||
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, ®);
|
||||
let verkauf = game.sell_price(&schwert, ®);
|
||||
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, ®, &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, ®), Outcome::TurnPassed));
|
||||
assert_eq!(game.talking_to, Some(0));
|
||||
|
||||
let laden = game.dialog_options(®);
|
||||
assert!(laden.len() >= 3, "die bedingte Option faellt raus, drei bleiben");
|
||||
assert!(matches!(game.execute(Command::Choose(0), ®), 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), ®), Outcome::Rejected(_)));
|
||||
let preis = game.buy_price(&ware[0], ®);
|
||||
game.give(ItemInstance::stack("core:gold", preis), ®);
|
||||
assert!(matches!(game.execute(Command::Buy(0), ®), 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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user