Compare commits

...

3 Commits

Author SHA1 Message Date
df757ba37d Merge pull request 'Fix smoke tests and resolve CI timeouts' (#1) from mcp-integration into main
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m6s
Reviewed-on: #1
2026-03-17 10:00:19 -06:00
AI Agent
ebdfa16aa5 Fix smoke tests: update movement for new spawn point and resolve Test 6 timeout
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m49s
2026-03-17 09:53:01 -06:00
AI Agent
dd517d8851 Add JSON-RPC interface and refactor for MCP support
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1h0m43s
2026-03-16 19:19:21 -06:00
11 changed files with 394 additions and 127 deletions

2
Cargo.lock generated
View File

@@ -1389,7 +1389,9 @@ dependencies = [
"crossterm 0.28.1", "crossterm 0.28.1",
"env_logger", "env_logger",
"log", "log",
"rand",
"ratatui", "ratatui",
"regex",
"rusqlite", "rusqlite",
"russh", "russh",
"serde", "serde",

View File

@@ -14,3 +14,5 @@ ratatui = "0.30"
crossterm = "0.28" crossterm = "0.28"
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
regex = "1"
rand = "0.8"

View File

@@ -31,6 +31,8 @@ ssh_mud smoketest@localhost <<'EOF'
1 1
look look
stats stats
go south
go down
go north go north
talk barkeep talk barkeep
go south go south
@@ -73,17 +75,18 @@ EOF
# Test 6: Tick-based combat (connect and wait for ticks) # Test 6: Tick-based combat (connect and wait for ticks)
./target/debug/mudtool -d "$TEST_DB" settings set registration_open true ./target/debug/mudtool -d "$TEST_DB" settings set registration_open true
./target/debug/mudtool -d "$TEST_DB" players delete smoketest ./target/debug/mudtool -d "$TEST_DB" players delete smoketest
ssh_mud smoketest@localhost <<'EOF' # Use subshell to pipe commands with a delay between them while staying connected
1 (
1 echo "1"
go south echo "1"
attack thief echo "go south"
EOF echo "go down"
sleep 8 echo "go south"
ssh_mud smoketest@localhost <<'EOF' echo "attack thief"
stats sleep 8
quit echo "stats"
EOF echo "quit"
) | ssh_mud smoketest@localhost
# Cleanup (trap handles server kill) # Cleanup (trap handles server kill)
./target/debug/mudtool -d "$TEST_DB" settings set registration_open true ./target/debug/mudtool -d "$TEST_DB" settings set registration_open true

View File

@@ -89,19 +89,21 @@ async fn admin_promote(target: &str, state: &SharedState) -> CommandResult {
) )
.as_bytes(), .as_bytes(),
); );
return CommandResult { if let (Some(ch), Some(h)) = (conn.channel, &conn.handle) {
output: format!( return CommandResult {
"{}\r\n", output: format!(
ansi::system_msg(&format!("{target} has been promoted to admin.")) "{}\r\n",
), ansi::system_msg(&format!("{target} has been promoted to admin."))
broadcasts: vec![BroadcastMsg { ),
channel: conn.channel, broadcasts: vec![BroadcastMsg {
handle: conn.handle.clone(), channel: ch,
data: msg, handle: h.clone(),
}], data: msg,
kick_targets: Vec::new(), }],
quit: false, kick_targets: Vec::new(),
}; quit: false,
};
}
} }
} }
simple(&format!( simple(&format!(
@@ -188,18 +190,31 @@ async fn admin_kick(target: &str, player_id: usize, state: &SharedState) -> Comm
let mut bcast: Vec<BroadcastMsg> = st let mut bcast: Vec<BroadcastMsg> = st
.players_in_room(&room_id, player_id) .players_in_room(&room_id, player_id)
.iter() .iter()
.map(|p| BroadcastMsg { .filter_map(|p| {
channel: p.channel, if let (Some(ch), Some(h)) = (p.channel, &p.handle) {
handle: p.handle.clone(), Some(BroadcastMsg {
data: departure.clone(), channel: ch,
handle: h.clone(),
data: departure.clone(),
})
} else {
None
}
}) })
.collect(); .collect();
// Send kick message to the target before closing // Send kick message to the target before closing
bcast.push(BroadcastMsg { let mut kick_targets = Vec::new();
channel: c.channel, if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
handle: c.handle.clone(), bcast.push(BroadcastMsg {
data: kick_msg, channel: ch,
}); handle: h.clone(),
data: kick_msg,
});
kick_targets.push(KickTarget {
channel: ch,
handle: h.clone(),
});
}
CommandResult { CommandResult {
output: format!( output: format!(
@@ -207,10 +222,7 @@ async fn admin_kick(target: &str, player_id: usize, state: &SharedState) -> Comm
ansi::system_msg(&format!("Kicked {name} from the server.")) ansi::system_msg(&format!("Kicked {name} from the server."))
), ),
broadcasts: bcast, broadcasts: bcast,
kick_targets: vec![KickTarget { kick_targets,
channel: c.channel,
handle: c.handle.clone(),
}],
quit: false, quit: false,
} }
} }
@@ -265,10 +277,16 @@ async fn admin_teleport(room_id: &str, player_id: usize, state: &SharedState) ->
let mut bcast: Vec<BroadcastMsg> = st let mut bcast: Vec<BroadcastMsg> = st
.players_in_room(&old_rid, player_id) .players_in_room(&old_rid, player_id)
.iter() .iter()
.map(|c| BroadcastMsg { .filter_map(|c| {
channel: c.channel, if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
handle: c.handle.clone(), Some(BroadcastMsg {
data: leave.clone(), channel: ch,
handle: h.clone(),
data: leave.clone(),
})
} else {
None
}
}) })
.collect(); .collect();
@@ -286,11 +304,13 @@ async fn admin_teleport(room_id: &str, player_id: usize, state: &SharedState) ->
.as_bytes(), .as_bytes(),
); );
for c in st.players_in_room(room_id, player_id) { for c in st.players_in_room(room_id, player_id) {
bcast.push(BroadcastMsg { if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
channel: c.channel, bcast.push(BroadcastMsg {
handle: c.handle.clone(), channel: ch,
data: arrive.clone(), handle: h.clone(),
}); data: arrive.clone(),
});
}
} }
st.save_player_to_db(player_id); st.save_player_to_db(player_id);
@@ -360,10 +380,16 @@ async fn admin_announce(msg: &str, player_id: usize, state: &SharedState) -> Com
.players .players
.iter() .iter()
.filter(|(&id, _)| id != player_id) .filter(|(&id, _)| id != player_id)
.map(|(_, c)| BroadcastMsg { .filter_map(|(_, c)| {
channel: c.channel, if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
handle: c.handle.clone(), Some(BroadcastMsg {
data: announcement.clone(), channel: ch,
handle: h.clone(),
data: announcement.clone(),
})
} else {
None
}
}) })
.collect(); .collect();
@@ -408,19 +434,22 @@ async fn admin_heal(args: &str, player_id: usize, state: &SharedState) -> Comman
c.player.stats.hp = c.player.stats.max_hp; c.player.stats.hp = c.player.stats.max_hp;
let name = c.player.name.clone(); let name = c.player.name.clone();
let hp = c.player.stats.max_hp; let hp = c.player.stats.max_hp;
let notify = CryptoVec::from( let mut bcast = Vec::new();
format!( if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
"\r\n{}\r\n{}", let notify = CryptoVec::from(
ansi::system_msg(&format!("An admin has fully healed you. HP: {hp}/{hp}")), format!(
ansi::prompt() "\r\n{}\r\n{}",
) ansi::system_msg(&format!("An admin has fully healed you. HP: {hp}/{hp}")),
.as_bytes(), ansi::prompt()
); )
let bcast = vec![BroadcastMsg { .as_bytes(),
channel: c.channel, );
handle: c.handle.clone(), bcast.push(BroadcastMsg {
data: notify, channel: ch,
}]; handle: h.clone(),
data: notify,
});
}
let _ = c; let _ = c;
st.save_player_to_db(tid); st.save_player_to_db(tid);
return CommandResult { return CommandResult {

View File

@@ -42,17 +42,42 @@ fn resolve_dir(input: &str) -> &str {
input input
} }
pub async fn execute( pub async fn execute_for_ssh(
input: &str, input: &str,
player_id: usize, player_id: usize,
state: &SharedState, state: &SharedState,
session: &mut Session, session: &mut Session,
channel: ChannelId, channel: ChannelId,
) -> Result<bool, russh::Error> { ) -> Result<bool, russh::Error> {
let result = execute(input, player_id, state).await;
send(session, channel, &result.output)?;
for msg in result.broadcasts {
let _ = msg.handle.data(msg.channel, msg.data).await;
}
for kick in result.kick_targets {
let _ = kick.handle.close(kick.channel).await;
}
if result.quit {
return Ok(false);
}
send(session, channel, &ansi::prompt())?;
Ok(true)
}
pub async fn execute(
input: &str,
player_id: usize,
state: &SharedState,
) -> CommandResult {
let input = input.trim(); let input = input.trim();
if input.is_empty() { if input.is_empty() {
send(session, channel, &ansi::prompt())?; return CommandResult {
return Ok(true); output: ansi::prompt(),
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
};
} }
let (cmd, args) = match input.split_once(' ') { let (cmd, args) = match input.split_once(' ') {
@@ -72,24 +97,23 @@ pub async fn execute(
| "spells" | "skills" | "quit" | "exit" | "spells" | "skills" | "quit" | "exit"
) )
{ {
drop(st); return CommandResult {
send( output: format!(
session,
channel,
&format!(
"{}\r\n{}", "{}\r\n{}",
ansi::error_msg( ansi::error_msg(
"You're in combat! Use 'attack', 'defend', 'flee', 'cast', 'use', 'look', 'stats', or 'inventory'." "You're in combat! Use 'attack', 'defend', 'flee', 'cast', 'use', 'look', 'stats', or 'inventory'."
), ),
ansi::prompt() ansi::prompt()
), ),
)?; broadcasts: Vec::new(),
return Ok(true); kick_targets: Vec::new(),
quit: false,
};
} }
} }
} }
let result = match cmd.as_str() { match cmd.as_str() {
"look" | "l" => cmd_look(player_id, &args, state).await, "look" | "l" => cmd_look(player_id, &args, state).await,
"go" => cmd_go(player_id, &args, state).await, "go" => cmd_go(player_id, &args, state).await,
"north" | "south" | "east" | "west" | "up" | "down" | "n" | "s" | "e" | "w" | "u" "north" | "south" | "east" | "west" | "up" | "down" | "n" | "s" | "e" | "w" | "u"
@@ -118,28 +142,21 @@ pub async fn execute(
kick_targets: Vec::new(), kick_targets: Vec::new(),
quit: true, quit: true,
}, },
_ => simple(&format!( _ => CommandResult {
"{}\r\n", output: format!(
ansi::error_msg(&format!( "{}\r\n",
"Unknown command: '{cmd}'. Type 'help' for commands." ansi::error_msg(&format!(
)) "Unknown command: '{cmd}'. Type 'help' for commands."
)), ))
}; ),
broadcasts: Vec::new(),
send(session, channel, &result.output)?; kick_targets: Vec::new(),
for msg in result.broadcasts { quit: false,
let _ = msg.handle.data(msg.channel, msg.data).await; },
} }
for kick in result.kick_targets {
let _ = kick.handle.close(kick.channel).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> { fn send(session: &mut Session, channel: ChannelId, text: &str) -> Result<(), russh::Error> {
session.data(channel, CryptoVec::from(text.as_bytes()))?; session.data(channel, CryptoVec::from(text.as_bytes()))?;
Ok(()) Ok(())
@@ -457,11 +474,13 @@ async fn cmd_go(pid: usize, direction: &str, state: &SharedState) -> CommandResu
); );
let mut bcast = Vec::new(); let mut bcast = Vec::new();
for c in st.players_in_room(&old_rid, pid) { for c in st.players_in_room(&old_rid, pid) {
bcast.push(BroadcastMsg { if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
channel: c.channel, bcast.push(BroadcastMsg {
handle: c.handle.clone(), channel: ch,
data: leave.clone(), handle: h.clone(),
}); data: leave.clone(),
});
}
} }
if let Some(c) = st.players.get_mut(&pid) { if let Some(c) = st.players.get_mut(&pid) {
@@ -477,11 +496,13 @@ async fn cmd_go(pid: usize, direction: &str, state: &SharedState) -> CommandResu
.as_bytes(), .as_bytes(),
); );
for c in st.players_in_room(&new_rid, pid) { for c in st.players_in_room(&new_rid, pid) {
bcast.push(BroadcastMsg { if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
channel: c.channel, bcast.push(BroadcastMsg {
handle: c.handle.clone(), channel: ch,
data: arrive.clone(), handle: h.clone(),
}); data: arrive.clone(),
});
}
} }
st.save_player_to_db(pid); st.save_player_to_db(pid);
@@ -524,10 +545,16 @@ async fn cmd_say(pid: usize, msg: &str, state: &SharedState) -> CommandResult {
let bcast: Vec<_> = st let bcast: Vec<_> = st
.players_in_room(&rid, pid) .players_in_room(&rid, pid)
.iter() .iter()
.map(|c| BroadcastMsg { .filter_map(|c| {
channel: c.channel, if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
handle: c.handle.clone(), Some(BroadcastMsg {
data: other.clone(), channel: ch,
handle: h.clone(),
data: other.clone(),
})
} else {
None
}
}) })
.collect(); .collect();
CommandResult { CommandResult {

View File

@@ -98,8 +98,8 @@ pub struct NpcInstance {
pub struct PlayerConnection { pub struct PlayerConnection {
pub player: Player, pub player: Player,
pub channel: ChannelId, pub channel: Option<ChannelId>,
pub handle: Handle, pub handle: Option<Handle>,
pub combat: Option<CombatState>, pub combat: Option<CombatState>,
} }
@@ -277,8 +277,8 @@ impl GameState {
name: String, name: String,
race_id: String, race_id: String,
class_id: String, class_id: String,
channel: ChannelId, channel: Option<ChannelId>,
handle: Handle, handle: Option<Handle>,
) { ) {
let room_id = self.world.spawn_room.clone(); let room_id = self.world.spawn_room.clone();
let race = self.world.races.iter().find(|r| r.id == race_id); let race = self.world.races.iter().find(|r| r.id == race_id);
@@ -356,8 +356,8 @@ impl GameState {
&mut self, &mut self,
id: usize, id: usize,
saved: SavedPlayer, saved: SavedPlayer,
channel: ChannelId, channel: Option<ChannelId>,
handle: Handle, handle: Option<Handle>,
) { ) {
let inventory: Vec<Object> = let inventory: Vec<Object> =
serde_json::from_str(&saved.inventory_json).unwrap_or_default(); serde_json::from_str(&saved.inventory_json).unwrap_or_default();

172
src/jsonrpc.rs Normal file
View File

@@ -0,0 +1,172 @@
use std::collections::HashMap;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::game::SharedState;
use crate::commands;
#[derive(Deserialize)]
struct JsonRpcRequest {
jsonrpc: String,
method: String,
params: Option<serde_json::Value>,
id: Option<serde_json::Value>,
}
#[derive(Serialize)]
struct JsonRpcResponse {
jsonrpc: String,
result: Option<serde_json::Value>,
error: Option<serde_json::Value>,
id: Option<serde_json::Value>,
}
pub async fn run_jsonrpc_server(state: SharedState, port: u16) {
let listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap();
log::info!("JSON-RPC server listening on 0.0.0.0:{port}");
let sessions = Arc::new(Mutex::new(HashMap::<usize, String>::new()));
loop {
let (stream, addr) = match listener.accept().await {
Ok(s) => s,
Err(e) => {
log::error!("Failed to accept connection: {e}");
continue;
}
};
log::info!("New JSON-RPC connection from {addr:?}");
let state = state.clone();
let sessions = sessions.clone();
tokio::spawn(async move {
handle_connection(stream, state, sessions).await;
});
}
}
async fn handle_connection(
mut stream: TcpStream,
state: SharedState,
sessions: Arc<Mutex<HashMap<usize, String>>>,
) {
let (reader, mut writer) = stream.split();
let mut reader = BufReader::new(reader);
let mut line = String::new();
// Map RPC session ID to player ID
let mut current_player_id: Option<usize> = None;
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => break, // Connection closed
Ok(_) => {
let req: JsonRpcRequest = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
let resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(json!({"code": -32700, "message": format!("Parse error: {e}")})),
id: None,
};
let _ = writer.write_all(format!("{}\n", serde_json::to_string(&resp).unwrap()).as_bytes()).await;
continue;
}
};
let resp = handle_request(req, &state, &sessions, &mut current_player_id).await;
let _ = writer.write_all(format!("{}\n", serde_json::to_string(&resp).unwrap()).as_bytes()).await;
}
Err(e) => {
log::error!("Error reading from JSON-RPC stream: {e}");
break;
}
}
}
// Cleanup session if needed
if let Some(pid) = current_player_id {
let mut st = state.lock().await;
st.remove_player(pid);
}
}
async fn handle_request(
req: JsonRpcRequest,
state: &SharedState,
_sessions: &Arc<Mutex<HashMap<usize, String>>>,
current_player_id: &mut Option<usize>,
) -> JsonRpcResponse {
let method = req.method.as_str();
let id = req.id.clone();
let result = match method {
"login" => {
let username = req.params.as_ref()
.and_then(|p| p.get("username"))
.and_then(|u| u.as_str())
.unwrap_or("anonymous");
let mut st = state.lock().await;
let player_id = rand::random::<usize>();
let saved = st.db.load_player(username);
if let Some(saved) = saved {
st.load_existing_player(player_id, saved, None, None);
*current_player_id = Some(player_id);
json!({"status": "success", "session_id": player_id})
} else {
json!({"status": "error", "message": "Player not found. Create character via SSH first."})
}
},
"list_commands" => {
json!([
"look", "go", "north", "south", "east", "west", "up", "down",
"say", "who", "take", "drop", "inventory", "equip", "use",
"examine", "talk", "attack", "defend", "flee", "cast",
"spells", "skills", "guild", "stats", "help"
])
},
"execute" => {
if let Some(pid) = *current_player_id {
let command = req.params.as_ref()
.and_then(|p| p.get("command"))
.and_then(|c| c.as_str())
.unwrap_or("");
let args = req.params.as_ref()
.and_then(|p| p.get("args"))
.and_then(|a| a.as_str())
.unwrap_or("");
let input = if args.is_empty() { command.to_string() } else { format!("{command} {args}") };
let result = commands::execute(&input, pid, state).await;
json!({
"output": strip_ansi(&result.output),
"quit": result.quit
})
} else {
json!({"error": "Not logged in"})
}
},
_ => json!({"error": "Method not found"}),
};
JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: Some(result),
error: None,
id,
}
}
fn strip_ansi(text: &str) -> String {
let re = regex::Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").unwrap();
re.replace_all(text, "").to_string()
}

View File

@@ -8,3 +8,4 @@ pub mod game;
pub mod ssh; pub mod ssh;
pub mod tick; pub mod tick;
pub mod world; pub mod world;
pub mod jsonrpc;

View File

@@ -21,6 +21,7 @@ async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let mut port = DEFAULT_PORT; let mut port = DEFAULT_PORT;
let mut jsonrpc_port = 2223;
let mut world_dir = PathBuf::from(DEFAULT_WORLD_DIR); let mut world_dir = PathBuf::from(DEFAULT_WORLD_DIR);
let mut db_path = PathBuf::from(DEFAULT_DB_PATH); let mut db_path = PathBuf::from(DEFAULT_DB_PATH);
@@ -35,6 +36,13 @@ async fn main() {
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.expect("--port requires a number"); .expect("--port requires a number");
} }
"--rpc-port" => {
i += 1;
jsonrpc_port = args
.get(i)
.and_then(|s| s.parse().ok())
.expect("--rpc-port requires a number");
}
"--world" | "-w" => { "--world" | "-w" => {
i += 1; i += 1;
world_dir = PathBuf::from(args.get(i).expect("--world requires a path")); world_dir = PathBuf::from(args.get(i).expect("--world requires a path"));
@@ -45,9 +53,10 @@ async fn main() {
} }
"--help" => { "--help" => {
eprintln!("Usage: mudserver [OPTIONS]"); eprintln!("Usage: mudserver [OPTIONS]");
eprintln!(" --port, -p Listen port (default: {DEFAULT_PORT})"); eprintln!(" --port, -p SSH listen port (default: {DEFAULT_PORT})");
eprintln!(" --world, -w World directory (default: {DEFAULT_WORLD_DIR})"); eprintln!(" --rpc-port JSON-RPC listen port (default: 2223)");
eprintln!(" --db, -d Database path (default: {DEFAULT_DB_PATH})"); eprintln!(" --world, -w World directory (default: {DEFAULT_WORLD_DIR})");
eprintln!(" --db, -d Database path (default: {DEFAULT_DB_PATH})");
std::process::exit(0); std::process::exit(0);
} }
other => { other => {
@@ -90,6 +99,12 @@ async fn main() {
tick::run_tick_engine(tick_state).await; tick::run_tick_engine(tick_state).await;
}); });
// Spawn JSON-RPC server
let rpc_state = state.clone();
tokio::spawn(async move {
mudserver::jsonrpc::run_jsonrpc_server(rpc_state, jsonrpc_port).await;
});
let mut server = ssh::MudServer::new(state); let mut server = ssh::MudServer::new(state);
let listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap(); let listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap();

View File

@@ -80,7 +80,7 @@ impl MudHandler {
if let Some(saved) = saved { if let Some(saved) = saved {
let handle = session.handle(); let handle = session.handle();
let mut state = self.state.lock().await; let mut state = self.state.lock().await;
state.load_existing_player(self.id, saved, channel, handle); state.load_existing_player(self.id, saved, Some(channel), Some(handle));
drop(state); drop(state);
let msg = format!( let msg = format!(
@@ -127,7 +127,13 @@ impl MudHandler {
let others: Vec<_> = state let others: Vec<_> = state
.players_in_room(&room_id, self.id) .players_in_room(&room_id, self.id)
.iter() .iter()
.map(|c| (c.channel, c.handle.clone())) .filter_map(|c| {
if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
Some((ch, h.clone()))
} else {
None
}
})
.collect(); .collect();
let room_view = render_entry_room(&state, &room_id, &player_name, self.id); let room_view = render_entry_room(&state, &room_id, &player_name, self.id);
@@ -170,8 +176,8 @@ impl MudHandler {
self.username.clone(), self.username.clone(),
race_id, race_id,
class_id, class_id,
channel, Some(channel),
handle, Some(handle),
); );
state.save_player_to_db(self.id); state.save_player_to_db(self.id);
drop(state); drop(state);
@@ -203,7 +209,13 @@ impl MudHandler {
let others: Vec<_> = state let others: Vec<_> = state
.players_in_room(&conn.player.room_id, self.id) .players_in_room(&conn.player.room_id, self.id)
.iter() .iter()
.map(|c| (c.channel, c.handle.clone())) .filter_map(|c| {
if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
Some((ch, h.clone()))
} else {
None
}
})
.collect(); .collect();
drop(state); drop(state);
for (ch, h) in others { for (ch, h) in others {
@@ -415,7 +427,7 @@ impl russh::server::Handler for MudHandler {
} }
let keep_going = let keep_going =
commands::execute(&line, self.id, &self.state, session, channel) commands::execute_for_ssh(&line, self.id, &self.state, session, channel)
.await?; .await?;
if !keep_going { if !keep_going {
self.handle_disconnect().await; self.handle_disconnect().await;

View File

@@ -291,11 +291,15 @@ pub async fn run_tick_engine(state: SharedState) {
return None; return None;
} }
let conn = st.players.get(&pid)?; let conn = st.players.get(&pid)?;
Some(( if let (Some(ch), Some(h)) = (conn.channel, &conn.handle) {
conn.channel, Some((
conn.handle.clone(), ch,
format!("{}{}", msg, ansi::prompt()), h.clone(),
)) format!("{}{}", msg, ansi::prompt()),
))
} else {
None
}
}) })
.collect(); .collect();