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:
250
src/game/mod.rs
250
src/game/mod.rs
@@ -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, ®), 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, ®), 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), ®);
|
||||
|
||||
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, ®);
|
||||
let ohne_preis =
|
||||
game.item_price(&ItemInstance::new("core:iron_sword"), ®);
|
||||
|
||||
let mut flammend = ItemInstance::new("core:iron_sword");
|
||||
flammend.affixes.push(String::from("core:flaming"));
|
||||
let mit_preis = game.item_price(&flammend, ®);
|
||||
game.player.equipment.insert(String::from("core:hand"), flammend.clone());
|
||||
|
||||
let mit = game.attribute(p, Attribute::Damage, None, ®);
|
||||
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, ®), "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.
|
||||
|
||||
Reference in New Issue
Block a user