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:
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user