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