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:
535
src/data/loader.rs
Normal file
535
src/data/loader.rs
Normal file
@@ -0,0 +1,535 @@
|
||||
//! Mod-Ladepipeline (Ticket 03) und Fehlerstrategie (Ticket 04).
|
||||
//!
|
||||
//! Drei Phasen: Manifeste → Einlesen → Aufloesen. In Phase 2 wird nichts
|
||||
//! aufgeloest, dadurch gibt es **keine Vorwaertsreferenzen** — ein Mod darf auf
|
||||
//! etwas zeigen, das erst ein spaeterer liefert.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use toml::Spanned;
|
||||
|
||||
use super::registries::{Category, CharacterOption, Item, Registries, Slot};
|
||||
|
||||
/// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus
|
||||
/// Verwerfen des **einzelnen Eintrags**.
|
||||
///
|
||||
/// Handgeschrieben statt `thiserror`: drei Varianten, ein Konsument — und in
|
||||
/// einem Ticket, das Rust lernen soll, ist dieses `match` der Zweck.
|
||||
#[derive(Debug)]
|
||||
pub enum LoadError {
|
||||
CoreMissing,
|
||||
DependencyMissing { module: String, dep: String },
|
||||
DependencyOrder { module: String, dep: String },
|
||||
}
|
||||
|
||||
impl fmt::Display for LoadError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
LoadError::CoreMissing => write!(
|
||||
f,
|
||||
"Das Kernspiel wurde nicht gefunden. Erwartet wird ein Ordner \
|
||||
'mods/core' mit einer manifest.toml."
|
||||
),
|
||||
LoadError::DependencyMissing { module, dep } => {
|
||||
write!(f, "'{module}' braucht '{dep}', aber '{dep}' ist nicht installiert.")
|
||||
}
|
||||
LoadError::DependencyOrder { module, dep } => write!(
|
||||
f,
|
||||
"'{module}' muss nach '{dep}' geladen werden, steht in der \
|
||||
load_order.toml aber davor."
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Manifest und Mod-Erkennung
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Manifest {
|
||||
id: String,
|
||||
#[allow(dead_code)]
|
||||
name: String,
|
||||
/// Einziger Leser ist der Save-Header (Ticket 03/09).
|
||||
#[allow(dead_code)]
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
depends: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ModInfo {
|
||||
id: String,
|
||||
depends: Vec<String>,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
/// Zwei Suchpfade (Ticket 03): `mods/` neben der Binary und `mods/` im
|
||||
/// Benutzerprofil. Bei gleicher Mod-ID **gewinnt das Profil** — es existiert
|
||||
/// genau dafuer, dass die Sachen des Spielers eine Neuinstallation ueberleben.
|
||||
fn search_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
if let Ok(exe) = std::env::current_exe()
|
||||
&& let Some(dir) = exe.parent()
|
||||
{
|
||||
paths.push(dir.join("mods"));
|
||||
}
|
||||
// Entwicklungsfall: `cargo run` legt die Binary nach target/debug/.
|
||||
paths.push(PathBuf::from("mods"));
|
||||
|
||||
if let Some(dir) = profile_dir() {
|
||||
paths.push(dir.join("mods"));
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
/// `data_dir()` ueber die Crate `directories` — Windows, Linux *und* macOS.
|
||||
/// Keine Trennung von Daten und Konfiguration: Mods, `load_order.toml`, Save
|
||||
/// und Logs liegen in einem Ordner (Ticket 03).
|
||||
pub fn profile_dir() -> Option<PathBuf> {
|
||||
directories::ProjectDirs::from("", "", "rustkeep").map(|d| d.data_dir().to_path_buf())
|
||||
}
|
||||
|
||||
fn discover(warnings: &mut Vec<String>) -> Vec<ModInfo> {
|
||||
let mut found: HashMap<String, ModInfo> = HashMap::new();
|
||||
|
||||
for base in search_paths() {
|
||||
let Ok(entries) = fs::read_dir(&base) else { continue };
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let manifest_path = path.join("manifest.toml");
|
||||
let Ok(raw) = fs::read_to_string(&manifest_path) else {
|
||||
// Ordner ohne Manifest ist kein Mod — sonst legt eine vergessene
|
||||
// screenshots/-Kopie das Spiel lahm (Ticket 03).
|
||||
warnings.push(format!("{}: kein manifest.toml, übersprungen", path.display()));
|
||||
continue;
|
||||
};
|
||||
|
||||
match toml::from_str::<Manifest>(&raw) {
|
||||
Ok(m) => {
|
||||
if let Some(old) = found.get(&m.id) {
|
||||
warnings.push(format!(
|
||||
"'{}' liegt zweimal: {} gewinnt gegen {}",
|
||||
m.id,
|
||||
path.display(),
|
||||
old.path.display()
|
||||
));
|
||||
}
|
||||
found.insert(
|
||||
m.id.clone(),
|
||||
ModInfo { id: m.id, depends: m.depends, path },
|
||||
);
|
||||
}
|
||||
Err(e) => warnings.push(format!("{}: {}", manifest_path.display(), e.message())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
found.into_values().collect()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Ladereihenfolge — selbstheilend (Ticket 03)
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Default, Deserialize, serde::Serialize)]
|
||||
struct LoadOrder {
|
||||
#[serde(default)]
|
||||
mods: Vec<String>,
|
||||
}
|
||||
|
||||
fn resolve_order(found: &[ModInfo], warnings: &mut Vec<String>) -> Vec<String> {
|
||||
let file = profile_dir().map(|d| d.join("load_order.toml"));
|
||||
|
||||
let mut order: Vec<String> = file
|
||||
.as_ref()
|
||||
.and_then(|p| fs::read_to_string(p).ok())
|
||||
.and_then(|raw| toml::from_str::<LoadOrder>(&raw).ok())
|
||||
.map(|lo| lo.mods)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Eintrag ohne Ordner: Warnzeile und ueberspringen. Das ist der Normalfall
|
||||
// nach dem Deinstallieren und darf den Start nicht verhindern.
|
||||
order.retain(|id| {
|
||||
let exists = found.iter().any(|m| &m.id == id);
|
||||
if !exists {
|
||||
warnings.push(format!("'{id}' steht in load_order.toml, ist aber nicht installiert"));
|
||||
}
|
||||
exists
|
||||
});
|
||||
|
||||
// Ordner ohne Eintrag: hinten anhaengen. Ein entpackter Mod laeuft damit,
|
||||
// ohne dass der Spieler von der Sortierdatei wissen muss.
|
||||
let mut fresh: Vec<&str> = found
|
||||
.iter()
|
||||
.filter(|m| !order.contains(&m.id))
|
||||
.map(|m| m.id.as_str())
|
||||
.collect();
|
||||
fresh.sort_unstable();
|
||||
|
||||
if order.is_empty() {
|
||||
// Erststart: core zuerst, Rest alphabetisch.
|
||||
if let Some(pos) = fresh.iter().position(|id| *id == "core") {
|
||||
order.push(fresh.remove(pos).to_string());
|
||||
}
|
||||
}
|
||||
order.extend(fresh.into_iter().map(str::to_string));
|
||||
|
||||
if let Some(path) = file {
|
||||
let _ = fs::create_dir_all(path.parent().unwrap());
|
||||
if let Ok(text) = toml::to_string(&LoadOrder { mods: order.clone() }) {
|
||||
// Schreibfehler wird verschluckt — ein Spiel, das wegen seiner
|
||||
// Sortierdatei nicht startet, waere die Karikatur guter
|
||||
// Fehlerbehandlung (Ticket 04).
|
||||
let _ = fs::write(&path, text);
|
||||
}
|
||||
}
|
||||
order
|
||||
}
|
||||
|
||||
/// `depends` sortiert nicht, es **prueft** — das ist der Preis dafuer, dass ein
|
||||
/// Mensch die Reihenfolge pflegt (Ticket 03).
|
||||
fn check_depends(found: &[ModInfo], order: &[String]) -> Result<(), LoadError> {
|
||||
for (i, id) in order.iter().enumerate() {
|
||||
let Some(info) = found.iter().find(|m| &m.id == id) else { continue };
|
||||
|
||||
for dep in &info.depends {
|
||||
match order.iter().position(|o| o == dep) {
|
||||
None => {
|
||||
return Err(LoadError::DependencyMissing {
|
||||
module: id.clone(),
|
||||
dep: dep.clone(),
|
||||
});
|
||||
}
|
||||
Some(j) if j > i => {
|
||||
return Err(LoadError::DependencyOrder {
|
||||
module: id.clone(),
|
||||
dep: dep.clone(),
|
||||
});
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Phase 2 — Einlesen, zweistufig (Ticket 04)
|
||||
// ============================================================================
|
||||
|
||||
/// Jede Datei darf jede Registry-Tabelle enthalten (Ticket 03). `toml::Value`
|
||||
/// als Zwischenstufe ist der Grund, warum ein kaputter Eintrag nur *sich selbst*
|
||||
/// mitreisst und nicht die ganze Datei.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ModFile {
|
||||
#[serde(default)]
|
||||
items: Vec<Spanned<toml::Value>>,
|
||||
#[serde(default)]
|
||||
slots: Vec<Spanned<toml::Value>>,
|
||||
#[serde(default)]
|
||||
categories: Vec<Spanned<toml::Value>>,
|
||||
#[serde(default)]
|
||||
options: Vec<Spanned<toml::Value>>,
|
||||
}
|
||||
|
||||
/// Damit die vier Registry-Typen durch dieselbe Einlese-Schleife passen.
|
||||
trait Entry: DeserializeOwned {
|
||||
fn id(&self) -> &str;
|
||||
fn set_id(&mut self, id: String);
|
||||
fn overrides(&self) -> Option<&str>;
|
||||
}
|
||||
|
||||
macro_rules! impl_entry {
|
||||
($t:ty) => {
|
||||
impl Entry for $t {
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
fn set_id(&mut self, id: String) {
|
||||
self.id = id;
|
||||
}
|
||||
fn overrides(&self) -> Option<&str> {
|
||||
self.overrides.as_deref()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
impl_entry!(Item);
|
||||
impl_entry!(Slot);
|
||||
impl_entry!(Category);
|
||||
impl_entry!(CharacterOption);
|
||||
|
||||
struct FileCtx<'a> {
|
||||
module: &'a str,
|
||||
/// Pfad relativ zum Mod-Ordner — die Mod-ID steht ohnehin vorn.
|
||||
file: String,
|
||||
source: &'a str,
|
||||
}
|
||||
|
||||
/// Zeile aus dem Byte-Offset. `toml::de::Error` und `Spanned` liefern nur
|
||||
/// Offsets, keine Zeilen (gegen die Doku geprueft).
|
||||
fn line_of(source: &str, offset: usize) -> usize {
|
||||
source[..offset.min(source.len())].matches('\n').count() + 1
|
||||
}
|
||||
|
||||
fn parse_entries<T: Entry>(
|
||||
raw: Vec<Spanned<toml::Value>>,
|
||||
ctx: &FileCtx<'_>,
|
||||
into: &mut HashMap<String, T>,
|
||||
warnings: &mut Vec<String>,
|
||||
) {
|
||||
for (index, spanned) in raw.into_iter().enumerate() {
|
||||
let line = line_of(ctx.source, spanned.span().start);
|
||||
let value = spanned.into_inner();
|
||||
|
||||
let mut entry = match T::deserialize(value) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
// Ein Eintrag ohne lesbare `id` kann sich nicht benennen und
|
||||
// heisst nach seiner Position (Ticket 04).
|
||||
warnings.push(format!(
|
||||
"{}/{}:{}: [{}] — {}",
|
||||
ctx.module, ctx.file, line, index, e.message()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Der Lader stellt manifest.id automatisch voran (Ticket 03).
|
||||
let own_id = format!("{}:{}", ctx.module, entry.id());
|
||||
|
||||
// `overrides` ersetzt den genannten Eintrag und uebernimmt dessen ID —
|
||||
// die eigene wird dann gar nicht registriert (Ticket 03).
|
||||
let key = match entry.overrides() {
|
||||
Some(target) => {
|
||||
let target = target.to_string();
|
||||
if !into.contains_key(&target) {
|
||||
warnings.push(format!(
|
||||
"{}/{}:{}: {} überschreibt '{}', das es nicht gibt — verworfen",
|
||||
ctx.module, ctx.file, line, own_id, target
|
||||
));
|
||||
continue;
|
||||
}
|
||||
warnings.push(format!("{} überschreibt '{}'", ctx.module, target));
|
||||
target
|
||||
}
|
||||
None => {
|
||||
if into.contains_key(&own_id) {
|
||||
warnings.push(format!("{} überschreibt '{}'", ctx.module, own_id));
|
||||
}
|
||||
own_id
|
||||
}
|
||||
};
|
||||
|
||||
entry.set_id(key.clone());
|
||||
into.insert(key, entry);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Phase 3 — Aufloesen
|
||||
// ============================================================================
|
||||
|
||||
fn resolve(reg: &mut Registries, warnings: &mut Vec<String>) {
|
||||
let known_slots: Vec<String> = reg.slots.keys().cloned().collect();
|
||||
reg.items.retain(|id, item| match &item.slot {
|
||||
Some(slot) if !known_slots.contains(slot) => {
|
||||
warnings.push(format!("{id}: Slot '{slot}' existiert nicht — verworfen"));
|
||||
false
|
||||
}
|
||||
_ => true,
|
||||
});
|
||||
|
||||
let known_categories: Vec<String> = reg.categories.keys().cloned().collect();
|
||||
reg.options.retain(|id, option| {
|
||||
if known_categories.contains(&option.category) {
|
||||
true
|
||||
} else {
|
||||
warnings.push(format!(
|
||||
"{id}: Kategorie '{}' existiert nicht — verworfen",
|
||||
option.category
|
||||
));
|
||||
false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Einstiegspunkt
|
||||
// ============================================================================
|
||||
|
||||
pub fn load_mods() -> Result<(Registries, Vec<String>), LoadError> {
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
// Phase 1 — Manifeste, Reihenfolge, depends
|
||||
let found = discover(&mut warnings);
|
||||
if !found.iter().any(|m| m.id == "core") {
|
||||
return Err(LoadError::CoreMissing);
|
||||
}
|
||||
let order = resolve_order(&found, &mut warnings);
|
||||
check_depends(&found, &order)?;
|
||||
|
||||
// Phase 2 — Einlesen in Ladereihenfolge
|
||||
let mut reg = Registries::default();
|
||||
for id in &order {
|
||||
let Some(info) = found.iter().find(|m| &m.id == id) else { continue };
|
||||
|
||||
for file in toml_files(&info.path) {
|
||||
let name = file
|
||||
.strip_prefix(&info.path)
|
||||
.unwrap_or(&file)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
let Ok(source) = fs::read_to_string(&file) else { continue };
|
||||
|
||||
// Kaputte Syntax reisst die ganze Datei mit — da kommt der Parser
|
||||
// schlicht nicht weiter. Das ist keine Entscheidung, sondern eine
|
||||
// Tatsache (Ticket 04).
|
||||
let parsed: ModFile = match toml::from_str(&source) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let line = e.span().map(|s| line_of(&source, s.start)).unwrap_or(0);
|
||||
warnings.push(format!("{}/{}:{}: {}", info.id, name, line, e.message()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let ctx = FileCtx { module: &info.id, file: name, source: &source };
|
||||
parse_entries(parsed.items, &ctx, &mut reg.items, &mut warnings);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3 — Referenzen aufloesen
|
||||
resolve(&mut reg, &mut warnings);
|
||||
|
||||
Ok((reg, warnings))
|
||||
}
|
||||
|
||||
fn toml_files(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut files: Vec<PathBuf> = fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
|
||||
.filter(|p| p.file_name().is_some_and(|n| n != "manifest.toml"))
|
||||
.collect();
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Warnungen ins Log (Ticket 04)
|
||||
// ============================================================================
|
||||
|
||||
/// Schreibt die Warnliste, behaelt die letzten zehn Laeufe und gibt den
|
||||
/// Dateinamen zurueck. Ein sauberer Lauf legt keine Datei an.
|
||||
pub fn write_log(warnings: &[String]) -> Option<String> {
|
||||
if warnings.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let dir = profile_dir()?.join("logs");
|
||||
fs::create_dir_all(&dir).ok()?;
|
||||
|
||||
// ponytail: Sekunden seit Epoch statt JJJJMMTT-HHMMSS. Sortierbar, und die
|
||||
// Alternative waere Datumsarithmetik von Hand oder eine Crate dafuer.
|
||||
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
|
||||
let name = format!("load-{stamp}.log");
|
||||
fs::write(dir.join(&name), warnings.join("\n")).ok()?;
|
||||
|
||||
let mut old: Vec<PathBuf> = fs::read_dir(&dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with("load-") && n.ends_with(".log"))
|
||||
})
|
||||
.collect();
|
||||
old.sort();
|
||||
for path in old.iter().rev().skip(10) {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
|
||||
Some(name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zeilennummer_aus_offset() {
|
||||
let src = "a\nb\nc";
|
||||
assert_eq!(line_of(src, 0), 1);
|
||||
assert_eq!(line_of(src, 2), 2);
|
||||
assert_eq!(line_of(src, 4), 3);
|
||||
}
|
||||
|
||||
/// Der Check aus Ticket 04: Ein kaputtes Kernspiel faellt beim `cargo test`
|
||||
/// auf, nicht beim Spieler — und die Ladelogik bleibt frei von Sonderregeln.
|
||||
#[test]
|
||||
fn core_laedt_ohne_warnungen() {
|
||||
let (reg, warnings) = load_mods().expect("core muss laden");
|
||||
let relevant: Vec<&String> =
|
||||
warnings.iter().filter(|w| !w.contains("load_order.toml")).collect();
|
||||
assert!(relevant.is_empty(), "core lädt nicht sauber: {relevant:#?}");
|
||||
assert!(reg.items.contains_key("core:iron_sword"));
|
||||
assert!(reg.slots.contains_key("core:hand"));
|
||||
assert!(reg.options.contains_key("core:dwarf"));
|
||||
}
|
||||
|
||||
/// Ticket 04 versprach, dass ein kaputter Eintrag nur *sich selbst*
|
||||
/// mitreisst. Das ist mit serde nicht gratis — hier wird es geprueft.
|
||||
#[test]
|
||||
fn kaputter_eintrag_reisst_die_nachbarn_nicht_mit() {
|
||||
let source = r#"
|
||||
[[items]]
|
||||
id = "gut"
|
||||
name = "Gut"
|
||||
|
||||
[[items]]
|
||||
id = "kaputt"
|
||||
name = 42
|
||||
|
||||
[[items]]
|
||||
id = "auch_gut"
|
||||
name = "Auch gut"
|
||||
"#;
|
||||
let parsed: ModFile = toml::from_str(source).expect("Syntax ist in Ordnung");
|
||||
let ctx = FileCtx { module: "test", file: "items.toml".into(), source };
|
||||
let mut items: HashMap<String, Item> = HashMap::new();
|
||||
let mut warnings = Vec::new();
|
||||
parse_entries(parsed.items, &ctx, &mut items, &mut warnings);
|
||||
|
||||
assert_eq!(items.len(), 2, "die beiden heilen Eintraege muessen ueberleben");
|
||||
assert!(items.contains_key("test:gut"));
|
||||
assert!(items.contains_key("test:auch_gut"));
|
||||
assert_eq!(warnings.len(), 1);
|
||||
// `Spanned` zeigt auf den Anfang des Eintrags (`[[items]]`, Zeile 6),
|
||||
// nicht auf das schuldige Feld — genau der Zeilenbereich, den Ticket 04
|
||||
// zugesagt hat. Der Feldname steht dafuer in `message()`.
|
||||
assert!(warnings[0].contains("items.toml:6"), "Zeile fehlt: {}", warnings[0]);
|
||||
assert!(warnings[0].contains("expected a string"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user