feat: m2 etagengenerierung — prefabs, floors, korridore, abstieg und sieg

Der handgebaute Testraum ist weg; Etagen entstehen aus Daten.

- game/rng.rs: xorshift64*, Seed im Game (Ticket 14)
- game/generate.rs: freie Platzierung, Pflichtraeume zuerst, Kette
  i->i-1 fuer strukturelle Erreichbarkeit, acht Orientierungen
- data/: [[prefabs]] und [[floors]] als neunte und zehnte Registry,
  Ladepruefungen fuer rechteckige Layouts und Pflichtraum-Flaeche
- Descend plus Siegbedingung: keine [[floors]] mit der naechsten Nummer
  heisst gewonnen — die Zahl 5 steht nirgends im Code
- GameOver-Screen, mitlaufender Bildausschnitt im Grid

Befund: Korridore stanzten 2340 Loecher in gezeichnete Waende, weil sie
AUF der Tuer starteten. Erst Schritt nach aussen, dann L-Form, sonst
Wegsuche ueber den freien Raum — die kann eine Raumwand nicht beruehren.
Der Test zaehlt jetzt null.

21 Tests.
This commit is contained in:
2026-08-12 16:14:23 +02:00
parent 3714bad313
commit f998e55210
14 changed files with 1121 additions and 82 deletions

View File

@@ -14,7 +14,7 @@ use serde::Deserialize;
use serde::de::DeserializeOwned;
use toml::Spanned;
use super::registries::{Category, CharacterOption, Item, Registries, Slot};
use super::registries::{Category, CharacterOption, FloorDef, Item, Prefab, Registries, Slot};
/// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus
/// Verwerfen des **einzelnen Eintrags**.
@@ -244,6 +244,10 @@ struct ModFile {
categories: Vec<Spanned<toml::Value>>,
#[serde(default)]
options: Vec<Spanned<toml::Value>>,
#[serde(default)]
prefabs: Vec<Spanned<toml::Value>>,
#[serde(default)]
floors: Vec<Spanned<toml::Value>>,
}
/// Damit die vier Registry-Typen durch dieselbe Einlese-Schleife passen.
@@ -272,6 +276,8 @@ impl_entry!(Item);
impl_entry!(Slot);
impl_entry!(Category);
impl_entry!(CharacterOption);
impl_entry!(Prefab);
impl_entry!(FloorDef);
struct FileCtx<'a> {
module: &'a str,
@@ -366,6 +372,66 @@ fn resolve(reg: &mut Registries, warnings: &mut Vec<String>) {
false
}
});
// Ticket 10: `layout`-Zeilen muessen gleich lang sein, sonst ist der Block
// kein Rechteck und die Platzierung rechnet mit Muell. Ein Zweizeiler, der
// eine ganze Klasse von Modder-Frust abfaengt.
reg.prefabs.retain(|id, prefab| {
let rows = prefab.rows();
if rows.is_empty() {
warnings.push(format!("{id}: layout ist leer — verworfen"));
return false;
}
let width = rows[0].chars().count();
match rows.iter().position(|r| r.chars().count() != width) {
Some(i) => {
warnings.push(format!(
"{id}: layout-Zeile {} ist {} Zeichen breit, erwartet {width} — verworfen",
i + 1,
rows[i].chars().count()
));
false
}
None => true,
}
});
// Ticket 10: Passen die Pflicht-Tags grundsaetzlich nicht in `size`, ist das
// ein **Datenfehler und beim Laden erkennbar**. Zur Laufzeit gibt es dafuer
// dann keinen Wiederholungszaehler und keinen Rueckfallpfad — die Pruefung
// sitzt dort, wo der Fehler gemacht wurde.
let prefabs = &reg.prefabs;
reg.floors.retain(|id, floor| {
let mut needed = 0u32;
for tag in &floor.required {
let smallest = prefabs
.values()
.filter(|p| p.tags.iter().any(|t| t == tag))
.map(|p| {
let (w, h) = p.size();
(w as u32 + 1) * (h as u32 + 1)
})
.min();
match smallest {
None => {
warnings.push(format!("{id}: kein Prefab mit dem Pflicht-Tag '{tag}'"));
return false;
}
Some(area) => needed += area,
}
}
let available = floor.size.0 as u32 * floor.size.1 as u32;
if needed > available {
warnings.push(format!(
"{id}: Pflichträume brauchen mindestens {needed} Felder, size bietet {available}"
));
return false;
}
true
});
}
// ============================================================================
@@ -414,6 +480,8 @@ pub fn load_mods() -> Result<(Registries, Vec<String>), LoadError> {
parse_entries(parsed.slots, &ctx, &mut reg.slots, &mut warnings);
parse_entries(parsed.categories, &ctx, &mut reg.categories, &mut warnings);
parse_entries(parsed.options, &ctx, &mut reg.options, &mut warnings);
parse_entries(parsed.prefabs, &ctx, &mut reg.prefabs, &mut warnings);
parse_entries(parsed.floors, &ctx, &mut reg.floors, &mut warnings);
}
}

View File

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

View File

@@ -92,6 +92,62 @@ pub struct CharacterOption {
// Startausruestung brauchen Kampf bzw. Inventar.
}
/// Ein von Hand gezeichneter Raum als ASCII-Block (Ticket 10).
#[derive(Debug, Clone, Deserialize)]
pub struct Prefab {
pub id: String,
#[serde(default)]
pub tags: Vec<String>,
pub layout: String,
/// Ersetzt die Etagentabelle fuer die `l`-Plaetze dieses Raums (Ticket 16).
#[serde(default)]
pub loot: Option<String>,
#[serde(default)]
pub overrides: Option<String>,
}
impl Prefab {
/// Zeilen ohne fuehrende/abschliessende Leerzeile. TOML-Mehrzeiler beginnen
/// mit einem Zeilenumbruch, sobald `"""` allein auf der Zeile steht.
pub fn rows(&self) -> Vec<&str> {
self.layout.lines().filter(|l| !l.trim().is_empty()).collect()
}
pub fn size(&self) -> (u16, u16) {
let rows = self.rows();
(rows.first().map_or(0, |r| r.chars().count() as u16), rows.len() as u16)
}
}
/// Gewichteter Verweis auf eine Prefab-**Tag**-Gruppe. Tags statt IDs, sonst
/// muesste jede Etagentabelle jedes neue Modder-Prefab einzeln aufzaehlen.
#[derive(Debug, Clone, Deserialize)]
pub struct WeightedTag {
pub tag: String,
#[serde(default = "one")]
pub weight: u32,
}
/// Eine Ebene des Dungeons und zugleich ihr Bauplan (Ticket 10).
#[derive(Debug, Clone, Deserialize)]
pub struct FloorDef {
pub id: String,
pub number: u8,
/// Felder, nicht Zellen.
pub size: (u16, u16),
/// Zielzahl inklusive Pflichtraeume.
pub rooms: usize,
/// Tags, die garantiert vorkommen — zuerst platziert, das *ist* die Garantie.
#[serde(default)]
pub required: Vec<String>,
#[serde(default)]
pub prefabs: Vec<WeightedTag>,
#[serde(default)]
pub overrides: Option<String>,
// TODO(M3): `creatures` — welche Gegner die `e`-Plaetze fuellen.
// TODO(M4): `loot` — die Beutetabelle fuer die `l`-Plaetze (Ticket 16).
}
/// Alle geladenen Definitionen. Liegt **neben** `Game`, nicht darin — als Feld
/// kollidierte `&mut game.player` mit `&game.registries` bei jedem
/// Methodenaufruf (Ticket 07).
@@ -101,8 +157,10 @@ pub struct Registries {
pub slots: HashMap<String, Slot>,
pub categories: HashMap<String, Category>,
pub options: HashMap<String, CharacterOption>,
// TODO(M2+): `affixes`, `abilities`, `quests`, `creatures`, `prefabs`,
// `floors`, `loot_tables`, `scripts` — zwoelf sind es am Ende.
pub prefabs: HashMap<String, Prefab>,
pub floors: HashMap<String, FloorDef>,
// TODO(M3+): `affixes`, `abilities`, `quests`, `creatures`, `loot_tables`,
// `scripts` — zwoelf sind es am Ende.
}
impl Registries {
@@ -113,4 +171,21 @@ impl Registries {
v.sort_by_key(|s| (s.order, s.id.clone()));
v
}
/// Prefabs mit einem bestimmten Tag, in stabiler Reihenfolge — sonst
/// haengt die Etage von der HashMap-Iterationsreihenfolge ab und derselbe
/// Seed erzeugt zwei verschiedene Dungeons.
pub fn prefabs_tagged(&self, tag: &str) -> Vec<&Prefab> {
let mut v: Vec<&Prefab> =
self.prefabs.values().filter(|p| p.tags.iter().any(|t| t == tag)).collect();
v.sort_by(|a, b| a.id.cmp(&b.id));
v
}
pub fn floor_number(&self, number: u8) -> Option<&FloorDef> {
// Stabil, falls zwei Mods dieselbe Nummer vergeben: die kleinere ID.
// Ohne das entscheidet die HashMap-Reihenfolge, und derselbe Seed
// erzeugte zwei verschiedene Dungeons.
self.floors.values().filter(|f| f.number == number).min_by_key(|f| &f.id)
}
}