feat: m1 vertikaler durchstich — mod-lader, registries, attributberechnung, screen-stack

Ersetzt src/ vollstaendig: print_box, include_str! und enum ItemType fallen,
wie Ticket 01-03 es vorgezeichnet haben.

- data/: Mod-Lader mit zwei Suchpfaden, selbstheilender load_order.toml,
  drei Phasen und zweistufiger Deserialisierung (03/04)
- game/: Attributkatalog, Creature, Game::attribute ueber mehrere Quellen,
  Command mit Outcome (01/07/11/12)
- ui/: Screen-Stack mit Response, Grid-Widget, Press-Filter (06/11/12)
- mods/core/: echte TOML-Dateien auf der Platte statt include_str!

10 Tests, darunter der Borrow-Beweis fuer Registries-neben-Game, den
Ticket 12 nicht fuehren konnte.
This commit is contained in:
2026-08-12 15:39:17 +02:00
parent 9c5efab331
commit b39491916f
28 changed files with 3414 additions and 279 deletions

191
src/game/mod.rs Normal file
View File

@@ -0,0 +1,191 @@
//! Der Spielkern. **Kennt ratatui nicht** — `tests/boundaries.rs` prueft das.
pub mod attributes;
pub mod command;
pub mod creature;
pub mod floor;
pub use attributes::{Attribute, Modifier};
pub use command::{Command, Direction, Outcome};
pub use creature::{Creature, CreatureRef, ItemInstance};
pub use floor::{Floor, Tile};
use crate::data::Registries;
pub struct Game {
pub player: Creature,
/// Der **laufende Kampf**, nicht die Etage (Ticket 07/14). Leer = kein Kampf.
pub enemies: Vec<Creature>,
pub floor: Floor,
pub player_pos: (u16, u16),
pub turn: u64,
/// Letzte Meldung fuer die Statuszeile.
pub last: String,
// TODO(M3): `events: Vec<Event>` (Warteschlange aus Ticket 08) und der
// xorshift-Seed aus Ticket 14. Beide brauchen erst einen Trigger
// bzw. einen Wurf, um etwas zu tun.
// TODO(M4): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an
// `Game`, nicht am `Creature`).
}
impl Game {
pub fn new(player: Creature) -> Self {
let floor = Floor::test_room();
let player_pos = floor.entrance();
Self {
player,
enemies: Vec::new(),
floor,
player_pos,
turn: 0,
last: String::from("—"),
}
}
pub fn creature(&self, who: CreatureRef) -> &Creature {
match who {
CreatureRef::Player => &self.player,
CreatureRef::Enemy(i) => &self.enemies[i],
}
}
/// Der Lesezugang liegt auf `Game`, nicht auf `Creature` — eine Aura wohnt
/// beim Traeger und wird beim Auslesen des Ziels eingesammelt (Ticket 01),
/// ein `Creature` kann seine Attribute also gar nicht allein ausrechnen.
///
/// Vier Quellen: `base` → `options` → `equipment` samt Affixen → befristete
/// Modifikatoren. **Kein Cache** (Ticket 07): Das passiert pro Tastendruck,
/// nicht pro Frame.
pub fn attribute(
&self,
who: CreatureRef,
attr: Attribute,
_against: Option<CreatureRef>,
reg: &Registries,
) -> f32 {
let c = self.creature(who);
let from_options = c
.options
.iter()
.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()
});
// TODO(M3): Affix-Modifikatoren des Exemplars, befristete Modifikatoren
// am Traeger, und `_against` fuer bedingte Modifikatoren
// („+5 Schaden gegen Untote", Ticket 08).
attributes::stack(c.base.get(attr), from_options.chain(from_equipment), attr)
}
/// Der **eine** Eingang, an dem ein Zug endet (Ticket 11).
pub fn execute(&mut self, cmd: Command, _reg: &Registries) -> Outcome {
let outcome = match cmd {
Command::Move(dir) => self.step(dir),
Command::Wait => {
self.last = String::from("gewartet");
Outcome::TurnPassed
}
};
// Nur ausgefuehrte Kommandos kosten einen Zug (Ticket 12).
if matches!(outcome, Outcome::TurnPassed) {
self.turn += 1;
// TODO(M3): befristete Modifikatoren ticken, Ausdauer regeneriert,
// Abklingzeiten sinken, `turn_started` feuern, dann die
// Event-Warteschlange leerarbeiten (Ticket 08/14).
}
outcome
}
fn step(&mut self, dir: Direction) -> Outcome {
let (x, y) = self.player_pos;
let (nx, ny) = match dir {
Direction::North => (x, y.saturating_sub(1)),
Direction::South => (x, y + 1),
Direction::West => (x.saturating_sub(1), y),
Direction::East => (x + 1, y),
};
if self.floor.tile(nx, ny) == Tile::Wall {
return Outcome::Rejected("Da ist eine Wand");
}
self.player_pos = (nx, ny);
self.last = match self.floor.tile(nx, ny) {
Tile::Stairs => String::from("Eine Treppe führt nach unten"),
_ => format!("{},{}", nx, ny),
};
Outcome::TurnPassed
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::load_mods;
fn zwergenkrieger() -> (Game, Registries) {
let (reg, _) = load_mods().expect("core muss laden");
let mut hero = Creature::new("Testheld");
hero.options = vec![String::from("core:dwarf"), String::from("core:warrior")];
hero.equipment
.insert(String::from("core:hand"), ItemInstance::new("core:iron_sword"));
(Game::new(hero), reg)
}
/// Der Zielsatz von Meilenstein 1 als Behauptung: ein Wert, der aus einem
/// Modifikator stammt, der aus einer TOML-Datei kam.
#[test]
fn attribut_sammelt_aus_optionen_und_ausruestung() {
let (game, reg) = zwergenkrieger();
let p = CreatureRef::Player;
// Basis 1, Zwerg +2 (character_options.toml)
assert_eq!(game.attribute(p, Attribute::Constitution, None, &reg), 3.0);
// Basis 2, Eisenschwert +5 flach (items.toml), Krieger +10 % ⇒ (2+5)*1.1
let damage = game.attribute(p, Attribute::Damage, None, &reg);
assert!((damage - 7.7).abs() < 0.001, "erwartet 7.7, war {damage}");
// Unberuehrtes Attribut bleibt bei der Basis.
assert_eq!(game.attribute(p, Attribute::Charisma, None, &reg), 1.0);
}
/// Ticket 07 hat `Registries` **neben** `Game` gelegt, weil ein Feld bei
/// jedem Methodenaufruf mit `&mut self` kollidiert waere. Dass dieser Test
/// kompiliert, *ist* der Beweis — Ticket 12 konnte ihn nicht fuehren, weil
/// der Durchstich keine Registries lud.
#[test]
fn disjunkte_borrows_ueber_methodenaufrufe_hinweg() {
let (mut game, reg) = zwergenkrieger();
game.execute(Command::Move(Direction::North), &reg);
let _ = game.attribute(CreatureRef::Player, Attribute::Damage, None, &reg);
game.execute(Command::Wait, &reg);
}
/// Befund aus Ticket 12: „alle Commands kosten einen Zug" heisst **alle
/// ausgefuehrten**.
#[test]
fn wand_kostet_keinen_zug() {
let (mut game, reg) = zwergenkrieger();
game.player_pos = (1, 1);
assert!(matches!(
game.execute(Command::Move(Direction::North), &reg),
Outcome::Rejected(_)
));
assert_eq!(game.turn, 0, "eine Wand darf keine Zeit verbrauchen");
assert!(matches!(game.execute(Command::Wait, &reg), Outcome::TurnPassed));
assert_eq!(game.turn, 1);
}
}