Files
rustkeep/src/game/generate.rs
MDietermann 7ed15afaf9 feat(handel): NPC-Registry, Dialog, Laden und Stapelregel
Vierzehnte Registry: NPCs sind keine Wesen — kein Kampfzustand, keine
Initiative, keine Attribute. Ihre Antwortliste ist flach; was erst nach
einer Antwort erscheint, ist eine Option mit einer Bedingung.

- open_shop als elfte Aktion (game/ setzt eine Absicht, die UI schiebt)
- Talk/Choose/Buy/Sell, Talk wirkt auf das eigene Feld wie PickUp
- Preisspanne statt Goldkonto beim Haendler, Charisma in der Preisfunktion
- Warenkorb einmal bei der Etagenerzeugung gezogen
- Stapelregel aus Ticket 07 endlich gebaut: 50-120 Gold war 120 Eintraege
- Dialog- und Shop-Screen, NPC-Symbol im Grid
2026-08-12 20:31:35 +02:00

500 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Etagengenerator — Ticket 10.
//!
//! Freie Platzierung mit L-Korridoren. Verbunden wird **Raum `i` mit Raum
//! `i−1`**, weil eine Kette Erreichbarkeit *strukturell* garantiert: Haengt
//! jeder Raum am vorigen, ist jeder von jedem erreichbar — ohne Flutfuellung
//! und ohne Reparaturschleife.
use crate::data::{FloorDef, Prefab, Registries};
use super::creature::Creature;
use super::floor::{Floor, Rect, Room, Tile};
use super::rng::Rng;
/// Ein Prefab-Layout in einer der acht Ausrichtungen.
struct Stamp {
w: u16,
h: u16,
cells: Vec<char>,
}
impl Stamp {
fn from(prefab: &Prefab) -> Self {
let rows = prefab.rows();
let w = rows[0].chars().count() as u16;
let h = rows.len() as u16;
Self { w, h, cells: rows.iter().flat_map(|r| r.chars()).collect() }
}
fn at(&self, x: u16, y: u16) -> char {
self.cells[y as usize * self.w as usize + x as usize]
}
/// 90° im Uhrzeigersinn: transponieren und Zeilen umkehren.
fn rotated(&self) -> Self {
let mut cells = Vec::with_capacity(self.cells.len());
for x in 0..self.w {
for y in (0..self.h).rev() {
cells.push(self.at(x, y));
}
}
Self { w: self.h, h: self.w, cells }
}
fn mirrored(&self) -> Self {
let mut cells = Vec::with_capacity(self.cells.len());
for y in 0..self.h {
for x in (0..self.w).rev() {
cells.push(self.at(x, y));
}
}
Self { w: self.w, h: self.h, cells }
}
/// Acht Varianten je Prefab — achtmal so viel Inhalt aus derselben
/// Handarbeit (Ticket 10).
fn oriented(prefab: &Prefab, rng: &mut Rng) -> Self {
let mut s = Stamp::from(prefab);
if rng.chance(0.5) {
s = s.mirrored();
}
for _ in 0..rng.below(4) {
s = s.rotated();
}
s
}
}
struct Placed {
prefab: String,
rect: Rect,
stamp: Stamp,
}
pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor {
let (width, height) = def.size;
let mut placed: Vec<Placed> = Vec::new();
// Pflichtraeume zuerst auf die leere Flaeche — **das ist die Garantie**.
for tag in &def.required {
let candidates = reg.prefabs_tagged(tag);
if candidates.is_empty() {
continue; // beim Laden bereits gewarnt (Ticket 10)
}
let prefab = candidates[rng.below(candidates.len())];
try_place(prefab, width, height, rng, &mut placed);
}
// Danach gewichtet auffuellen. Raeume, die nicht mehr passen, entfallen
// einfach — das ist der Normalfall, kein Fehler.
let weights: Vec<u32> = def.prefabs.iter().map(|p| p.weight).collect();
let mut attempts = 0;
while placed.len() < def.rooms && attempts < def.rooms * 20 {
attempts += 1;
let Some(i) = rng.weighted(&weights) else { break };
let candidates = reg.prefabs_tagged(&def.prefabs[i].tag);
if candidates.is_empty() {
continue;
}
let prefab = candidates[rng.below(candidates.len())];
try_place(prefab, width, height, rng, &mut placed);
}
// Stempeln
let mut tiles = vec![Tile::Wall; width as usize * height as usize];
let mut rooms = Vec::new();
for p in &placed {
let mut enemy_spawns = Vec::new();
let mut loot = Vec::new();
let mut npc_plaetze = Vec::new();
for y in 0..p.stamp.h {
for x in 0..p.stamp.w {
let (gx, gy) = (p.rect.x + x, p.rect.y + y);
let c = p.stamp.at(x, y);
let tile = match c {
'#' => Tile::Wall,
'+' => Tile::Door,
'>' => Tile::Stairs,
'<' => Tile::Entrance,
_ => Tile::Floor,
};
tiles[gy as usize * width as usize + gx as usize] = tile;
match c {
'e' => enemy_spawns.push((gx, gy)),
'l' => loot.push((gx, gy)),
'n' => npc_plaetze.push((gx, gy)),
_ => {}
}
}
}
// Ein `e` sagt „hier steht ein Gegner", **nicht welcher** — das
// entscheidet die Etage (Ticket 10). Nur so laesst sich derselbe Raum
// auf Etage 1 und Etage 5 verwenden.
let gewichte: Vec<u32> = def.creatures.iter().map(|c| c.weight).collect();
let enemies = enemy_spawns
.iter()
.filter_map(|_| {
let i = rng.weighted(&gewichte)?;
let cdef = reg.creatures.get(&def.creatures[i].id)?;
Some(Creature::from_def(cdef))
})
.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();
// Der i-te `n`-Platz bekommt den i-ten Eintrag aus `prefab.npcs` —
// Positionsbindung statt eines zweiten Markers je NPC.
let npcs = npc_plaetze
.into_iter()
.enumerate()
.filter_map(|(i, pos)| {
let id = reg.prefabs.get(&p.prefab)?.npcs.get(i)?.clone();
let stock = reg
.npcs
.get(&id)
.and_then(|n| n.shop.as_deref())
.map(|t| crate::game::loot::roll(t, reg, rng))
.unwrap_or_default();
Some(super::floor::NpcInstance { pos, def: id, stock })
})
.collect();
rooms.push(Room {
prefab: p.prefab.clone(),
rect: p.rect,
enemy_spawns,
enemies,
loot_table,
loot,
npcs,
cleared: false,
});
}
// Kette: Raum i an Raum i-1.
for i in 1..placed.len() {
connect(&placed, i - 1, i, width, height, &mut tiles);
}
Floor { number: def.number, width, height, tiles, rooms, player_room: None }
}
fn try_place(prefab: &Prefab, width: u16, height: u16, rng: &mut Rng, placed: &mut Vec<Placed>) {
for _ in 0..60 {
let stamp = Stamp::oriented(prefab, rng);
if stamp.w + 2 >= width || stamp.h + 2 >= height {
return; // passt grundsaetzlich nicht
}
let x = 1 + rng.below((width - stamp.w - 2) as usize) as u16;
let y = 1 + rng.below((height - stamp.h - 2) as usize) as u16;
let rect = Rect { x, y, w: stamp.w, h: stamp.h };
// Ein Feld Rand, damit zwei Raeume nicht Wand an Wand kleben.
if placed.iter().any(|p| p.rect.touches(&rect)) {
continue;
}
placed.push(Placed { prefab: prefab.id.clone(), rect, stamp });
return;
}
}
/// Tueren eines platzierten Raums, in Weltkoordinaten.
fn doors(p: &Placed) -> Vec<(u16, u16)> {
let mut out = Vec::new();
for y in 0..p.stamp.h {
for x in 0..p.stamp.w {
if p.stamp.at(x, y) == '+' {
out.push((p.rect.x + x, p.rect.y + y));
}
}
}
out
}
/// Das Feld **direkt ausserhalb** einer Tuer. Ohne diesen Schritt beginnt der
/// Korridor *auf* der Tuer — und weil eine Tuer auf der Raumwand liegt, graebt
/// der waagerechte Abschnitt die ganze Wandreihe auf. Der Test
/// `korridore_stanzen_keine_loecher_in_raumwaende` hat genau das gefunden.
fn door_exit(p: &Placed, (x, y): (u16, u16)) -> Option<(u16, u16)> {
let r = p.rect;
if y == r.y {
return y.checked_sub(1).map(|ny| (x, ny));
}
if y == r.y + r.h - 1 {
return Some((x, y + 1));
}
if x == r.x {
return x.checked_sub(1).map(|nx| (nx, y));
}
if x == r.x + r.w - 1 {
return Some((x + 1, y));
}
None // Tuer mitten im Raum ergibt keinen Ausgang
}
fn connect(all: &[Placed], ia: usize, ib: usize, width: u16, height: u16, tiles: &mut [Tile]) {
let exits = |i: usize| -> Vec<(u16, u16)> {
doors(&all[i]).into_iter().filter_map(|d| door_exit(&all[i], d)).collect()
};
let (ea, eb) = (exits(ia), exits(ib));
if ea.is_empty() || eb.is_empty() {
// Prefab ohne Tuer laesst sich nicht anschliessen. Mitte gegen Mitte
// waere die Alternative — und wuerde genau die Loecher stanzen, die
// Ticket 10 verhindern wollte.
return;
}
// Alle Tuerpaare, kuerzeste zuerst, jeweils beide L-Varianten.
let mut paare: Vec<((u16, u16), (u16, u16))> =
ea.iter().flat_map(|s| eb.iter().map(move |e| (*s, *e))).collect();
paare.sort_by_key(|(s, e)| s.0.abs_diff(e.0) as u32 + s.1.abs_diff(e.1) as u32);
let frei = |path: &[(u16, u16)]| {
path.iter().all(|&(x, y)| {
x < width && y < height && !all.iter().any(|p| p.rect.contains(x, y))
})
};
// Erst die L-Form: zwei Segmente sehen wie ein Korridor aus, nicht wie ein
// Irrgang. Wenn eine davon frei ist, gewinnt sie.
for (start, end) in &paare {
for horizontal_first in [true, false] {
let path = l_path(*start, *end, horizontal_first);
if frei(&path) {
carve(&path, width, tiles);
return;
}
}
}
// Sonst Wegsuche **ueber den freien Raum**. Sie kann eine Raumwand gar nicht
// beruehren, weil Raumfelder nicht im Suchraum liegen — damit haelt die
// Zusage aus Ticket 10 („keine Loecher in gezeichnete Waende") statt nur
// wahrscheinlich zu sein. Zwei L-Varianten senkten sie nur.
for (start, end) in &paare {
if let Some(path) = bfs(*start, *end, width, height, all) {
carve(&path, width, tiles);
return;
}
}
// Kein Weg: dann bleibt der Raum unverbunden, und
// `alles_begehbare_haengt_zusammen` schlaegt an. Laut statt still.
}
/// Kuerzester Weg durch Felder, die in **keinem** Raum liegen.
fn bfs(
start: (u16, u16),
end: (u16, u16),
width: u16,
height: u16,
rooms: &[Placed],
) -> Option<Vec<(u16, u16)>> {
let idx = |x: u16, y: u16| y as usize * width as usize + x as usize;
let blocked = |x: u16, y: u16| rooms.iter().any(|p| p.rect.contains(x, y));
if start.0 >= width || start.1 >= height || blocked(start.0, start.1) {
return None;
}
let mut prev: Vec<Option<(u16, u16)>> = vec![None; width as usize * height as usize];
let mut seen = vec![false; width as usize * height as usize];
let mut queue = std::collections::VecDeque::from([start]);
seen[idx(start.0, start.1)] = true;
while let Some((x, y)) = queue.pop_front() {
if (x, y) == end {
let mut path = vec![(x, y)];
let mut cur = (x, y);
while let Some(p) = prev[idx(cur.0, cur.1)] {
path.push(p);
cur = p;
}
return Some(path);
}
let nachbarn = [
(x.wrapping_sub(1), y),
(x + 1, y),
(x, y.wrapping_sub(1)),
(x, y + 1),
];
for (nx, ny) in nachbarn {
if nx >= width || ny >= height || seen[idx(nx, ny)] || blocked(nx, ny) {
continue;
}
seen[idx(nx, ny)] = true;
prev[idx(nx, ny)] = Some((x, y));
queue.push_back((nx, ny));
}
}
None
}
fn carve(path: &[(u16, u16)], width: u16, tiles: &mut [Tile]) {
for &(x, y) in path {
let idx = y as usize * width as usize + x as usize;
if idx < tiles.len() && tiles[idx] == Tile::Wall {
tiles[idx] = Tile::Floor;
}
}
}
fn l_path(from: (u16, u16), to: (u16, u16), horizontal_first: bool) -> Vec<(u16, u16)> {
let mut path = Vec::new();
let (x0, y0) = from;
let (x1, y1) = to;
let push_h = |y: u16, path: &mut Vec<(u16, u16)>| {
for x in x0.min(x1)..=x0.max(x1) {
path.push((x, y));
}
};
let push_v = |x: u16, path: &mut Vec<(u16, u16)>| {
for y in y0.min(y1)..=y0.max(y1) {
path.push((x, y));
}
};
if horizontal_first {
push_h(y0, &mut path);
push_v(x1, &mut path);
} else {
push_v(x0, &mut path);
push_h(y1, &mut path);
}
path
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::load_mods;
fn erzeuge(seed: u64, nummer: u8) -> Floor {
let (reg, _) = load_mods().expect("core muss laden");
let def = reg.floor_number(nummer).expect("Etage existiert");
generate(def, &reg, &mut Rng::new(seed))
}
#[test]
fn derselbe_seed_erzeugt_dieselbe_etage() {
let a = erzeuge(1234, 1);
let b = erzeuge(1234, 1);
assert_eq!(a.tiles, b.tiles, "sonst waere der Seed im Save wertlos");
assert_eq!(a.rooms.len(), b.rooms.len());
}
#[test]
fn pflichtraeume_sind_da() {
// Pflichtraeume kommen zuerst auf die leere Flaeche — sie muessen
// ueber alle Seeds hinweg auftauchen.
for seed in 1..40u64 {
let f = erzeuge(seed, 1);
let hat = |tag: &str| f.rooms.iter().any(|r| r.prefab.contains(tag));
assert!(hat("entrance_hall"), "seed {seed}: kein Eingang");
assert!(hat("stair_room"), "seed {seed}: keine Treppe");
assert!(hat("market"), "seed {seed}: kein Händler");
}
}
#[test]
fn raeume_ueberlappen_nicht() {
for seed in 1..30u64 {
let f = erzeuge(seed, 3);
for (i, a) in f.rooms.iter().enumerate() {
for b in f.rooms.iter().skip(i + 1) {
assert!(!a.rect.touches(&b.rect), "seed {seed}: {} trifft {}", a.prefab, b.prefab);
}
}
}
}
/// Die Kette garantiert Erreichbarkeit strukturell — hier wird nachgezaehlt,
/// ob das auch nach dem Stempeln stimmt.
#[test]
fn alles_begehbare_haengt_zusammen() {
for seed in 1..30u64 {
let f = erzeuge(seed, 2);
let start = f.entrance();
let erreichbar = flood(&f, start);
let begehbar = f.tiles.iter().filter(|t| **t != Tile::Wall).count();
assert_eq!(
erreichbar, begehbar,
"seed {seed}: {} von {begehbar} Feldern erreichbar",
erreichbar
);
}
}
/// Ticket 10 hat Tueren ausdruecklich gemacht, damit der Generator **keine
/// Loecher in eine von Hand gezeichnete Wand stanzt**. Der Rand eines Raums
/// besteht im Prefab nur aus `#` und `+` — findet sich dort Boden, hat ein
/// Korridor durchgegraben.
#[test]
fn korridore_stanzen_keine_loecher_in_raumwaende() {
let mut durchgegraben = 0;
for seed in 1..60u64 {
let f = erzeuge(seed, 3);
for room in &f.rooms {
let r = room.rect;
let rand = (r.x..r.x + r.w)
.flat_map(|x| [(x, r.y), (x, r.y + r.h - 1)])
.chain((r.y..r.y + r.h).flat_map(|y| [(r.x, y), (r.x + r.w - 1, y)]));
for (x, y) in rand {
if f.tile(x, y) == Tile::Floor {
durchgegraben += 1;
}
}
}
}
assert_eq!(
durchgegraben, 0,
"{durchgegraben} Wandfelder wurden durchgegraben — der Rueckfallpfad in \
`connect` greift haeufiger als gedacht und braucht eine echte Wegsuche"
);
}
fn flood(f: &Floor, start: (u16, u16)) -> usize {
let mut seen = vec![false; f.tiles.len()];
let mut stack = vec![start];
let mut n = 0;
while let Some((x, y)) = stack.pop() {
let idx = y as usize * f.width as usize + x as usize;
if seen[idx] || f.tile(x, y) == Tile::Wall {
continue;
}
seen[idx] = true;
n += 1;
if x > 0 {
stack.push((x - 1, y));
}
if y > 0 {
stack.push((x, y - 1));
}
if x + 1 < f.width {
stack.push((x + 1, y));
}
if y + 1 < f.height {
stack.push((x, y + 1));
}
}
n
}
}