fix(inventar): ein Stapel wird stueckweise verbraucht

use_item und equip entfernten den ganzen Eintrag — ein Schluck aus zwei
Heiltraenken verbrauchte beide. `count` kam in M4 dazu, die drei aelteren
Verbraucher wurden nicht mitgezogen.

Ein Griff fuer alle: take_at(index) nimmt ein Stueck und raeumt den
Eintrag ab, wenn er leer ist. unequip laeuft jetzt durch give, damit ein
abgelegter Stapel wieder verschmilzt.

Dabei aufgefallen: Der Heiltrank hatte nie `actions` — seit M1
verbrauchbar, ohne zu wirken.
This commit is contained in:
2026-08-12 20:54:07 +02:00
parent 6014471c33
commit 036f60d463
3 changed files with 73 additions and 18 deletions

View File

@@ -217,3 +217,24 @@ bleiben Indizes stabil.
`Stats` zerfällt in `Attributes` (Katalog aus 01) und Zustandsfelder — heute stehen `max_health` und `Stats` zerfällt in `Attributes` (Katalog aus 01) und Zustandsfelder — heute stehen `max_health` und
`current_health` nebeneinander in derselben Struktur. `Player` wird zu `Creature`. Neben `Item` als `current_health` nebeneinander in derselben Struktur. `Player` wird zu `Creature`. Neben `Item` als
Definition entsteht `ItemInstance` als Laufzeitform. Definition entsteht `ItemInstance` als Laufzeitform.
### Befund aus dem Spielen — der Stapel hatte keine Verbraucher
Ticket 07 beschreibt die Stapelregel, M4 hat sie gebaut: `ItemInstance.count`, `give` verschmilzt,
`take` zieht ab. Gebaut wurde damit die **Erzeugungsseite** — Beute, Kauf, Verkauf. Die drei älteren
Verbraucher blieben, wie sie waren, und die nahmen alle denselben Weg: `inventory.remove(index)`.
Im Spiel sichtbar an zwei Heiltränken, von denen ein Schluck beide verbrauchte. Derselbe Fehler steckte
in `equip` (legt den ganzen Stapel an) und dieselbe Lücke in `unequip` (schob am Verschmelzen vorbei
zurück ins Inventar).
Entschieden: **ein** Griff für alle Verbraucher — `take_at(index)` nimmt ein Stück aus dem Eintrag und
räumt ihn ab, wenn er leer ist. Über den Index und nicht über die `def`, weil zwei Tränke mit
verschiedenen Affixen sich die `def` teilen. `unequip` läuft jetzt durch `give`.
Die Lehre ist nicht „Stapel sind schwierig", sondern: Ein Feld, das die Bedeutung eines Eintrags
ändert, macht **jeden** bestehenden Leser zum Kandidaten. `count` einzuführen war ein Vierzeiler; die
Verbraucher nachzuziehen war die eigentliche Arbeit, und sie wurde beim ersten Mal vergessen.
Nebenbei aufgefallen: Der Heiltrank hatte nie `actions` — er war seit M1 verbrauchbar, ohne zu wirken.
Kein Test hat es gemerkt, weil kein Test ihn getrunken hat.

View File

@@ -28,6 +28,7 @@ stackable = true
consumed_on_use = true consumed_on_use = true
symbol = "!" symbol = "!"
color = "red" color = "red"
actions = [ { action = "change_attribute", attribute = "health", amount = 25, target = "self" } ]
[[items]] [[items]]
id = "gold" id = "gold"

View File

@@ -741,7 +741,7 @@ impl Game {
Command::PickUp => self.pick_up(reg), Command::PickUp => self.pick_up(reg),
Command::UseItem(i) => self.use_item(*i, reg), Command::UseItem(i) => self.use_item(*i, reg),
Command::Equip(i) => self.equip(*i, reg), Command::Equip(i) => self.equip(*i, reg),
Command::Unequip(slot) => self.unequip(&slot.clone()), Command::Unequip(slot) => self.unequip(&slot.clone(), reg),
Command::Talk => self.talk(reg), Command::Talk => self.talk(reg),
Command::Choose(i) => self.choose(*i, reg), Command::Choose(i) => self.choose(*i, reg),
Command::Buy(i) => self.buy(*i, reg), Command::Buy(i) => self.buy(*i, reg),
@@ -828,25 +828,33 @@ impl Game {
fn sell(&mut self, index: usize, reg: &Registries) { fn sell(&mut self, index: usize, reg: &Registries) {
// Ein Stapel wird stueckweise verkauft — sonst gaebe es keinen Weg, // Ein Stapel wird stueckweise verkauft — sonst gaebe es keinen Weg,
// fuenf von fuenfzig Traenken loszuwerden. // fuenf von fuenfzig Traenken loszuwerden.
let inst = ItemInstance { count: 1, ..self.player.inventory[index].clone() }; let inst = self.take_at(index);
let preis = self.sell_price(&inst, reg); let preis = self.sell_price(&inst, reg);
let name = self.item_label(&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); self.give(ItemInstance::stack("core:gold", preis), reg);
// Der Haendler hat kein Gold-Konto: Die Spanne, nicht ein Vorrat, // Der Haendler hat kein Gold-Konto: Die Spanne, nicht ein Vorrat,
// begrenzt den Handel. // begrenzt den Handel.
self.log(format!("{name} verkauft fuer {preis} Gold")); self.log(format!("{name} verkauft fuer {preis} Gold"));
} }
/// 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.
fn take_at(&mut self, index: usize) -> ItemInstance {
let slot = &mut self.player.inventory[index];
slot.count -= 1;
let leer = slot.count == 0;
let einzeln = ItemInstance { count: 1, ..slot.clone() };
if leer {
self.player.inventory.remove(index);
}
einzeln
}
fn use_item(&mut self, index: usize, reg: &Registries) { fn use_item(&mut self, index: usize, reg: &Registries) {
let inst = self.player.inventory[index].clone(); // Ein Stapel wird stueckweise benutzt — `count` wegzuwerfen hiesse,
// mit einem Trank alle zu trinken.
let inst = ItemInstance { count: 1, ..self.player.inventory[index].clone() };
let Some(def) = reg.items.get(&inst.def) else { return }; let Some(def) = reg.items.get(&inst.def) else { return };
let name = self.item_label(&inst, reg); let name = self.item_label(&inst, reg);
@@ -860,29 +868,30 @@ impl Game {
} }
if def.consumed_on_use { if def.consumed_on_use {
self.player.inventory.remove(index); self.take_at(index);
} }
self.log(format!("{name} benutzt")); self.log(format!("{name} benutzt"));
self.events.push_back(Event::new(EventKind::ItemUsed, CreatureRef::Player)); self.events.push_back(Event::new(EventKind::ItemUsed, CreatureRef::Player));
} }
fn equip(&mut self, index: usize, reg: &Registries) { fn equip(&mut self, index: usize, reg: &Registries) {
let inst = self.player.inventory[index].clone(); let def = self.player.inventory[index].def.clone();
let Some(slot) = reg.items.get(&inst.def).and_then(|d| d.slot.clone()) else { return }; let Some(slot) = reg.items.get(&def).and_then(|d| d.slot.clone()) else { return };
let inst = self.take_at(index);
// Was im Slot lag, wandert zurueck ins Inventar — nicht ins Nichts. // Was im Slot lag, wandert zurueck ins Inventar — nicht ins Nichts.
if let Some(alt) = self.player.equipment.remove(&slot) { if let Some(alt) = self.player.equipment.remove(&slot) {
self.player.inventory.push(alt); self.give(alt, reg);
} }
self.player.inventory.remove(index);
let name = self.item_label(&inst, reg); let name = self.item_label(&inst, reg);
self.player.equipment.insert(slot, inst); self.player.equipment.insert(slot, inst);
self.log(format!("{name} angelegt")); self.log(format!("{name} angelegt"));
} }
fn unequip(&mut self, slot: &str) { fn unequip(&mut self, slot: &str, reg: &Registries) {
if let Some(inst) = self.player.equipment.remove(slot) { if let Some(inst) = self.player.equipment.remove(slot) {
self.player.inventory.push(inst); // Ueber `give`, damit ein abgelegter Stapel wieder verschmilzt.
self.give(inst, reg);
self.log(String::from("Abgelegt")); self.log(String::from("Abgelegt"));
} }
} }
@@ -1655,4 +1664,28 @@ mod tests {
assert_eq!(game.player.health, vorher, "ein gestoerter Gegner richtet nichts an"); assert_eq!(game.player.health, vorher, "ein gestoerter Gegner richtet nichts an");
assert!(game.enemies[0].intent.is_some(), "und er sagt fuer den naechsten Zug neu an"); assert!(game.enemies[0].intent.is_some(), "und er sagt fuer den naechsten Zug neu an");
} }
/// Gefunden im Spiel: Zwei Heiltraenke lagen als **ein** Stapel im
/// Inventar, und `use_item` entfernte den Eintrag — ein Schluck, beide weg.
/// Der Stapel war in M4 dazugekommen, seine Verbraucher nicht mitgezogen.
#[test]
fn ein_stapel_wird_stueckweise_verbraucht() {
let (mut game, reg) = zwergenkrieger();
game.give(ItemInstance::stack("core:health_potion", 2), &reg);
let platz = game.player.inventory.len() - 1;
game.player.health = 10.0;
assert!(matches!(game.execute(Command::UseItem(platz), &reg), Outcome::TurnPassed));
assert_eq!(game.count_of("core:health_potion"), 1, "nur einer darf weg sein");
assert!(game.player.health > 10.0, "und er muss auch wirken");
// Der zweite raeumt den Eintrag ab, statt einen Stapel der Groesse 0
// stehen zu lassen.
assert!(matches!(game.execute(Command::UseItem(platz), &reg), Outcome::TurnPassed));
assert_eq!(game.count_of("core:health_potion"), 0);
assert!(
!game.player.inventory.iter().any(|i| i.def == "core:health_potion"),
"der leere Eintrag muss verschwinden"
);
}
} }