Add admin system, registration gate, mudtool database editor, and test checklist
- Add is_admin flag to player DB schema with migration for existing databases - Add server_settings table for key-value config (registration_open, etc.) - Add 10 in-game admin commands: promote, demote, kick, teleport, registration, announce, heal, info, setattitude, list — all gated behind admin flag - Registration gate: new players rejected when registration_open=false, existing players can still reconnect - Add mudtool binary with CLI mode (players/settings/attitudes CRUD) and interactive ratatui TUI with tabbed interface for database management - Restructure to lib.rs + main.rs so mudtool shares DB code with server - Add TESTING.md with comprehensive pre-commit checklist and smoke test script - Stats and who commands show [ADMIN] badge; help shows admin section for admins Made-with: Cursor
This commit is contained in:
267
src/ssh.rs
267
src/ssh.rs
@@ -15,7 +15,10 @@ pub struct MudServer {
|
||||
|
||||
impl MudServer {
|
||||
pub fn new(state: SharedState) -> Self {
|
||||
MudServer { state, next_id: AtomicUsize::new(1) }
|
||||
MudServer {
|
||||
state,
|
||||
next_id: AtomicUsize::new(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +29,14 @@ impl Server for MudServer {
|
||||
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(), chargen: None, state: self.state.clone(),
|
||||
id,
|
||||
username: String::new(),
|
||||
channel: None,
|
||||
handle: None,
|
||||
line_buffer: String::new(),
|
||||
chargen: None,
|
||||
rejected: false,
|
||||
state: self.state.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +51,8 @@ pub struct MudHandler {
|
||||
channel: Option<ChannelId>,
|
||||
handle: Option<Handle>,
|
||||
line_buffer: String,
|
||||
// None = not yet determined, Some(None) = returning player, Some(Some(cg)) = in chargen
|
||||
chargen: Option<Option<ChargenState>>,
|
||||
rejected: bool,
|
||||
state: SharedState,
|
||||
}
|
||||
|
||||
@@ -55,32 +64,41 @@ impl MudHandler {
|
||||
async fn start_session(&mut self, session: &mut Session, channel: ChannelId) {
|
||||
let state = self.state.lock().await;
|
||||
let world_name = state.world.name.clone();
|
||||
|
||||
// Check if returning player
|
||||
let saved = state.db.load_player(&self.username);
|
||||
let registration_open = state.is_registration_open();
|
||||
drop(state);
|
||||
|
||||
let welcome = format!(
|
||||
"{}\r\n{}Welcome to {}, {}!\r\n",
|
||||
ansi::CLEAR_SCREEN, ansi::welcome_banner(),
|
||||
ansi::bold(&world_name), ansi::player_name(&self.username),
|
||||
ansi::CLEAR_SCREEN,
|
||||
ansi::welcome_banner(),
|
||||
ansi::bold(&world_name),
|
||||
ansi::player_name(&self.username),
|
||||
);
|
||||
self.send_text(session, channel, &welcome);
|
||||
|
||||
if let Some(saved) = saved {
|
||||
// Returning player — load from DB
|
||||
let handle = session.handle();
|
||||
let mut state = self.state.lock().await;
|
||||
state.load_existing_player(self.id, saved, channel, handle);
|
||||
|
||||
let msg = format!("{}\r\n", ansi::system_msg("Welcome back! Your character has been restored."));
|
||||
drop(state);
|
||||
self.send_text(session, channel, &msg);
|
||||
|
||||
self.chargen = Some(None); // signal: no chargen needed
|
||||
let msg = format!(
|
||||
"{}\r\n",
|
||||
ansi::system_msg("Welcome back! Your character has been restored.")
|
||||
);
|
||||
self.send_text(session, channel, &msg);
|
||||
self.chargen = Some(None);
|
||||
self.enter_world(session, channel).await;
|
||||
} else if !registration_open {
|
||||
let msg = format!(
|
||||
"{}\r\n{}\r\n",
|
||||
ansi::error_msg("Registration is currently closed. New characters cannot be created."),
|
||||
ansi::system_msg("Contact an administrator for access. Disconnecting..."),
|
||||
);
|
||||
self.send_text(session, channel, &msg);
|
||||
self.rejected = true;
|
||||
} else {
|
||||
// New player — start chargen
|
||||
let cg = ChargenState::new();
|
||||
let state = self.state.lock().await;
|
||||
let prompt = cg.prompt_text(&state.world);
|
||||
@@ -98,13 +116,20 @@ impl MudHandler {
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Broadcast arrival
|
||||
let arrival = CryptoVec::from(
|
||||
format!("\r\n{}\r\n{}", ansi::system_msg(&format!("{player_name} has entered the world.")), ansi::prompt()).as_bytes(),
|
||||
format!(
|
||||
"\r\n{}\r\n{}",
|
||||
ansi::system_msg(&format!("{player_name} has entered the world.")),
|
||||
ansi::prompt()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
let others: Vec<_> = state.players_in_room(&room_id, self.id).iter().map(|c| (c.channel, c.handle.clone())).collect();
|
||||
let others: Vec<_> = state
|
||||
.players_in_room(&room_id, self.id)
|
||||
.iter()
|
||||
.map(|c| (c.channel, c.handle.clone()))
|
||||
.collect();
|
||||
|
||||
// Render room
|
||||
let room_view = render_entry_room(&state, &room_id, &player_name, self.id);
|
||||
drop(state);
|
||||
|
||||
@@ -115,19 +140,49 @@ impl MudHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async fn finish_chargen(&mut self, race_id: String, class_id: String, session: &mut Session, channel: ChannelId) {
|
||||
async fn finish_chargen(
|
||||
&mut self,
|
||||
race_id: String,
|
||||
class_id: String,
|
||||
session: &mut Session,
|
||||
channel: ChannelId,
|
||||
) {
|
||||
let handle = session.handle();
|
||||
let mut state = self.state.lock().await;
|
||||
|
||||
let race_name = state.world.races.iter().find(|r| r.id == race_id).map(|r| r.name.clone()).unwrap_or_default();
|
||||
let class_name = state.world.classes.iter().find(|c| c.id == class_id).map(|c| c.name.clone()).unwrap_or_default();
|
||||
let race_name = state
|
||||
.world
|
||||
.races
|
||||
.iter()
|
||||
.find(|r| r.id == race_id)
|
||||
.map(|r| r.name.clone())
|
||||
.unwrap_or_default();
|
||||
let class_name = state
|
||||
.world
|
||||
.classes
|
||||
.iter()
|
||||
.find(|c| c.id == class_id)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
state.create_new_player(self.id, self.username.clone(), race_id, class_id, channel, handle);
|
||||
state.create_new_player(
|
||||
self.id,
|
||||
self.username.clone(),
|
||||
race_id,
|
||||
class_id,
|
||||
channel,
|
||||
handle,
|
||||
);
|
||||
state.save_player_to_db(self.id);
|
||||
|
||||
drop(state);
|
||||
|
||||
let msg = format!("\r\n{}\r\n\r\n", ansi::system_msg(&format!("Character created: {} the {} {}", self.username, race_name, class_name)));
|
||||
let msg = format!(
|
||||
"\r\n{}\r\n\r\n",
|
||||
ansi::system_msg(&format!(
|
||||
"Character created: {} the {} {}",
|
||||
self.username, race_name, class_name
|
||||
))
|
||||
);
|
||||
self.send_text(session, channel, &msg);
|
||||
|
||||
self.chargen = Some(None);
|
||||
@@ -138,49 +193,93 @@ impl MudHandler {
|
||||
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(),
|
||||
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();
|
||||
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; }
|
||||
for (ch, h) in others {
|
||||
let _ = h.data(ch, departure.clone()).await;
|
||||
}
|
||||
log::info!("{} disconnected (id={})", conn.player.name, self.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_entry_room(state: &crate::game::GameState, room_id: &str, player_name: &str, player_id: usize) -> String {
|
||||
let room = match state.world.get_room(room_id) { Some(r) => r, None => return String::new() };
|
||||
fn render_entry_room(
|
||||
state: &crate::game::GameState,
|
||||
room_id: &str,
|
||||
player_name: &str,
|
||||
player_id: usize,
|
||||
) -> String {
|
||||
let room = match state.world.get_room(room_id) {
|
||||
Some(r) => r,
|
||||
None => return String::new(),
|
||||
};
|
||||
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",
|
||||
ansi::room_name(&room.name),
|
||||
ansi::system_msg(&format!("[{}]", room.region))
|
||||
));
|
||||
out.push_str(&format!(" {}\r\n", room.description));
|
||||
|
||||
let npc_strs: Vec<String> = room.npcs.iter().filter_map(|id| {
|
||||
let npc = state.world.get_npc(id)?;
|
||||
let alive = state.npc_instances.get(id).map(|i| i.alive).unwrap_or(true);
|
||||
if !alive { return None; }
|
||||
let att = state.npc_attitude_toward(id, player_name);
|
||||
let color = match att {
|
||||
crate::world::Attitude::Friendly => ansi::GREEN,
|
||||
crate::world::Attitude::Neutral | crate::world::Attitude::Wary => ansi::YELLOW,
|
||||
_ => ansi::RED,
|
||||
};
|
||||
Some(ansi::color(color, &npc.name))
|
||||
}).collect();
|
||||
let npc_strs: Vec<String> = room
|
||||
.npcs
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let npc = state.world.get_npc(id)?;
|
||||
let alive = state.npc_instances.get(id).map(|i| i.alive).unwrap_or(true);
|
||||
if !alive {
|
||||
return None;
|
||||
}
|
||||
let att = state.npc_attitude_toward(id, player_name);
|
||||
let color = match att {
|
||||
crate::world::Attitude::Friendly => ansi::GREEN,
|
||||
crate::world::Attitude::Neutral | crate::world::Attitude::Wary => ansi::YELLOW,
|
||||
_ => ansi::RED,
|
||||
};
|
||||
Some(ansi::color(color, &npc.name))
|
||||
})
|
||||
.collect();
|
||||
if !npc_strs.is_empty() {
|
||||
out.push_str(&format!("\r\n{}{}\r\n", ansi::color(ansi::DIM, "Present: "), npc_strs.join(", ")));
|
||||
out.push_str(&format!(
|
||||
"\r\n{}{}\r\n",
|
||||
ansi::color(ansi::DIM, "Present: "),
|
||||
npc_strs.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(", ")));
|
||||
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.push_str(&format!(
|
||||
"{} {}\r\n",
|
||||
ansi::color(ansi::DIM, "Exits:"),
|
||||
dir_strs.join(", ")
|
||||
));
|
||||
}
|
||||
out.push_str(&ansi::prompt());
|
||||
out
|
||||
@@ -195,7 +294,11 @@ impl russh::server::Handler for MudHandler {
|
||||
Ok(Auth::Accept)
|
||||
}
|
||||
|
||||
async fn auth_publickey(&mut self, user: &str, _key: &russh::keys::ssh_key::PublicKey) -> Result<Auth, Self::Error> {
|
||||
async fn auth_publickey(
|
||||
&mut self,
|
||||
user: &str,
|
||||
_key: &russh::keys::ssh_key::PublicKey,
|
||||
) -> Result<Auth, Self::Error> {
|
||||
self.username = user.to_string();
|
||||
Ok(Auth::Accept)
|
||||
}
|
||||
@@ -205,24 +308,52 @@ impl russh::server::Handler for MudHandler {
|
||||
Ok(Auth::Accept)
|
||||
}
|
||||
|
||||
async fn channel_open_session(&mut self, channel: Channel<Msg>, session: &mut Session) -> Result<bool, Self::Error> {
|
||||
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> {
|
||||
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> {
|
||||
async fn shell_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
session.channel_success(channel)?;
|
||||
self.start_session(session, channel).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn data(&mut self, channel: ChannelId, data: &[u8], session: &mut Session) -> Result<(), Self::Error> {
|
||||
async fn data(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
data: &[u8],
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
if self.rejected {
|
||||
session.close(channel)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for &byte in data {
|
||||
match byte {
|
||||
3 | 4 => {
|
||||
@@ -237,7 +368,9 @@ impl russh::server::Handler for MudHandler {
|
||||
}
|
||||
}
|
||||
b'\r' | b'\n' => {
|
||||
if byte == b'\n' && self.line_buffer.is_empty() { continue; }
|
||||
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);
|
||||
|
||||
@@ -251,8 +384,11 @@ impl russh::server::Handler for MudHandler {
|
||||
let state = self.state.lock().await;
|
||||
cg.handle_input(&line, &state.world)
|
||||
};
|
||||
let msg_text = match result { Ok(msg) | Err(msg) => msg };
|
||||
let _ = session.data(channel, CryptoVec::from(msg_text.as_bytes()));
|
||||
let msg_text = match result {
|
||||
Ok(msg) | Err(msg) => msg,
|
||||
};
|
||||
let _ =
|
||||
session.data(channel, CryptoVec::from(msg_text.as_bytes()));
|
||||
if cg.is_done() {
|
||||
chargen_done = cg.result();
|
||||
}
|
||||
@@ -260,16 +396,17 @@ impl russh::server::Handler for MudHandler {
|
||||
}
|
||||
if let Some((race_id, class_id)) = chargen_done {
|
||||
self.chargen = None;
|
||||
self.finish_chargen(race_id, class_id, session, channel).await;
|
||||
self.finish_chargen(race_id, class_id, session, channel)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
if chargen_active {
|
||||
// Still in chargen, show next prompt
|
||||
if let Some(Some(ref cg)) = self.chargen {
|
||||
let state = self.state.lock().await;
|
||||
let prompt = cg.prompt_text(&state.world);
|
||||
drop(state);
|
||||
let _ = session.data(channel, CryptoVec::from(prompt.as_bytes()));
|
||||
let _ =
|
||||
session.data(channel, CryptoVec::from(prompt.as_bytes()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -277,7 +414,9 @@ impl russh::server::Handler for MudHandler {
|
||||
continue;
|
||||
}
|
||||
|
||||
let keep_going = commands::execute(&line, self.id, &self.state, session, channel).await?;
|
||||
let keep_going =
|
||||
commands::execute(&line, self.id, &self.state, session, channel)
|
||||
.await?;
|
||||
if !keep_going {
|
||||
self.handle_disconnect().await;
|
||||
session.close(channel)?;
|
||||
@@ -295,12 +434,20 @@ impl russh::server::Handler for MudHandler {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn channel_eof(&mut self, _channel: ChannelId, _session: &mut Session) -> Result<(), Self::Error> {
|
||||
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> {
|
||||
async fn channel_close(
|
||||
&mut self,
|
||||
_channel: ChannelId,
|
||||
_session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.handle_disconnect().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user