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

46
mods/core/affixes.toml Normal file
View File

@@ -0,0 +1,46 @@
# Affixe (Ticket 07/16): eine benannte Modifikatorliste, die an einem EINZELNEN
# Exemplar haengt. Strukturell dasselbe wie eine Charakteroption — der
# Unterschied ist, woran sie haengt.
#
# Abstufungen sind EIGENE EINTRAEGE, keine Zahl am Exemplar: Eine Staerke je
# Exemplar haette Save-Format (ID-Liste), Preisformel und Stapelregel auf einmal
# gekostet. Mit Stufen kann der Modder seine Abstufungen benennen.
[[affixes]]
id = "sharp"
name = "Scharfes"
price_factor = 1.3
modifiers = [ { attribute = "damage", flat = 2 } ]
[[affixes]]
id = "flaming"
name = "Flammendes"
price_factor = 1.6
modifiers = [ { attribute = "damage", flat = 3 } ]
triggers = [
{ on = "attack_hit", chance = 0.25,
actions = [ { action = "damage", amount = 4, damage_type = "fire", target = "target" } ] },
]
[[affixes]]
id = "greater_flaming"
name = "Stark flammendes"
price_factor = 2.4
modifiers = [ { attribute = "damage", flat = 6 } ]
triggers = [
{ on = "attack_hit", chance = 0.4,
actions = [ { action = "damage", amount = 9, damage_type = "fire", target = "target" } ] },
]
[[affixes]]
id = "sturdy"
name = "Robuste"
price_factor = 1.4
modifiers = [ { attribute = "max_health", flat = 12 } ]
[[affixes]]
id = "warding"
name = "Schirmende"
price_factor = 1.8
modifiers = [ { attribute = "resistance", damage_type = "fire", flat = 20 },
{ attribute = "resistance", damage_type = "ice", flat = 20 } ]

View File

@@ -16,6 +16,7 @@ rooms = 7
required = ["core:entrance", "core:stairs", "core:merchant"]
prefabs = [ { tag = "core:normal", weight = 10 } ]
creatures = [ { id = "core:rat", weight = 10 } ]
loot = "core:floor_1_table"
[[floors]]
id = "floor_2"
@@ -26,6 +27,7 @@ required = ["core:entrance", "core:stairs", "core:merchant"]
prefabs = [ { tag = "core:normal", weight = 10 },
{ tag = "core:treasure", weight = 2 } ]
creatures = [ { id = "core:rat", weight = 6 }, { id = "core:skeleton", weight = 6 } ]
loot = "core:floor_1_table"
[[floors]]
id = "floor_3"
@@ -36,6 +38,7 @@ required = ["core:entrance", "core:stairs", "core:merchant"]
prefabs = [ { tag = "core:normal", weight = 10 },
{ tag = "core:treasure", weight = 3 } ]
creatures = [ { id = "core:rat", weight = 3 }, { id = "core:skeleton", weight = 8 }, { id = "core:ghoul", weight = 3 } ]
loot = "core:floor_3_table"
[[floors]]
id = "floor_4"
@@ -46,6 +49,7 @@ required = ["core:entrance", "core:stairs", "core:merchant"]
prefabs = [ { tag = "core:normal", weight = 10 },
{ tag = "core:treasure", weight = 3 } ]
creatures = [ { id = "core:skeleton", weight = 6 }, { id = "core:ghoul", weight = 8 } ]
loot = "core:floor_3_table"
[[floors]]
id = "floor_5"
@@ -56,3 +60,4 @@ required = ["core:entrance", "core:stairs", "core:merchant"]
prefabs = [ { tag = "core:normal", weight = 10 },
{ tag = "core:treasure", weight = 4 } ]
creatures = [ { id = "core:ghoul", weight = 8 }, { id = "core:bone_lord", weight = 2 } ]
loot = "core:floor_5_table"

View File

@@ -53,3 +53,13 @@ triggers = [
when = { damage_type = "fire" },
actions = [ { action = "damage", amount = 3, damage_type = "fire", target = "target" } ] },
]
[[items]]
id = "bone_blade"
name = "Knochenklinge"
base_price = 210
slot = "core:hand"
symbol = "/"
color = "magenta"
modifiers = [ { attribute = "damage", flat = 11 },
{ attribute = "critical_chance", flat = 8 } ]

88
mods/core/loot.toml Normal file
View File

@@ -0,0 +1,88 @@
# Beutetabellen (Ticket 16). EINE Maschinerie fuer Gegenstaende und Affixe:
# dieselben Gewichte, dieselbe Tabelle-in-Tabelle, dieselben Nieten.
#
# Gewichte statt Prozent — Prozente muessten sich auf 100 summieren, und ein
# Eintrag, den ein Mod hinzufuegt, macht die Summe kaputt.
#
# Der MOD-EINHAENGEPUNKT ist Tabelle-in-Tabelle: Ein Mod legt seine eigene
# Tabelle an, bindet `core:floor_3_table` als Untertabelle ein und haengt die
# Etage um. Zwei gewoehnliche Ueberschreibungen, kein zweiter Mechanismus.
# --- Affixe ---------------------------------------------------------------
# Abstufungen sind EIGENE Eintraege mit kleinerem Gewicht, keine Zahl am
# Exemplar. Deshalb erhoeht ein zusaetzlicher Zug auch die Chance auf die
# staerkere Stufe.
[[loot_tables]]
id = "weapon_affixes"
rolls = "0-1"
entries = [
{ affix = "core:sharp", weight = 6 },
{ affix = "core:flaming", weight = 3 },
{ affix = "core:greater_flaming", weight = 1 },
{ nothing = true, weight = 14 },
]
[[loot_tables]]
id = "armor_affixes"
rolls = "0-1"
entries = [
{ affix = "core:sturdy", weight = 5 },
{ affix = "core:warding", weight = 2 },
{ nothing = true, weight = 16 },
]
# --- Etagen ---------------------------------------------------------------
[[loot_tables]]
id = "floor_1_table"
rolls = "1-2"
entries = [
{ item = "core:health_potion", weight = 12, count = "1-2" },
{ item = "core:gold", weight = 10, count = "3-9" },
{ item = "core:iron_sword", weight = 2, affixes = "core:weapon_affixes" },
{ item = "core:leather_cap", weight = 2, affixes = "core:armor_affixes" },
{ nothing = true, weight = 8 },
]
[[loot_tables]]
id = "floor_3_table"
rolls = "1-3"
entries = [
{ item = "core:health_potion", weight = 10, count = "1-2" },
{ item = "core:gold", weight = 10, count = "6-18" },
{ item = "core:iron_sword", weight = 3, affixes = "core:weapon_affixes" },
{ item = "core:thorn_mail", weight = 1, affixes = "core:armor_affixes" },
{ table = "core:rare_table", weight = 2 },
{ nothing = true, weight = 6 },
]
[[loot_tables]]
id = "floor_5_table"
rolls = "2-3"
entries = [
{ item = "core:health_potion", weight = 8, count = "2-3" },
{ item = "core:gold", weight = 10, count = "15-40" },
{ item = "core:iron_sword", weight = 4, affixes = "core:weapon_affixes" },
{ item = "core:thorn_mail", weight = 3, affixes = "core:armor_affixes" },
{ table = "core:rare_table", weight = 4 },
]
[[loot_tables]]
id = "rare_table"
rolls = "1-1"
entries = [
{ item = "core:bone_blade", weight = 3, affixes = "core:weapon_affixes" },
{ item = "core:thorn_mail", weight = 4, affixes = "core:armor_affixes" },
]
# `guaranteed` nimmt an der Gewichtung nicht teil und kommt immer — mit
# Gewichten allein laesst sich "sicher" nicht ausdruecken.
[[loot_tables]]
id = "boss_table"
rolls = "1-2"
entries = [
{ item = "core:bone_blade", guaranteed = true, affixes = "core:weapon_affixes" },
{ item = "core:gold", weight = 10, count = "50-120" },
{ item = "core:health_potion", weight = 6, count = "2-4" },
]

View File

@@ -15,8 +15,8 @@ use serde::de::DeserializeOwned;
use toml::Spanned;
use super::registries::{
Ability, Category, CharacterOption, CreatureDef, FloorDef, Item, ModifierDef, Prefab,
Registries, Slot,
Ability, Affix, Category, CharacterOption, CreatureDef, FloorDef, Item, LootTable, ModifierDef,
Prefab, Registries, Slot,
};
/// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus
@@ -257,6 +257,10 @@ struct ModFile {
creatures: Vec<Spanned<toml::Value>>,
#[serde(default)]
modifiers: Vec<Spanned<toml::Value>>,
#[serde(default)]
affixes: Vec<Spanned<toml::Value>>,
#[serde(default)]
loot_tables: Vec<Spanned<toml::Value>>,
/// **Keine** Tabelle mit `[[...]]`, sondern ein einzelner Block, der
/// feldweise verschmilzt — die ausdrueckliche Ausnahme zu Ticket 03.
#[serde(default)]
@@ -294,6 +298,8 @@ impl_entry!(FloorDef);
impl_entry!(Ability);
impl_entry!(CreatureDef);
impl_entry!(ModifierDef);
impl_entry!(Affix);
impl_entry!(LootTable);
struct FileCtx<'a> {
module: &'a str,
@@ -502,6 +508,8 @@ pub fn load_mods() -> Result<(Registries, Vec<String>), LoadError> {
parse_entries(parsed.abilities, &ctx, &mut reg.abilities, &mut warnings);
parse_entries(parsed.creatures, &ctx, &mut reg.creatures, &mut warnings);
parse_entries(parsed.modifiers, &ctx, &mut reg.modifiers, &mut warnings);
parse_entries(parsed.affixes, &ctx, &mut reg.affixes, &mut warnings);
parse_entries(parsed.loot_tables, &ctx, &mut reg.loot_tables, &mut warnings);
// `[balance]` verschmilzt **feldweise** (Ticket 14): Ein Mod, der
// einen Knopf dreht, schreibt eine Zeile — und ein neuer Knopf in

View File

@@ -5,4 +5,6 @@ pub mod loader;
pub mod registries;
pub use loader::{load_mods, write_log};
pub use registries::{Balance, CreatureDef, FloorDef, Prefab, Registries};
pub use registries::{
Balance, CreatureDef, FloorDef, LootEntry, Prefab, Registries, parse_range,
};

View File

@@ -229,6 +229,90 @@ impl Default for Balance {
}
}
/// Eine benannte Modifikatorliste, die an einem **einzelnen Exemplar** haengt —
/// „Flammendes Eisenschwert" (Ticket 07).
///
/// Abstufungen sind **eigene Eintraege** mit kleinerem Gewicht (`core:flaming`
/// neben `core:greater_flaming`), keine Zahl am Exemplar: Eine Staerke je
/// Exemplar haette Save-Format, Preisformel und Stapelregel auf einmal
/// gekostet (Ticket 16).
#[derive(Debug, Clone, Deserialize)]
pub struct Affix {
pub id: String,
/// Wird dem Gegenstandsnamen vorangestellt: „Flammendes Eisenschwert".
pub name: String,
#[serde(default = "one_f32")]
pub price_factor: f32,
#[serde(default)]
pub modifiers: Vec<Modifier>,
#[serde(default)]
pub triggers: Vec<Trigger>,
#[serde(default)]
pub overrides: Option<String>,
}
fn one_f32() -> f32 {
1.0
}
/// Ein Eintrag einer Beutetabelle. Genau eine der vier Formen (Ticket 16).
#[derive(Debug, Clone, Deserialize)]
pub struct LootEntry {
#[serde(default = "one")]
pub weight: u32,
/// Wird **immer** ausgegeben und nimmt an der Gewichtung nicht teil — mit
/// Gewichten allein laesst sich „sicher" nicht ausdruecken, und Bosse
/// stehen in der Destination.
#[serde(default)]
pub guaranteed: bool,
#[serde(default)]
pub item: Option<String>,
/// Tabelle-in-Tabelle: liefert Seltenheitsstufen, ohne dass „Seltenheit"
/// ein Begriff werden muss. Zugleich der **Mod-Einhaengepunkt** — ein Mod
/// bindet `core:floor_3_table` als Untertabelle ein.
#[serde(default)]
pub table: Option<String>,
/// Dieselbe Maschinerie zieht Gegenstaende *und* Affixe.
#[serde(default)]
pub affix: Option<String>,
/// Niete. Ein gewoehnlicher Eintrag, kein eigenes `drop_chance`-Feld — die
/// Engine kennt eine Mechanik statt zweier.
#[serde(default)]
pub nothing: bool,
/// Stapelmenge, als Bereich `"1-2"`.
#[serde(default)]
pub count: Option<String>,
/// Affix-Tabelle fuer diesen Gegenstand.
#[serde(default)]
pub affixes: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LootTable {
pub id: String,
/// Wie viele Zuege, als Bereich `"1-3"`.
#[serde(default)]
pub rolls: Option<String>,
#[serde(default)]
pub entries: Vec<LootEntry>,
#[serde(default)]
pub overrides: Option<String>,
}
/// Bereichsangabe `"1-3"` oder `"2"`. Ein Zweizeiler statt einer Grammatik.
pub fn parse_range(spec: Option<&String>) -> (u32, u32) {
let Some(s) = spec else { return (1, 1) };
match s.split_once('-') {
Some((a, b)) => (a.trim().parse().unwrap_or(1), b.trim().parse().unwrap_or(1)),
None => {
let n = s.trim().parse().unwrap_or(1);
(n, n)
}
}
}
/// Ein von Hand gezeichneter Raum als ASCII-Block (Ticket 10).
#[derive(Debug, Clone, Deserialize)]
pub struct Prefab {
@@ -283,9 +367,11 @@ pub struct FloorDef {
/// als bei Prefabs, weil eine Etage ihre Gegner benennen koennen soll.
#[serde(default)]
pub creatures: Vec<WeightedId>,
/// Beutetabelle fuer die `l`-Plaetze (Ticket 16).
#[serde(default)]
pub loot: Option<String>,
#[serde(default)]
pub overrides: Option<String>,
// TODO(M4): `loot` — die Beutetabelle fuer die `l`-Plaetze (Ticket 16).
}
#[derive(Debug, Clone, Deserialize)]
@@ -309,9 +395,11 @@ pub struct Registries {
pub abilities: HashMap<String, Ability>,
pub creatures: HashMap<String, CreatureDef>,
pub modifiers: HashMap<String, ModifierDef>,
pub affixes: HashMap<String, Affix>,
pub loot_tables: HashMap<String, LootTable>,
/// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen.
pub balance: Balance,
// TODO(M4+): `affixes`, `quests`, `loot_tables`, `scripts`.
// TODO(M5+): `quests` (Ticket 08) und `scripts` (Ticket 13).
}
impl Registries {

View File

@@ -13,13 +13,20 @@ pub enum Direction {
/// Ticket 11 hat zwoelf Commands festgelegt; M1 braucht zwei davon.
///
// TODO(M3+): `UseAbility`, `UseItem`, `Equip`, `Unequip`, `Drop`, `PickUp`,
// `Talk`, `Buy`, `Sell` — und `AssignPoints` aus 15.
// TODO(M5+): `Talk`, `Buy`, `Sell` — und `AssignPoints` aus Ticket 15.
#[derive(Debug, Clone)]
pub enum Command {
Move(Direction),
Wait,
Descend,
/// Hebt den Haufen auf, auf dem der Held steht. Ticket 16: Der Inhalt wird
/// **erst hier** gezogen — moeglich nur, weil alle Haufen dasselbe Symbol
/// zeigen, die Anzeige also nichts verraet.
PickUp,
UseItem(usize),
Equip(usize),
Unequip(String),
Drop(usize),
/// Der normale Angriff ist **keine eigene Variante**, sondern
/// `UseAbility("core:attack")` — Faehigkeiten sind seit Ticket 02 eine
/// Registry, also gehoert er dorthin, und ein Mod kann ihn ersetzen.

View File

@@ -49,6 +49,11 @@ pub struct Room {
/// Die Gegner dieses Raums. Wandern beim Betreten nach `Game.enemies` —
/// **verschoben, nicht kopiert** (Ticket 10).
pub enemies: Vec<super::creature::Creature>,
/// Aus welcher Tabelle die Haufen dieses Raums ziehen — Prefab-`loot` oder,
/// wenn es fehlt, die Etagentabelle. **Eine** ID je Raum: Alle Haufen ziehen
/// ohnehin aus derselben, sie je Haufen zu wiederholen waere Redundanz
/// (Ticket 16).
pub loot_table: String,
/// Die `l`-Plaetze. Ticket 16: erst beim **Aufheben** aufgeloest, deshalb
/// stehen hier Positionen und keine Gegenstaende.
pub loot: Vec<(u16, u16)>,

View File

@@ -142,11 +142,22 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor {
})
.collect();
// Ein Prefab darf ueber sein optionales `loot`-Feld die Etagentabelle
// ersetzen — sonst ist ein Schatzkammer-Raum nur ein Raum mit mehr
// `l`-Zeichen (Ticket 16).
let loot_table = reg
.prefabs
.get(&p.prefab)
.and_then(|pf| pf.loot.clone())
.or_else(|| def.loot.clone())
.unwrap_or_default();
rooms.push(Room {
prefab: p.prefab.clone(),
rect: p.rect,
enemy_spawns,
enemies,
loot_table,
loot,
cleared: false,
});

207
src/game/loot.rs Normal file
View File

@@ -0,0 +1,207 @@
//! Beutetabellen ziehen — Ticket 16.
//!
//! **Eine** Maschinerie fuer Gegenstaende *und* Affixe: dieselben Gewichte,
//! dieselbe Tabelle-in-Tabelle, dieselben Nieten.
//!
//! Gewichte statt Prozente ist keine Geschmacksfrage, sondern eine
//! Modding-Frage: Prozente muessen sich auf 100 summieren, und ein hinzugefuegter
//! Eintrag macht die Summe kaputt.
use crate::data::{Registries, parse_range};
use super::creature::ItemInstance;
use super::rng::Rng;
/// Zieht aus einer Tabelle. Rekursion ueber Tabelle-in-Tabelle ist mit einer
/// Tiefe gedeckelt — eine Tabelle, die sich selbst einbindet, wuerde sonst
/// endlos laufen, und das ist ein Datenfehler, kein Absturzgrund.
pub fn roll(table: &str, reg: &Registries, rng: &mut Rng) -> Vec<ItemInstance> {
let mut out = Vec::new();
draw(table, reg, rng, 0, &mut out);
out
}
fn draw(
table_id: &str,
reg: &Registries,
rng: &mut Rng,
depth: u8,
out: &mut Vec<ItemInstance>,
) {
if depth > 8 {
return;
}
let Some(table) = reg.loot_tables.get(table_id) else { return };
// `guaranteed` nimmt an der Gewichtung nicht teil und kommt immer.
for entry in table.entries.iter().filter(|e| e.guaranteed) {
emit(entry, reg, rng, depth, out);
}
let pool: Vec<&crate::data::LootEntry> =
table.entries.iter().filter(|e| !e.guaranteed).collect();
if pool.is_empty() {
return;
}
let weights: Vec<u32> = pool.iter().map(|e| e.weight).collect();
let (lo, hi) = parse_range(table.rolls.as_ref());
let rolls = rng.range(lo, hi);
for _ in 0..rolls {
if let Some(i) = rng.weighted(&weights) {
emit(pool[i], reg, rng, depth, out);
}
}
}
fn emit(
entry: &crate::data::LootEntry,
reg: &Registries,
rng: &mut Rng,
depth: u8,
out: &mut Vec<ItemInstance>,
) {
// Niete: ein gewoehnlicher Eintrag, kein eigenes `drop_chance`-Feld.
if entry.nothing {
return;
}
if let Some(sub) = &entry.table {
draw(sub, reg, rng, depth + 1, out);
return;
}
// Ein Affix-Eintrag ausserhalb einer Affix-Tabelle ergibt keinen
// Gegenstand — er wird von `affixes_for` gelesen, nicht hier.
if entry.affix.is_some() {
return;
}
let Some(item) = &entry.item else { return };
if !reg.items.contains_key(item) {
return; // beim Laden gemeldet
}
let affixes = match &entry.affixes {
Some(table) => affixes_for(table, reg, rng),
None => Vec::new(),
};
let (lo, hi) = parse_range(entry.count.as_ref());
for _ in 0..rng.range(lo, hi) {
out.push(ItemInstance { def: item.clone(), affixes: affixes.clone() });
}
}
/// Zieht Affixe aus derselben Tabellenmaschinerie. Abstufungen sind eigene
/// Eintraege mit kleinerem Gewicht — deshalb erhoeht ein zusaetzlicher Zug
/// (Glueck) auch die Chance auf die staerkere Stufe.
fn affixes_for(table_id: &str, reg: &Registries, rng: &mut Rng) -> Vec<String> {
let Some(table) = reg.loot_tables.get(table_id) else { return Vec::new() };
let weights: Vec<u32> = table.entries.iter().map(|e| e.weight).collect();
let (lo, hi) = parse_range(table.rolls.as_ref());
let mut out = Vec::new();
for _ in 0..rng.range(lo, hi) {
let Some(i) = rng.weighted(&weights) else { continue };
let entry = &table.entries[i];
if entry.nothing {
continue;
}
if let Some(affix) = &entry.affix
&& reg.affixes.contains_key(affix)
&& !out.contains(affix)
{
out.push(affix.clone());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::load_mods;
#[test]
fn niete_liefert_nichts_und_stuerzt_nicht_ab() {
let (reg, _) = load_mods().expect("core");
let mut rng = Rng::new(1);
// Eine Tabelle, die es nicht gibt, ist kein Absturzgrund.
assert!(roll("gibtsnicht", &reg, &mut rng).is_empty());
}
#[test]
fn garantierter_eintrag_kommt_immer() {
let (reg, _) = load_mods().expect("core");
for seed in 1..30u64 {
let mut rng = Rng::new(seed);
let beute = roll("core:boss_table", &reg, &mut rng);
assert!(
beute.iter().any(|i| i.def == "core:bone_blade"),
"seed {seed}: das Einzelstueck muss fallen"
);
}
}
#[test]
fn affixe_kommen_aus_derselben_maschinerie() {
let (reg, _) = load_mods().expect("core");
let mut mit_affix = 0;
for seed in 1..80u64 {
let mut rng = Rng::new(seed);
for inst in roll("core:floor_5_table", &reg, &mut rng) {
if !inst.affixes.is_empty() {
mit_affix += 1;
// Jeder gezogene Affix muss existieren.
assert!(inst.affixes.iter().all(|a| reg.affixes.contains_key(a)));
}
}
}
assert!(mit_affix > 0, "auf Etage 5 muessen Affixe vorkommen");
}
#[test]
fn derselbe_seed_liefert_dieselbe_beute() {
let (reg, _) = load_mods().expect("core");
let a = roll("core:floor_3_table", &reg, &mut Rng::new(77));
let b = roll("core:floor_3_table", &reg, &mut Rng::new(77));
let ids = |v: &[ItemInstance]| {
v.iter().map(|i| (i.def.clone(), i.affixes.clone())).collect::<Vec<_>>()
};
assert_eq!(ids(&a), ids(&b), "sonst waere der Seed im Save wertlos");
}
/// Zwei Tabellen, die sich gegenseitig einbinden, sind ein Datenfehler —
/// aber kein Absturzgrund. Der Tiefendeckel ist die Stelle, die das traegt,
/// also braucht sie einen Test. Gebaut von Hand, damit `core` keine kaputten
/// Daten ausliefert.
#[test]
fn wechselseitiger_bezug_haengt_nicht() {
use crate::data::registries::{LootEntry, LootTable};
use crate::data::Registries;
let tabelle = |id: &str, other: &str| LootTable {
id: id.to_string(),
rolls: Some(String::from("1-1")),
entries: vec![LootEntry {
weight: 1,
guaranteed: false,
item: None,
table: Some(other.to_string()),
affix: None,
nothing: false,
count: None,
affixes: None,
}],
overrides: None,
};
let mut reg = Registries::default();
reg.loot_tables.insert(String::from("a"), tabelle("a", "b"));
reg.loot_tables.insert(String::from("b"), tabelle("b", "a"));
// Terminiert, statt den Stack zu sprengen.
assert!(roll("a", &reg, &mut Rng::new(5)).is_empty());
}
}

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.

View File

@@ -41,6 +41,14 @@ impl Rng {
if n == 0 { 0 } else { (self.next_u64() % n as u64) as usize }
}
/// Einschliesslich beider Grenzen.
pub fn range(&mut self, lo: u32, hi: u32) -> u32 {
if hi <= lo {
return lo;
}
lo + self.below((hi - lo + 1) as usize) as u32
}
pub fn chance(&mut self, p: f32) -> bool {
(self.next_u64() % 10_000) < (p.clamp(0.0, 1.0) * 10_000.0) as u64
}

View File

@@ -111,6 +111,7 @@ fn draw(frame: &mut Frame, app: &App) {
Screen::MainMenu => draw_main_menu(frame, area, app),
Screen::Explore => draw_explore(frame, area, app),
Screen::Combat { pick } => draw_combat(frame, area, app, *pick),
Screen::Inventory { pick } => draw_inventory(frame, area, app, *pick),
Screen::CharacterSheet => draw_sheet(frame, area, app),
Screen::GameOver => draw_game_over(frame, area, app),
}
@@ -159,7 +160,7 @@ fn draw_explore(frame: &mut Frame, area: Rect, app: &App) {
};
frame.render_widget(
Paragraph::new(format!(
"Zug {} │ {} │ hjkl/Pfeile .warten >absteigen c Bogen │ {}",
"Zug {} │ {} │ .warten g aufheben >absteigen i Inventar c Bogen │ {}",
app.game.turn, app.game.player.name, note
))
.right_aligned()
@@ -168,6 +169,63 @@ fn draw_explore(frame: &mut Frame, area: Rect, app: &App) {
);
}
/// Links das Inventar mit Cursor, rechts die Ausrüstung als **Schleife über die
/// Slot-Registry** — Modder können Slots ergänzen, ohne dass hier etwas
/// geändert wird (Ticket 02).
fn draw_inventory(frame: &mut Frame, area: Rect, app: &App, pick: usize) {
let block = Block::bordered().title(" Inventar ");
let inner = block.inner(area);
frame.render_widget(block, area);
let [links, rechts] =
Layout::horizontal([Constraint::Percentage(60), Constraint::Min(0)]).areas(inner);
let mut zeilen = Vec::new();
for (i, inst) in app.game.player.inventory.iter().enumerate() {
let def = app.registries.items.get(&inst.def);
let name = app.game.item_label(inst, &app.registries);
let preis = app.game.item_price(inst, &app.registries);
let slot = def.and_then(|d| d.slot.as_deref()).unwrap_or("—");
let zeiger = if i == pick { "›" } else { " " };
let farbe = if i == pick { Color::Yellow } else { Color::Reset };
zeilen.push(
Line::from(format!("{zeiger} {name:<30} {slot:<12} {preis:>5} G"))
.style(Style::new().fg(farbe)),
);
}
if zeilen.is_empty() {
zeilen.push(Line::from("leer").style(Style::new().fg(Color::DarkGray)));
}
zeilen.push(Line::from(""));
zeilen.push(
Line::from("↑↓ wählen · u benutzen · e anlegen · d wegwerfen · 1-9 ablegen · Esc")
.style(Style::new().fg(Color::DarkGray)),
);
frame.render_widget(
Paragraph::new(zeilen).block(Block::bordered().title(" Getragen ")),
links,
);
let mut aus = Vec::new();
for (i, slot) in app.registries.slots_ordered().iter().enumerate() {
let getragen = app.game.player.equipment.get(&slot.id);
let text = match getragen {
Some(inst) => app.game.item_label(inst, &app.registries),
None => String::from("—"),
};
let farbe = if getragen.is_some() { Color::Reset } else { Color::DarkGray };
aus.push(
Line::from(format!("{} {:<10} {}", i + 1, slot.name, text))
.style(Style::new().fg(farbe)),
);
}
frame.render_widget(
Paragraph::new(aus).block(Block::bordered().title(" Ausrüstung ")),
rechts,
);
}
/// Menue-Kampf (Charter: Dungeon Master / Etrian Odyssey). Links die Gegner mit
/// Ziffern, rechts die Faehigkeiten mit Kosten und **Sperrgrund** — der kommt
/// aus dem Spielkern, nicht aus der UI (Ticket 14).

View File

@@ -36,6 +36,23 @@ impl Widget for GridWidget<'_> {
}
}
// Beutehaufen: **ein** generisches Symbol fuer alles. Nur deshalb darf
// der Inhalt erst beim Aufheben gezogen werden — die Anzeige verraet
// nichts, was noch nicht gewuerfelt ist (Ticket 16).
for room in &self.floor.rooms {
for (lx, ly) in &room.loot {
if *lx < ox || *ly < oy {
continue;
}
let (px, py) = (area.x + lx - ox, area.y + ly - oy);
if px < area.right() && py < area.bottom() {
buf[(px, py)]
.set_symbol("*")
.set_style(Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD));
}
}
}
let (sx, sy) = (area.x + self.player.0 - ox, area.y + self.player.1 - oy);
if sx < area.right() && sy < area.bottom() {
buf[(sx, sy)]

View File

@@ -16,6 +16,10 @@ pub enum Screen {
/// Cursor in der Faehigkeitsliste.
pick: usize,
},
/// Zwei Bereiche: Inventar mit Cursor, Ausrüstung als Registry-Schleife.
Inventory {
pick: usize,
},
CharacterSheet,
/// Vollbild, kein Overlay. Bewusst nicht die lazy Variante mit einer
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
@@ -73,8 +77,10 @@ impl Screen {
}
KeyCode::Char('.') => Response::Command(Command::Wait),
KeyCode::Char('>') => Response::Command(Command::Descend),
KeyCode::Char('g') => Response::Command(Command::PickUp),
// Screen-Navigation ist reine UI und fasst `Game` nie an.
KeyCode::Char('c') => Response::Push(Screen::CharacterSheet),
KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }),
KeyCode::Esc => Response::Pop,
_ => Response::None,
},
@@ -118,6 +124,42 @@ impl Screen {
}
}
Screen::Inventory { pick } => {
let n = game.player.inventory.len();
if n > 0 {
*pick = (*pick).min(n - 1);
}
match key {
KeyCode::Up | KeyCode::Char('k') => {
*pick = pick.saturating_sub(1);
Response::None
}
KeyCode::Down | KeyCode::Char('j') => {
*pick = (*pick + 1).min(n.saturating_sub(1));
Response::None
}
KeyCode::Enter | KeyCode::Char('u') if n > 0 => {
Response::Command(Command::UseItem(*pick))
}
KeyCode::Char('e') if n > 0 => Response::Command(Command::Equip(*pick)),
KeyCode::Char('d') if n > 0 => Response::Command(Command::Drop(*pick)),
// Ablegen ueber die Slot-Reihenfolge aus der Registry —
// der Charakterbogen mauert kein Layout (Ticket 02).
KeyCode::Char(c @ '1'..='9') => {
let index = (c as u8 - b'1') as usize;
match reg.slots_ordered().get(index) {
Some(slot) => {
Response::Command(Command::Unequip(slot.id.clone()))
}
None => Response::None,
}
}
KeyCode::Esc | KeyCode::Char('i') | KeyCode::Char('q') => Response::Pop,
_ => Response::None,
}
}
Screen::CharacterSheet => match key {
KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => Response::Pop,
_ => Response::None,