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:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
2159
Cargo.lock
generated
Normal file
2159
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
Cargo.toml
Normal file
12
Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "mudserver"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
russh = { version = "0.54", default-features = false, features = ["ring"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
54
src/ansi.rs
Normal file
54
src/ansi.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
pub const RESET: &str = "\x1b[0m";
|
||||
pub const BOLD: &str = "\x1b[1m";
|
||||
pub const DIM: &str = "\x1b[2m";
|
||||
pub const RED: &str = "\x1b[31m";
|
||||
pub const GREEN: &str = "\x1b[32m";
|
||||
pub const YELLOW: &str = "\x1b[33m";
|
||||
pub const BLUE: &str = "\x1b[34m";
|
||||
pub const MAGENTA: &str = "\x1b[35m";
|
||||
pub const CYAN: &str = "\x1b[36m";
|
||||
pub const WHITE: &str = "\x1b[37m";
|
||||
pub const CLEAR_SCREEN: &str = "\x1b[2J\x1b[H";
|
||||
|
||||
pub fn color(c: &str, text: &str) -> String {
|
||||
format!("{c}{text}{RESET}")
|
||||
}
|
||||
|
||||
pub fn bold(text: &str) -> String {
|
||||
format!("{BOLD}{text}{RESET}")
|
||||
}
|
||||
|
||||
pub fn room_name(name: &str) -> String {
|
||||
format!("{BOLD}{CYAN}{name}{RESET}")
|
||||
}
|
||||
|
||||
pub fn player_name(name: &str) -> String {
|
||||
format!("{BOLD}{GREEN}{name}{RESET}")
|
||||
}
|
||||
|
||||
pub fn direction(dir: &str) -> String {
|
||||
format!("{YELLOW}{dir}{RESET}")
|
||||
}
|
||||
|
||||
pub fn system_msg(text: &str) -> String {
|
||||
format!("{DIM}{text}{RESET}")
|
||||
}
|
||||
|
||||
pub fn error_msg(text: &str) -> String {
|
||||
format!("{RED}{text}{RESET}")
|
||||
}
|
||||
|
||||
pub fn prompt() -> String {
|
||||
format!("{BOLD}{MAGENTA}> {RESET}")
|
||||
}
|
||||
|
||||
pub fn welcome_banner() -> String {
|
||||
format!(
|
||||
"\r\n{BOLD}{CYAN}\
|
||||
╔════════════════════════════════════════╗\r\n\
|
||||
║ Welcome to the MUD Server ║\r\n\
|
||||
║ A Text-Based Adventure World ║\r\n\
|
||||
╚════════════════════════════════════════╝\r\n\
|
||||
{RESET}\r\n"
|
||||
)
|
||||
}
|
||||
416
src/commands.rs
Normal file
416
src/commands.rs
Normal file
@@ -0,0 +1,416 @@
|
||||
use russh::server::Session;
|
||||
use russh::{ChannelId, CryptoVec};
|
||||
|
||||
use crate::ansi;
|
||||
use crate::game::SharedState;
|
||||
|
||||
pub struct BroadcastMsg {
|
||||
pub channel: ChannelId,
|
||||
pub handle: russh::server::Handle,
|
||||
pub data: CryptoVec,
|
||||
}
|
||||
|
||||
pub struct CommandResult {
|
||||
pub output: String,
|
||||
pub broadcasts: Vec<BroadcastMsg>,
|
||||
pub quit: bool,
|
||||
}
|
||||
|
||||
const DIRECTION_ALIASES: &[(&str, &str)] = &[
|
||||
("n", "north"),
|
||||
("s", "south"),
|
||||
("e", "east"),
|
||||
("w", "west"),
|
||||
("u", "up"),
|
||||
("d", "down"),
|
||||
];
|
||||
|
||||
fn resolve_direction(input: &str) -> &str {
|
||||
for &(alias, full) in DIRECTION_ALIASES {
|
||||
if input == alias {
|
||||
return full;
|
||||
}
|
||||
}
|
||||
input
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
input: &str,
|
||||
player_id: usize,
|
||||
state: &SharedState,
|
||||
session: &mut Session,
|
||||
channel: ChannelId,
|
||||
) -> Result<bool, russh::Error> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
send(session, channel, &ansi::prompt())?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let (cmd, args) = match input.split_once(' ') {
|
||||
Some((c, a)) => (c.to_lowercase(), a.trim().to_string()),
|
||||
None => (input.to_lowercase(), String::new()),
|
||||
};
|
||||
|
||||
let result = match cmd.as_str() {
|
||||
"look" | "l" => cmd_look(player_id, state).await,
|
||||
"go" => cmd_go(player_id, &args, state).await,
|
||||
"north" | "south" | "east" | "west" | "up" | "down" | "n" | "s" | "e" | "w" | "u"
|
||||
| "d" => cmd_go(player_id, resolve_direction(&cmd), state).await,
|
||||
"say" | "'" => cmd_say(player_id, &args, state).await,
|
||||
"who" => cmd_who(player_id, state).await,
|
||||
"help" | "h" | "?" => cmd_help(),
|
||||
"quit" | "exit" => CommandResult {
|
||||
output: format!("{}\r\n", ansi::system_msg("Farewell, adventurer...")),
|
||||
broadcasts: Vec::new(),
|
||||
quit: true,
|
||||
},
|
||||
_ => CommandResult {
|
||||
output: format!(
|
||||
"{}\r\n",
|
||||
ansi::error_msg(&format!("Unknown command: '{cmd}'. Type 'help' for commands."))
|
||||
),
|
||||
broadcasts: Vec::new(),
|
||||
quit: false,
|
||||
},
|
||||
};
|
||||
|
||||
send(session, channel, &result.output)?;
|
||||
|
||||
for msg in result.broadcasts {
|
||||
let _ = msg.handle.data(msg.channel, msg.data).await;
|
||||
}
|
||||
|
||||
if result.quit {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
send(session, channel, &ansi::prompt())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn send(session: &mut Session, channel: ChannelId, text: &str) -> Result<(), russh::Error> {
|
||||
session.data(channel, CryptoVec::from(text.as_bytes()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_room_view(
|
||||
room_id: &str,
|
||||
player_id: usize,
|
||||
state: &tokio::sync::MutexGuard<'_, crate::game::GameState>,
|
||||
) -> String {
|
||||
let room = match state.world.get_room(room_id) {
|
||||
Some(r) => r,
|
||||
None => return format!("{}\r\n", ansi::error_msg("You are in the void.")),
|
||||
};
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
"\r\n{} {}\r\n",
|
||||
ansi::room_name(&room.name),
|
||||
ansi::system_msg(&format!("[{}]", room.region))
|
||||
));
|
||||
out.push_str(&format!(" {}\r\n", room.description));
|
||||
|
||||
if !room.npcs.is_empty() {
|
||||
let npc_names: Vec<String> = room
|
||||
.npcs
|
||||
.iter()
|
||||
.filter_map(|id| state.world.get_npc(id))
|
||||
.map(|n| ansi::color(ansi::YELLOW, &n.name))
|
||||
.collect();
|
||||
if !npc_names.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"\r\n{}{}\r\n",
|
||||
ansi::color(ansi::DIM, "Present: "),
|
||||
npc_names.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !room.objects.is_empty() {
|
||||
let obj_names: Vec<String> = room
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|id| state.world.get_object(id))
|
||||
.map(|o| ansi::color(ansi::CYAN, &o.name))
|
||||
.collect();
|
||||
if !obj_names.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"{}{}\r\n",
|
||||
ansi::color(ansi::DIM, "You see: "),
|
||||
obj_names.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let others = state.players_in_room(room_id, player_id);
|
||||
if !others.is_empty() {
|
||||
let names: Vec<String> = others
|
||||
.iter()
|
||||
.map(|c| ansi::player_name(&c.player.name))
|
||||
.collect();
|
||||
out.push_str(&format!(
|
||||
"{}{}\r\n",
|
||||
ansi::color(ansi::GREEN, "Players here: "),
|
||||
names.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if !room.exits.is_empty() {
|
||||
let mut dirs: Vec<&String> = room.exits.keys().collect();
|
||||
dirs.sort();
|
||||
let dir_strs: Vec<String> = dirs.iter().map(|d| ansi::direction(d)).collect();
|
||||
out.push_str(&format!(
|
||||
"{} {}\r\n",
|
||||
ansi::color(ansi::DIM, "Exits:"),
|
||||
dir_strs.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
async fn cmd_look(player_id: usize, state: &SharedState) -> CommandResult {
|
||||
let state = state.lock().await;
|
||||
let room_id = match state.players.get(&player_id) {
|
||||
Some(c) => c.player.room_id.clone(),
|
||||
None => {
|
||||
return CommandResult {
|
||||
output: format!("{}\r\n", ansi::error_msg("You don't seem to exist.")),
|
||||
broadcasts: Vec::new(),
|
||||
quit: false,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
CommandResult {
|
||||
output: render_room_view(&room_id, player_id, &state),
|
||||
broadcasts: Vec::new(),
|
||||
quit: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_go(player_id: usize, direction: &str, state: &SharedState) -> CommandResult {
|
||||
let direction_lower = direction.to_lowercase();
|
||||
let direction = resolve_direction(&direction_lower);
|
||||
let mut state = state.lock().await;
|
||||
|
||||
let (old_room_id, new_room_id, player_name) = {
|
||||
let conn = match state.players.get(&player_id) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return CommandResult {
|
||||
output: format!("{}\r\n", ansi::error_msg("You don't exist.")),
|
||||
broadcasts: Vec::new(),
|
||||
quit: false,
|
||||
}
|
||||
}
|
||||
};
|
||||
let room = match state.world.get_room(&conn.player.room_id) {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return CommandResult {
|
||||
output: format!("{}\r\n", ansi::error_msg("You are in the void.")),
|
||||
broadcasts: Vec::new(),
|
||||
quit: false,
|
||||
}
|
||||
}
|
||||
};
|
||||
let dest = match room.exits.get(direction) {
|
||||
Some(id) => id.clone(),
|
||||
None => {
|
||||
return CommandResult {
|
||||
output: format!(
|
||||
"{}\r\n",
|
||||
ansi::error_msg(&format!("You can't go {direction}."))
|
||||
),
|
||||
broadcasts: Vec::new(),
|
||||
quit: false,
|
||||
}
|
||||
}
|
||||
};
|
||||
(
|
||||
conn.player.room_id.clone(),
|
||||
dest,
|
||||
conn.player.name.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
let leave_msg = CryptoVec::from(
|
||||
format!(
|
||||
"{}\r\n{}",
|
||||
ansi::system_msg(&format!("{player_name} heads {direction}.")),
|
||||
ansi::prompt()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
let mut broadcasts = Vec::new();
|
||||
for conn in state.players_in_room(&old_room_id, player_id) {
|
||||
broadcasts.push(BroadcastMsg {
|
||||
channel: conn.channel,
|
||||
handle: conn.handle.clone(),
|
||||
data: leave_msg.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(conn) = state.players.get_mut(&player_id) {
|
||||
conn.player.room_id = new_room_id.clone();
|
||||
}
|
||||
|
||||
let arrive_msg = CryptoVec::from(
|
||||
format!(
|
||||
"\r\n{}\r\n{}",
|
||||
ansi::system_msg(&format!("{player_name} arrives.")),
|
||||
ansi::prompt()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
for conn in state.players_in_room(&new_room_id, player_id) {
|
||||
broadcasts.push(BroadcastMsg {
|
||||
channel: conn.channel,
|
||||
handle: conn.handle.clone(),
|
||||
data: arrive_msg.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let output = render_room_view(&new_room_id, player_id, &state);
|
||||
|
||||
CommandResult {
|
||||
output,
|
||||
broadcasts,
|
||||
quit: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_say(player_id: usize, message: &str, state: &SharedState) -> CommandResult {
|
||||
if message.is_empty() {
|
||||
return CommandResult {
|
||||
output: format!("{}\r\n", ansi::error_msg("Say what?")),
|
||||
broadcasts: Vec::new(),
|
||||
quit: false,
|
||||
};
|
||||
}
|
||||
|
||||
let state = state.lock().await;
|
||||
let conn = match state.players.get(&player_id) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return CommandResult {
|
||||
output: format!("{}\r\n", ansi::error_msg("You don't exist.")),
|
||||
broadcasts: Vec::new(),
|
||||
quit: false,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let name = &conn.player.name;
|
||||
let room_id = conn.player.room_id.clone();
|
||||
|
||||
let self_msg = format!(
|
||||
"{}You say: {}{}\r\n",
|
||||
ansi::BOLD,
|
||||
ansi::RESET,
|
||||
ansi::color(ansi::WHITE, message)
|
||||
);
|
||||
|
||||
let other_msg = CryptoVec::from(
|
||||
format!(
|
||||
"\r\n{} says: {}{}\r\n{}",
|
||||
ansi::player_name(name),
|
||||
ansi::RESET,
|
||||
ansi::color(ansi::WHITE, message),
|
||||
ansi::prompt()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
|
||||
let mut broadcasts = Vec::new();
|
||||
for other in state.players_in_room(&room_id, player_id) {
|
||||
broadcasts.push(BroadcastMsg {
|
||||
channel: other.channel,
|
||||
handle: other.handle.clone(),
|
||||
data: other_msg.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
CommandResult {
|
||||
output: self_msg,
|
||||
broadcasts,
|
||||
quit: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_who(player_id: usize, state: &SharedState) -> CommandResult {
|
||||
let state = state.lock().await;
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
"\r\n{}\r\n",
|
||||
ansi::bold("=== Who's Online ===")
|
||||
));
|
||||
|
||||
let self_name = state
|
||||
.players
|
||||
.get(&player_id)
|
||||
.map(|c| c.player.name.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
for conn in state.players.values() {
|
||||
let room_name = state
|
||||
.world
|
||||
.get_room(&conn.player.room_id)
|
||||
.map(|r| r.name.as_str())
|
||||
.unwrap_or("???");
|
||||
let marker = if conn.player.name == self_name {
|
||||
" (you)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
out.push_str(&format!(
|
||||
" {} — {}{}\r\n",
|
||||
ansi::player_name(&conn.player.name),
|
||||
ansi::room_name(room_name),
|
||||
ansi::system_msg(marker)
|
||||
));
|
||||
}
|
||||
|
||||
let count = state.players.len();
|
||||
out.push_str(&format!(
|
||||
"{}\r\n",
|
||||
ansi::system_msg(&format!("{count} player(s) online"))
|
||||
));
|
||||
|
||||
CommandResult {
|
||||
output: out,
|
||||
broadcasts: Vec::new(),
|
||||
quit: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_help() -> CommandResult {
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!("\r\n{}\r\n", ansi::bold("=== Commands ===")));
|
||||
let cmds = [
|
||||
("look, l", "Look around the current room"),
|
||||
(
|
||||
"go <dir>, north/n, south/s, east/e, west/w",
|
||||
"Move in a direction",
|
||||
),
|
||||
("say <msg>, ' <msg>", "Say something to players in the room"),
|
||||
("who", "See who's online"),
|
||||
("help, h, ?", "Show this help"),
|
||||
("quit, exit", "Leave the game"),
|
||||
];
|
||||
for (cmd, desc) in cmds {
|
||||
out.push_str(&format!(
|
||||
" {:<44} {}\r\n",
|
||||
ansi::color(ansi::YELLOW, cmd),
|
||||
ansi::color(ansi::DIM, desc)
|
||||
));
|
||||
}
|
||||
CommandResult {
|
||||
output: out,
|
||||
broadcasts: Vec::new(),
|
||||
quit: false,
|
||||
}
|
||||
}
|
||||
70
src/game.rs
Normal file
70
src/game.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use russh::server::Handle;
|
||||
use russh::ChannelId;
|
||||
|
||||
use crate::world::World;
|
||||
|
||||
pub struct Player {
|
||||
pub name: String,
|
||||
pub room_id: String,
|
||||
}
|
||||
|
||||
pub struct PlayerConnection {
|
||||
pub player: Player,
|
||||
pub channel: ChannelId,
|
||||
pub handle: Handle,
|
||||
}
|
||||
|
||||
pub struct GameState {
|
||||
pub world: World,
|
||||
pub players: HashMap<usize, PlayerConnection>,
|
||||
}
|
||||
|
||||
pub type SharedState = Arc<Mutex<GameState>>;
|
||||
|
||||
impl GameState {
|
||||
pub fn new(world: World) -> Self {
|
||||
GameState {
|
||||
world,
|
||||
players: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_room(&self) -> &str {
|
||||
&self.world.spawn_room
|
||||
}
|
||||
|
||||
pub fn add_player(&mut self, id: usize, name: String, channel: ChannelId, handle: Handle) {
|
||||
let room_id = self.world.spawn_room.clone();
|
||||
self.players.insert(
|
||||
id,
|
||||
PlayerConnection {
|
||||
player: Player { name, room_id },
|
||||
channel,
|
||||
handle,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn remove_player(&mut self, id: usize) -> Option<PlayerConnection> {
|
||||
self.players.remove(&id)
|
||||
}
|
||||
|
||||
pub fn players_in_room(&self, room_id: &str, exclude_id: usize) -> Vec<&PlayerConnection> {
|
||||
self.players
|
||||
.iter()
|
||||
.filter(|(&id, conn)| conn.player.room_id == room_id && id != exclude_id)
|
||||
.map(|(_, conn)| conn)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn all_player_names(&self) -> Vec<&str> {
|
||||
self.players
|
||||
.values()
|
||||
.map(|c| c.player.name.as_str())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
83
src/main.rs
Normal file
83
src/main.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
mod ansi;
|
||||
mod commands;
|
||||
mod game;
|
||||
mod ssh;
|
||||
mod world;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use russh::keys::ssh_key::rand_core::OsRng;
|
||||
use russh::server::Server as _;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
const DEFAULT_PORT: u16 = 2222;
|
||||
const DEFAULT_WORLD_DIR: &str = "./world";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
let mut port = DEFAULT_PORT;
|
||||
let mut world_dir = PathBuf::from(DEFAULT_WORLD_DIR);
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--port" | "-p" => {
|
||||
i += 1;
|
||||
port = args
|
||||
.get(i)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.expect("--port requires a number");
|
||||
}
|
||||
"--world" | "-w" => {
|
||||
i += 1;
|
||||
world_dir = PathBuf::from(
|
||||
args.get(i).expect("--world requires a path"),
|
||||
);
|
||||
}
|
||||
"--help" => {
|
||||
eprintln!("Usage: mudserver [--port PORT] [--world PATH]");
|
||||
eprintln!(" --port, -p Listen port (default: {DEFAULT_PORT})");
|
||||
eprintln!(" --world, -w World directory (default: {DEFAULT_WORLD_DIR})");
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => {
|
||||
eprintln!("Unknown argument: {other}");
|
||||
eprintln!("Run with --help for usage.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
log::info!("Loading world from: {}", world_dir.display());
|
||||
let loaded_world = world::World::load(&world_dir).unwrap_or_else(|e| {
|
||||
eprintln!("Failed to load world: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let key =
|
||||
russh::keys::PrivateKey::random(&mut OsRng, russh::keys::Algorithm::Ed25519).unwrap();
|
||||
|
||||
let config = russh::server::Config {
|
||||
inactivity_timeout: Some(std::time::Duration::from_secs(3600)),
|
||||
auth_rejection_time: std::time::Duration::from_secs(1),
|
||||
auth_rejection_time_initial: Some(std::time::Duration::from_secs(0)),
|
||||
keys: vec![key],
|
||||
..Default::default()
|
||||
};
|
||||
let config = Arc::new(config);
|
||||
|
||||
let state = Arc::new(Mutex::new(game::GameState::new(loaded_world)));
|
||||
let mut server = ssh::MudServer::new(state);
|
||||
|
||||
let listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap();
|
||||
log::info!("MUD server listening on 0.0.0.0:{port}");
|
||||
log::info!("Connect with: ssh <username>@localhost -p {port}");
|
||||
|
||||
server.run_on_socket(config, &listener).await.unwrap();
|
||||
}
|
||||
311
src/ssh.rs
Normal file
311
src/ssh.rs
Normal file
@@ -0,0 +1,311 @@
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use russh::server::{Auth, Handle, Msg, Server, Session};
|
||||
use russh::{Channel, ChannelId, CryptoVec, Pty};
|
||||
|
||||
use crate::ansi;
|
||||
use crate::commands;
|
||||
use crate::game::SharedState;
|
||||
|
||||
pub struct MudServer {
|
||||
pub state: SharedState,
|
||||
next_id: AtomicUsize,
|
||||
}
|
||||
|
||||
impl MudServer {
|
||||
pub fn new(state: SharedState) -> Self {
|
||||
MudServer {
|
||||
state,
|
||||
next_id: AtomicUsize::new(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Server for MudServer {
|
||||
type Handler = MudHandler;
|
||||
|
||||
fn new_client(&mut self, addr: Option<std::net::SocketAddr>) -> MudHandler {
|
||||
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
|
||||
log::info!("New connection (id={id}) from {addr:?}");
|
||||
MudHandler {
|
||||
id,
|
||||
username: String::new(),
|
||||
channel: None,
|
||||
handle: None,
|
||||
line_buffer: String::new(),
|
||||
state: self.state.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_session_error(&mut self, error: <Self::Handler as russh::server::Handler>::Error) {
|
||||
log::error!("Session error: {error:#?}");
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MudHandler {
|
||||
id: usize,
|
||||
username: String,
|
||||
channel: Option<ChannelId>,
|
||||
handle: Option<Handle>,
|
||||
line_buffer: String,
|
||||
state: SharedState,
|
||||
}
|
||||
|
||||
impl MudHandler {
|
||||
async fn register_player(&self, session: &mut Session, channel: ChannelId) {
|
||||
let handle = session.handle();
|
||||
let mut state = self.state.lock().await;
|
||||
state.add_player(self.id, self.username.clone(), channel, handle);
|
||||
|
||||
let spawn_room = state.spawn_room().to_string();
|
||||
let arrival = CryptoVec::from(
|
||||
format!(
|
||||
"\r\n{}\r\n{}",
|
||||
ansi::system_msg(&format!("{} has entered the world.", self.username)),
|
||||
ansi::prompt()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
let others: Vec<_> = state
|
||||
.players_in_room(&spawn_room, self.id)
|
||||
.iter()
|
||||
.map(|c| (c.channel, c.handle.clone()))
|
||||
.collect();
|
||||
drop(state);
|
||||
|
||||
for (ch, h) in others {
|
||||
let _ = h.data(ch, arrival.clone()).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_welcome(&self, session: &mut Session, channel: ChannelId) {
|
||||
let state = self.state.lock().await;
|
||||
let world_name = state.world.name.clone();
|
||||
drop(state);
|
||||
|
||||
let welcome = format!(
|
||||
"{}\r\n{}Welcome to {}, {}! Type {} to get started.\r\n\r\n",
|
||||
ansi::CLEAR_SCREEN,
|
||||
ansi::welcome_banner(),
|
||||
ansi::bold(&world_name),
|
||||
ansi::player_name(&self.username),
|
||||
ansi::color(ansi::YELLOW, "'help'")
|
||||
);
|
||||
let _ = session.data(channel, CryptoVec::from(welcome.as_bytes()));
|
||||
}
|
||||
|
||||
async fn show_room(&self, session: &mut Session, channel: ChannelId) {
|
||||
let state = self.state.lock().await;
|
||||
let room_id = match state.players.get(&self.id) {
|
||||
Some(c) => c.player.room_id.clone(),
|
||||
None => return,
|
||||
};
|
||||
let room = match state.world.get_room(&room_id) {
|
||||
Some(r) => r,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
"{} {}\r\n",
|
||||
ansi::room_name(&room.name),
|
||||
ansi::system_msg(&format!("[{}]", room.region))
|
||||
));
|
||||
out.push_str(&format!(" {}\r\n", room.description));
|
||||
|
||||
if !room.npcs.is_empty() {
|
||||
let npc_names: Vec<String> = room
|
||||
.npcs
|
||||
.iter()
|
||||
.filter_map(|id| state.world.get_npc(id))
|
||||
.map(|n| ansi::color(ansi::YELLOW, &n.name))
|
||||
.collect();
|
||||
if !npc_names.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"\r\n{}{}\r\n",
|
||||
ansi::color(ansi::DIM, "Present: "),
|
||||
npc_names.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !room.exits.is_empty() {
|
||||
let mut dirs: Vec<&String> = room.exits.keys().collect();
|
||||
dirs.sort();
|
||||
let dir_strs: Vec<String> = dirs.iter().map(|d| ansi::direction(d)).collect();
|
||||
out.push_str(&format!(
|
||||
"{} {}\r\n",
|
||||
ansi::color(ansi::DIM, "Exits:"),
|
||||
dir_strs.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
out.push_str(&ansi::prompt());
|
||||
let _ = session.data(channel, CryptoVec::from(out.as_bytes()));
|
||||
}
|
||||
|
||||
async fn handle_disconnect(&self) {
|
||||
let mut state = self.state.lock().await;
|
||||
if let Some(conn) = state.remove_player(self.id) {
|
||||
let departure = CryptoVec::from(
|
||||
format!(
|
||||
"\r\n{}\r\n{}",
|
||||
ansi::system_msg(&format!("{} has left the world.", conn.player.name)),
|
||||
ansi::prompt()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
let others: Vec<_> = state
|
||||
.players_in_room(&conn.player.room_id, self.id)
|
||||
.iter()
|
||||
.map(|c| (c.channel, c.handle.clone()))
|
||||
.collect();
|
||||
drop(state);
|
||||
|
||||
for (ch, h) in others {
|
||||
let _ = h.data(ch, departure.clone()).await;
|
||||
}
|
||||
|
||||
log::info!("{} disconnected (id={})", conn.player.name, self.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl russh::server::Handler for MudHandler {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn auth_password(&mut self, user: &str, _password: &str) -> Result<Auth, Self::Error> {
|
||||
self.username = user.to_string();
|
||||
log::info!("Auth accepted for '{}' (id={})", user, self.id);
|
||||
Ok(Auth::Accept)
|
||||
}
|
||||
|
||||
async fn auth_publickey(
|
||||
&mut self,
|
||||
user: &str,
|
||||
_key: &russh::keys::ssh_key::PublicKey,
|
||||
) -> Result<Auth, Self::Error> {
|
||||
self.username = user.to_string();
|
||||
log::info!("Pubkey auth accepted for '{}' (id={})", user, self.id);
|
||||
Ok(Auth::Accept)
|
||||
}
|
||||
|
||||
async fn auth_none(&mut self, user: &str) -> Result<Auth, Self::Error> {
|
||||
self.username = user.to_string();
|
||||
Ok(Auth::Accept)
|
||||
}
|
||||
|
||||
async fn channel_open_session(
|
||||
&mut self,
|
||||
channel: Channel<Msg>,
|
||||
session: &mut Session,
|
||||
) -> Result<bool, Self::Error> {
|
||||
self.channel = Some(channel.id());
|
||||
self.handle = Some(session.handle());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn pty_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
_term: &str,
|
||||
_col_width: u32,
|
||||
_row_height: u32,
|
||||
_pix_width: u32,
|
||||
_pix_height: u32,
|
||||
_modes: &[(Pty, u32)],
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
session.channel_success(channel)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shell_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
session.channel_success(channel)?;
|
||||
|
||||
self.send_welcome(session, channel).await;
|
||||
self.register_player(session, channel).await;
|
||||
self.show_room(session, channel).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn data(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
data: &[u8],
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
for &byte in data {
|
||||
match byte {
|
||||
3 | 4 => {
|
||||
self.handle_disconnect().await;
|
||||
session.close(channel)?;
|
||||
return Ok(());
|
||||
}
|
||||
8 | 127 => {
|
||||
if !self.line_buffer.is_empty() {
|
||||
self.line_buffer.pop();
|
||||
session.data(channel, CryptoVec::from(&b"\x08 \x08"[..]))?;
|
||||
}
|
||||
}
|
||||
b'\r' | b'\n' => {
|
||||
if byte == b'\n' && self.line_buffer.is_empty() {
|
||||
continue;
|
||||
}
|
||||
session.data(channel, CryptoVec::from(&b"\r\n"[..]))?;
|
||||
|
||||
let line = std::mem::take(&mut self.line_buffer);
|
||||
let keep_going =
|
||||
commands::execute(&line, self.id, &self.state, session, channel).await?;
|
||||
|
||||
if !keep_going {
|
||||
self.handle_disconnect().await;
|
||||
session.close(channel)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
27 => {}
|
||||
b if b < 32 => {}
|
||||
_ => {
|
||||
self.line_buffer.push(byte as char);
|
||||
session.data(channel, CryptoVec::from(&[byte][..]))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn channel_eof(
|
||||
&mut self,
|
||||
_channel: ChannelId,
|
||||
_session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.handle_disconnect().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn channel_close(
|
||||
&mut self,
|
||||
_channel: ChannelId,
|
||||
_session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.handle_disconnect().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MudHandler {
|
||||
fn drop(&mut self) {
|
||||
let state = self.state.clone();
|
||||
let id = self.id;
|
||||
tokio::spawn(async move {
|
||||
let mut state = state.lock().await;
|
||||
state.remove_player(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
281
src/world.rs
Normal file
281
src/world.rs
Normal 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(®ion_toml)?;
|
||||
log::info!("Loading region: {region_name}");
|
||||
|
||||
load_entities_from_dir(
|
||||
®ion_path.join("rooms"),
|
||||
®ion_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(
|
||||
®ion_path.join("npcs"),
|
||||
®ion_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(
|
||||
®ion_path.join("objects"),
|
||||
®ion_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(())
|
||||
}
|
||||
2
world/manifest.toml
Normal file
2
world/manifest.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
name = "The Shattered Realm"
|
||||
spawn_room = "town:town_square"
|
||||
4
world/town/npcs/barkeep.toml
Normal file
4
world/town/npcs/barkeep.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
name = "Grizzled Barkeep"
|
||||
description = "A weathered man with thick forearms and a permanent scowl. He polishes the same mug endlessly."
|
||||
room = "town:tavern"
|
||||
dialogue = "Welcome to The Rusty Tankard. We've got ale, and we've got stronger ale. Pick one."
|
||||
4
world/town/npcs/guard.toml
Normal file
4
world/town/npcs/guard.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
name = "Town Guard"
|
||||
description = "A bored-looking guard in dented chainmail. He leans on his spear and watches passersby."
|
||||
room = "town:gate"
|
||||
dialogue = "Move along. Nothing to see here."
|
||||
4
world/town/objects/healing_potion.toml
Normal file
4
world/town/objects/healing_potion.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
name = "Healing Potion"
|
||||
description = "A small glass vial filled with a shimmering red liquid."
|
||||
room = "town:temple"
|
||||
kind = "consumable"
|
||||
4
world/town/objects/rusty_sword.toml
Normal file
4
world/town/objects/rusty_sword.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
name = "Rusty Sword"
|
||||
description = "A battered iron blade with a cracked leather grip. It's seen better days."
|
||||
room = "town:cellar"
|
||||
kind = "weapon"
|
||||
2
world/town/region.toml
Normal file
2
world/town/region.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
name = "Thornwall"
|
||||
description = "A fortified trading town at the crossroads of the known world."
|
||||
8
world/town/rooms/cellar.toml
Normal file
8
world/town/rooms/cellar.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
name = "Tavern Cellar"
|
||||
description = """\
|
||||
A damp underground room lined with barrels and crates. Cobwebs drape \
|
||||
the ceiling. A single lantern sputters on a hook, casting long shadows. \
|
||||
Something skitters in the dark corners."""
|
||||
|
||||
[exits]
|
||||
up = "town:tavern"
|
||||
9
world/town/rooms/dark_alley.toml
Normal file
9
world/town/rooms/dark_alley.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
name = "Dark Alley"
|
||||
description = """\
|
||||
A narrow passage between crumbling stone walls. Puddles of dubious origin \
|
||||
dot the ground. Shadows pool in doorways, and you catch the faint sound \
|
||||
of whispered conversation from somewhere above."""
|
||||
|
||||
[exits]
|
||||
north = "town:town_square"
|
||||
south = "town:gate"
|
||||
8
world/town/rooms/forge.toml
Normal file
8
world/town/rooms/forge.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
name = "Blacksmith's Forge"
|
||||
description = """\
|
||||
Waves of heat roll from a roaring forge. A massive anvil stands in the \
|
||||
center, scarred by countless hammer strikes. Weapons and armor line the \
|
||||
walls, gleaming with fresh oil."""
|
||||
|
||||
[exits]
|
||||
west = "town:market"
|
||||
8
world/town/rooms/gate.toml
Normal file
8
world/town/rooms/gate.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
name = "City Gate"
|
||||
description = """\
|
||||
Towering iron-reinforced wooden gates mark the southern edge of town. \
|
||||
Guards in dented armor lean on their spears, watching the dusty road \
|
||||
that stretches into the wilderness beyond."""
|
||||
|
||||
[exits]
|
||||
north = "town:dark_alley"
|
||||
9
world/town/rooms/market.toml
Normal file
9
world/town/rooms/market.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
name = "Market Row"
|
||||
description = """\
|
||||
Colorful stalls line both sides of a narrow street. Merchants hawk their \
|
||||
wares — bolts of cloth, exotic spices, gleaming trinkets. The air is thick \
|
||||
with competing smells and the chatter of commerce."""
|
||||
|
||||
[exits]
|
||||
west = "town:town_square"
|
||||
east = "town:forge"
|
||||
9
world/town/rooms/tavern.toml
Normal file
9
world/town/rooms/tavern.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
name = "The Rusty Tankard"
|
||||
description = """\
|
||||
A warm tavern with low wooden beams and the smell of roasting meat. \
|
||||
A crackling fireplace casts dancing shadows across rough-hewn tables. \
|
||||
The barkeep polishes a mug behind the counter, eyeing newcomers."""
|
||||
|
||||
[exits]
|
||||
south = "town:town_square"
|
||||
down = "town:cellar"
|
||||
8
world/town/rooms/temple.toml
Normal file
8
world/town/rooms/temple.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
name = "Temple of the Dawn"
|
||||
description = """\
|
||||
A serene stone temple bathed in golden light filtering through stained \
|
||||
glass windows. Rows of wooden pews face an altar adorned with candles. \
|
||||
The air hums with quiet reverence."""
|
||||
|
||||
[exits]
|
||||
east = "town:town_square"
|
||||
11
world/town/rooms/town_square.toml
Normal file
11
world/town/rooms/town_square.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
name = "Town Square"
|
||||
description = """\
|
||||
You stand in the heart of Thornwall. A worn stone fountain sits at the \
|
||||
center, water trickling quietly. Cobblestone paths branch in every \
|
||||
direction. The sounds of merchants and travelers fill the air."""
|
||||
|
||||
[exits]
|
||||
north = "town:tavern"
|
||||
east = "town:market"
|
||||
west = "town:temple"
|
||||
south = "town:dark_alley"
|
||||
Reference in New Issue
Block a user