Initial commit: SSH MUD server with data-driven world

Rust-based MUD server accepting SSH connections on port 2222.
Players connect with any SSH client, get dropped into a
data-driven world loaded from TOML files at startup.

Binary systems: SSH handling (russh), command parser, game state,
multiplayer broadcast, ANSI terminal rendering.

Data layer: world/ directory with regions, rooms, NPCs, and objects
defined as individual TOML files — no recompile needed to modify.

Commands: look, movement (n/s/e/w/u/d), say, who, help, quit.
Made-with: Cursor
This commit is contained in:
AI Agent
2026-03-14 13:24:34 -06:00
commit c82f57a720
23 changed files with 3477 additions and 0 deletions

281
src/world.rs Normal file
View File

@@ -0,0 +1,281 @@
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
// --- On-disk TOML schemas ---
#[derive(Deserialize)]
pub struct Manifest {
pub name: String,
pub spawn_room: String,
}
#[derive(Deserialize)]
pub struct RegionFile {
pub name: String,
#[serde(default)]
pub description: String,
}
#[derive(Deserialize)]
pub struct RoomFile {
pub name: String,
pub description: String,
#[serde(default)]
pub exits: HashMap<String, String>,
}
#[derive(Deserialize)]
pub struct NpcFile {
pub name: String,
pub description: String,
pub room: String,
#[serde(default)]
pub dialogue: Option<String>,
}
#[derive(Deserialize)]
pub struct ObjectFile {
pub name: String,
pub description: String,
#[serde(default)]
pub room: Option<String>,
#[serde(default)]
pub kind: Option<String>,
}
// --- Runtime types ---
pub struct Room {
pub id: String,
pub region: String,
pub name: String,
pub description: String,
pub exits: HashMap<String, String>,
pub npcs: Vec<String>,
pub objects: Vec<String>,
}
pub struct Npc {
pub id: String,
pub name: String,
pub description: String,
pub room: String,
pub dialogue: Option<String>,
}
pub struct Object {
pub id: String,
pub name: String,
pub description: String,
pub room: Option<String>,
pub kind: Option<String>,
}
pub struct World {
pub name: String,
pub spawn_room: String,
pub rooms: HashMap<String, Room>,
pub npcs: HashMap<String, Npc>,
pub objects: HashMap<String, Object>,
}
impl World {
pub fn load(world_dir: &Path) -> Result<Self, String> {
let manifest_path = world_dir.join("manifest.toml");
let manifest: Manifest = load_toml(&manifest_path)?;
let mut rooms = HashMap::new();
let mut npcs = HashMap::new();
let mut objects = HashMap::new();
let entries = std::fs::read_dir(world_dir)
.map_err(|e| format!("Cannot read world dir {}: {e}", world_dir.display()))?;
let mut region_dirs: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
.collect();
region_dirs.sort_by_key(|e| e.file_name());
for entry in region_dirs {
let region_name = entry.file_name().to_string_lossy().to_string();
let region_path = entry.path();
let region_toml = region_path.join("region.toml");
if !region_toml.exists() {
log::debug!("Skipping directory without region.toml: {}", region_path.display());
continue;
}
let _region_meta: RegionFile = load_toml(&region_toml)?;
log::info!("Loading region: {region_name}");
load_entities_from_dir(
&region_path.join("rooms"),
&region_name,
&mut |id, content| {
let rf: RoomFile = toml::from_str(content)
.map_err(|e| format!("Bad room {id}: {e}"))?;
rooms.insert(
id.clone(),
Room {
id: id.clone(),
region: region_name.clone(),
name: rf.name,
description: rf.description,
exits: rf.exits,
npcs: Vec::new(),
objects: Vec::new(),
},
);
Ok(())
},
)?;
load_entities_from_dir(
&region_path.join("npcs"),
&region_name,
&mut |id, content| {
let nf: NpcFile = toml::from_str(content)
.map_err(|e| format!("Bad npc {id}: {e}"))?;
npcs.insert(
id.clone(),
Npc {
id: id.clone(),
name: nf.name,
description: nf.description,
room: nf.room.clone(),
dialogue: nf.dialogue,
},
);
Ok(())
},
)?;
load_entities_from_dir(
&region_path.join("objects"),
&region_name,
&mut |id, content| {
let of: ObjectFile = toml::from_str(content)
.map_err(|e| format!("Bad object {id}: {e}"))?;
objects.insert(
id.clone(),
Object {
id: id.clone(),
name: of.name,
description: of.description,
room: of.room,
kind: of.kind,
},
);
Ok(())
},
)?;
}
// Place NPCs and objects into their rooms
for npc in npcs.values() {
if let Some(room) = rooms.get_mut(&npc.room) {
room.npcs.push(npc.id.clone());
}
}
for obj in objects.values() {
if let Some(ref room_id) = obj.room {
if let Some(room) = rooms.get_mut(room_id) {
room.objects.push(obj.id.clone());
}
}
}
// Validate
if !rooms.contains_key(&manifest.spawn_room) {
return Err(format!(
"Spawn room '{}' not found in loaded rooms",
manifest.spawn_room
));
}
for room in rooms.values() {
for (dir, target) in &room.exits {
if !rooms.contains_key(target) {
return Err(format!(
"Room '{}' exit '{dir}' points to unknown room '{target}'",
room.id
));
}
}
}
log::info!(
"World '{}' loaded: {} rooms, {} npcs, {} objects",
manifest.name,
rooms.len(),
npcs.len(),
objects.len()
);
Ok(World {
name: manifest.name,
spawn_room: manifest.spawn_room,
rooms,
npcs,
objects,
})
}
pub fn get_room(&self, id: &str) -> Option<&Room> {
self.rooms.get(id)
}
pub fn get_npc(&self, id: &str) -> Option<&Npc> {
self.npcs.get(id)
}
pub fn get_object(&self, id: &str) -> Option<&Object> {
self.objects.get(id)
}
}
fn load_toml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Cannot read {}: {e}", path.display()))?;
toml::from_str(&content).map_err(|e| format!("Bad TOML in {}: {e}", path.display()))
}
fn load_entities_from_dir(
dir: &Path,
region: &str,
handler: &mut dyn FnMut(String, &str) -> Result<(), String>,
) -> Result<(), String> {
if !dir.exists() {
return Ok(());
}
let mut entries: Vec<_> = std::fs::read_dir(dir)
.map_err(|e| format!("Cannot read {}: {e}", dir.display()))?
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "toml")
.unwrap_or(false)
})
.collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let stem = entry
.path()
.file_stem()
.unwrap()
.to_string_lossy()
.to_string();
let id = format!("{region}:{stem}");
let content = std::fs::read_to_string(entry.path())
.map_err(|e| format!("Cannot read {}: {e}", entry.path().display()))?;
handler(id, &content)?;
}
Ok(())
}