feat: m4 teil 1 — beute, inventar und affixe

- data/: [[loot_tables]] und [[affixes]] als Registries; LootEntry mit
  guaranteed, nothing, table-in-table, count und affixes
- game/loot.rs: EINE Ziehmaschinerie fuer Gegenstaende und Affixe,
  Tiefendeckel gegen wechselseitigen Bezug
- PickUp zieht erst beim Aufheben (Ticket 16) — moeglich nur, weil alle
  Haufen dasselbe Symbol zeigen
- UseItem/Equip/Unequip/Drop, Inventar-Screen als Registry-Schleife
- Affixe im Attributpfad, in der Resistenz und in der Trigger-Suche;
  item_label stellt Affixnamen voran, item_price multipliziert Faktoren
- Room haelt eine loot_table (nicht je Haufen eine), Prefab-loot
  ueberschreibt die Etagentabelle

53 Tests. Offen aus M4: Handel.
This commit is contained in:
2026-08-12 17:56:28 +02:00
parent cdcabcce21
commit 197f62ce33
16 changed files with 849 additions and 19 deletions

View File

@@ -9,6 +9,7 @@ pub mod creature;
pub mod event;
pub mod floor;
pub mod generate;
pub mod loot;
pub mod rng;
pub use action::{Action, ActiveModifier, DamageType, StateField, Target};
@@ -111,12 +112,17 @@ impl Game {
.filter_map(|id| reg.options.get(id))
.flat_map(|o| o.modifiers.iter());
// Gegenstands- **und** Affix-Modifikatoren des Exemplars. Ein Affix ist
// strukturell dasselbe wie eine Charakteroption — eine benannte
// Modifikatorliste (Ticket 07).
let from_equipment = c.equipment.values().flat_map(|inst| {
reg.items
.get(&inst.def)
.map(|i| i.modifiers.as_slice())
.unwrap_or(&[])
let item = reg.items.get(&inst.def).map(|i| i.modifiers.as_slice()).unwrap_or(&[]);
let affixes = inst
.affixes
.iter()
.filter_map(|a| reg.affixes.get(a))
.flat_map(|a| a.modifiers.iter());
item.iter().chain(affixes)
});
let mut value = attributes::stack(c.base.get(attr), from_options.chain(from_equipment), attr);
@@ -131,8 +137,8 @@ impl Game {
.fold((0.0, 0.0), |(f, p), m| (f + m.flat, p + m.percent));
value = (value + flat) * (1.0 + percent / 100.0);
// TODO(M4): Affix-Modifikatoren des Exemplars und `_against` fuer
// bedingte Modifikatoren („+5 gegen Untote", Ticket 08).
// TODO(M5): `_against` fuer bedingte Modifikatoren („+5 gegen Untote",
// Ticket 08) — der Parameter steht bereits in der Signatur.
value
}
@@ -151,7 +157,13 @@ impl Game {
.filter_map(|id| reg.options.get(id))
.flat_map(|o| o.modifiers.iter());
let from_equipment = c.equipment.values().flat_map(|inst| {
reg.items.get(&inst.def).map(|i| i.modifiers.as_slice()).unwrap_or(&[]).iter()
let item = reg.items.get(&inst.def).map(|i| i.modifiers.as_slice()).unwrap_or(&[]);
let affixes = inst
.affixes
.iter()
.filter_map(|a| reg.affixes.get(a))
.flat_map(|a| a.modifiers.iter());
item.iter().chain(affixes)
});
let flat: f32 = from_options
@@ -243,8 +255,11 @@ impl Game {
if let Some(item) = reg.items.get(&inst.def) {
out.extend(item.triggers.iter());
}
// TODO(M4): Affix-Trigger des Exemplars, sobald die Affix-Registry
// steht — dieselben zwei Zeilen.
for affix in &inst.affixes {
if let Some(def) = reg.affixes.get(affix) {
out.extend(def.triggers.iter());
}
}
}
out.retain(|t| t.on == kind);
out
@@ -517,6 +532,30 @@ impl Game {
Command::UseAbility { ability, .. } => {
self.ability_block(CreatureRef::Player, ability, reg)
}
Command::PickUp if in_combat => Some("Nicht im Kampf"),
Command::PickUp => {
let (x, y) = self.player_pos;
let hier = self
.floor
.player_room
.is_some_and(|i| self.floor.rooms[i].loot.contains(&(x, y)));
(!hier).then_some("Hier liegt nichts")
}
Command::UseItem(i) | Command::Drop(i) => {
(*i >= self.player.inventory.len()).then_some("Kein solcher Gegenstand")
}
Command::Equip(i) => {
let Some(inst) = self.player.inventory.get(*i) else {
return Some("Kein solcher Gegenstand");
};
match reg.items.get(&inst.def).and_then(|d| d.slot.as_ref()) {
Some(_) => None,
None => Some("Das kann man nicht anlegen"),
}
}
Command::Unequip(slot) => {
(!self.player.equipment.contains_key(slot)).then_some("Dort ist nichts")
}
Command::Wait => None,
}
}
@@ -530,8 +569,13 @@ impl Game {
pub fn abilities_of(&self, who: CreatureRef, reg: &Registries) -> Vec<String> {
let mut out = vec![String::from("core:attack"), String::from("core:flee")];
out.extend(self.creature(who).abilities.iter().cloned());
// TODO(M4): Faehigkeiten aus Charakteroptionen und Ausruestung — beide
// Registries brauchen dafuer erst ein `abilities`-Feld.
for inst in self.creature(who).equipment.values() {
if let Some(item) = reg.items.get(&inst.def) {
out.extend(item.abilities.iter().cloned());
}
}
// TODO(M5): Faehigkeiten aus Charakteroptionen — die Registry braucht
// dafuer erst ein `abilities`-Feld (Ticket 02).
out.retain(|id| reg.abilities.contains_key(id));
out.dedup();
out
@@ -598,9 +642,114 @@ impl Game {
Command::UseAbility { ability, target } => {
self.use_ability(CreatureRef::Player, ability, *target, reg)
}
Command::PickUp => self.pick_up(reg),
Command::UseItem(i) => self.use_item(*i, reg),
Command::Equip(i) => self.equip(*i, reg),
Command::Unequip(slot) => self.unequip(&slot.clone()),
Command::Drop(i) => {
let inst = self.player.inventory.remove(*i);
let name = self.item_label(&inst, reg);
// Fallen gelassenes wird nicht zu einem neuen Haufen: Ein Haufen
// traegt eine Tabellen-ID, keinen Gegenstand (Ticket 16).
// TODO(M5): Wenn Wegwerfen zurueckholbar sein soll, braucht ein
// Raum eine zweite Liste fuer konkrete Instanzen.
self.log(format!("{name} weggeworfen"));
}
}
}
/// Ticket 16: Der Inhalt eines Haufens wird **erst beim Aufheben** gezogen.
/// Ein Gluecksring wirkt damit auch auf Beute, die auf dieser Etage schon
/// liegt — bei Ziehung zur Erzeugungszeit waere der Wurf laengst gefallen.
fn pick_up(&mut self, reg: &Registries) {
let (x, y) = self.player_pos;
let Some(room) = self.floor.player_room else { return };
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);
if beute.is_empty() {
// Faellt der Wurf auf eine Niete, verschwindet der Haufen mit einer
// Meldung. Kein Neuwuerfeln — das haengt sich bei einer reinen
// Nieten-Tabelle auf (Ticket 16).
self.log(String::from("Nichts Brauchbares."));
return;
}
let namen: Vec<String> = beute.iter().map(|i| self.item_label(i, reg)).collect();
self.player.inventory.extend(beute);
self.log(format!("Aufgehoben: {}", namen.join(", ")));
self.events.push_back(Event::new(EventKind::ItemPickedUp, CreatureRef::Player));
}
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 };
let name = self.item_label(&inst, reg);
let target = if self.enemies.is_empty() {
CreatureRef::Player
} else {
CreatureRef::Enemy(0)
};
for action in &def.actions {
self.apply(action, CreatureRef::Player, target, reg);
}
if def.consumed_on_use {
self.player.inventory.remove(index);
}
self.log(format!("{name} benutzt"));
self.events.push_back(Event::new(EventKind::ItemUsed, CreatureRef::Player));
}
fn equip(&mut self, index: usize, reg: &Registries) {
let inst = self.player.inventory[index].clone();
let Some(slot) = reg.items.get(&inst.def).and_then(|d| d.slot.clone()) else { return };
// Was im Slot lag, wandert zurueck ins Inventar — nicht ins Nichts.
if let Some(alt) = self.player.equipment.remove(&slot) {
self.player.inventory.push(alt);
}
self.player.inventory.remove(index);
let name = self.item_label(&inst, reg);
self.player.equipment.insert(slot, inst);
self.log(format!("{name} angelegt"));
}
fn unequip(&mut self, slot: &str) {
if let Some(inst) = self.player.equipment.remove(slot) {
self.player.inventory.push(inst);
self.log(String::from("Abgelegt"));
}
}
/// Der Anzeigename eines Exemplars: Affixe werden vorangestellt —
/// „Flammendes Eisenschwert" (Ticket 07).
pub fn item_label(&self, inst: &ItemInstance, reg: &Registries) -> String {
let base = reg.items.get(&inst.def).map(|d| d.name.as_str()).unwrap_or(&inst.def);
let mut teile: Vec<&str> = inst
.affixes
.iter()
.filter_map(|a| reg.affixes.get(a).map(|d| d.name.as_str()))
.collect();
teile.push(base);
teile.join(" ")
}
/// Preis eines Exemplars: `base_price × Π price_factor` der Affixe
/// (Ticket 07) — eine Multiplikation, keine Rechnung mit Sonderfaellen.
pub fn item_price(&self, inst: &ItemInstance, reg: &Registries) -> u32 {
let base = reg.items.get(&inst.def).map_or(0, |d| d.base_price) as f32;
let faktor: f32 = inst
.affixes
.iter()
.filter_map(|a| reg.affixes.get(a))
.map(|a| a.price_factor)
.product();
(base * faktor).round() as u32
}
fn use_ability(
&mut self,
who: CreatureRef,
@@ -901,6 +1050,85 @@ mod tests {
assert_eq!(game.over, Some(RunOutcome::Died), "irgendwann trifft ein Gegner");
}
/// Ticket 16: Der Inhalt eines Haufens wird **erst beim Aufheben** gezogen.
#[test]
fn aufheben_zieht_aus_der_tabelle() {
let (mut game, reg) = zwergenkrieger();
// Einen Raum mit Beute finden und sich draufstellen.
let i = (0..game.floor.rooms.len())
.find(|i| !game.floor.rooms[*i].loot.is_empty())
.expect("mindestens ein Raum hat Beuteplaetze");
game.floor.rooms[i].enemies.clear(); // kein Kampf im Weg
let pos = game.floor.rooms[i].loot[0];
game.player_pos = pos;
game.floor.player_room = Some(i);
let vorher = game.player.inventory.len();
assert!(matches!(game.execute(Command::PickUp, &reg), Outcome::TurnPassed));
// Der Haufen ist weg — ob etwas drin war, entscheidet die Tabelle
// (Nieten sind gewoehnliche Eintraege).
assert!(!game.floor.rooms[i].loot.contains(&pos));
assert!(game.player.inventory.len() >= vorher);
}
#[test]
fn aufheben_wo_nichts_liegt_kostet_keinen_zug() {
let (mut game, reg) = zwergenkrieger();
game.player_pos = game.floor.entrance();
game.floor.player_room = game.floor.room_at(game.player_pos.0, game.player_pos.1);
assert!(matches!(game.execute(Command::PickUp, &reg), Outcome::Rejected(_)));
assert_eq!(game.turn, 0);
}
/// Anlegen tauscht: Was im Slot lag, wandert **zurueck ins Inventar**, nicht
/// ins Nichts.
#[test]
fn anlegen_tauscht_statt_zu_verschlucken() {
let (mut game, reg) = zwergenkrieger();
game.player.inventory.push(ItemInstance::new("core:bone_blade"));
let index = game.player.inventory.len() - 1;
let alt = game.player.equipment.get("core:hand").map(|i| i.def.clone());
game.execute(Command::Equip(index), &reg);
assert_eq!(game.player.equipment["core:hand"].def, "core:bone_blade");
if let Some(alt) = alt {
assert!(
game.player.inventory.iter().any(|i| i.def == alt),
"das alte Schwert muss zurueck ins Inventar"
);
}
}
/// Ticket 07: Affixe sind Registry-Eintraege, und ihre Modifikatoren
/// gehoeren in denselben Attributpfad wie die des Gegenstands.
#[test]
fn affixe_wirken_auf_attribute_und_preis() {
let (mut game, reg) = zwergenkrieger();
let p = CreatureRef::Player;
let ohne = game.attribute(p, Attribute::Damage, None, &reg);
let ohne_preis =
game.item_price(&ItemInstance::new("core:iron_sword"), &reg);
let mut flammend = ItemInstance::new("core:iron_sword");
flammend.affixes.push(String::from("core:flaming"));
let mit_preis = game.item_price(&flammend, &reg);
game.player.equipment.insert(String::from("core:hand"), flammend.clone());
let mit = game.attribute(p, Attribute::Damage, None, &reg);
assert!(mit > ohne, "der Affix muss Schaden bringen: {mit} > {ohne}");
// Preis = base_price × Π price_factor (Ticket 07).
assert!(mit_preis > ohne_preis, "{mit_preis} > {ohne_preis}");
// Und der Anzeigename stellt den Affix voran.
assert_eq!(game.item_label(&flammend, &reg), "Flammendes Eisenschwert");
}
/// Die Trefferformel aus Ticket 14 war beim ersten Anlauf **toter Code** —
/// jeder Angriff traf. Nur der Compiler hat es gemeldet („`hit_chance` is
/// never used"). Dieser Test haelt sie am Leben.