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:
191
src/game/mod.rs
Normal file
191
src/game/mod.rs
Normal 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, ®), 3.0);
|
||||
|
||||
// Basis 2, Eisenschwert +5 flach (items.toml), Krieger +10 % ⇒ (2+5)*1.1
|
||||
let damage = game.attribute(p, Attribute::Damage, None, ®);
|
||||
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, ®), 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), ®);
|
||||
let _ = game.attribute(CreatureRef::Player, Attribute::Damage, None, ®);
|
||||
game.execute(Command::Wait, ®);
|
||||
}
|
||||
|
||||
/// 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), ®),
|
||||
Outcome::Rejected(_)
|
||||
));
|
||||
assert_eq!(game.turn, 0, "eine Wand darf keine Zeit verbrauchen");
|
||||
|
||||
assert!(matches!(game.execute(Command::Wait, ®), Outcome::TurnPassed));
|
||||
assert_eq!(game.turn, 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user