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:
AI Agent
2026-03-14 14:24:03 -06:00
parent 680f48477e
commit e7aac6d1dd
11 changed files with 3895 additions and 311 deletions

625
src/bin/mudtool.rs Normal file
View File

@@ -0,0 +1,625 @@
use std::io;
use std::path::PathBuf;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::ExecutableCommand;
use ratatui::prelude::*;
use ratatui::widgets::*;
use mudserver::db::{GameDb, NpcAttitudeRow, SavedPlayer, SqliteDb};
use mudserver::world::Attitude;
fn main() {
let args: Vec<String> = std::env::args().collect();
let mut db_path = PathBuf::from("./mudserver.db");
let mut cmd_args: Vec<String> = Vec::new();
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--db" | "-d" => {
i += 1;
db_path = PathBuf::from(args.get(i).expect("--db requires a path"));
}
"--help" | "-h" => {
print_help();
return;
}
_ => cmd_args.push(args[i].clone()),
}
i += 1;
}
let db = match SqliteDb::open(&db_path) {
Ok(db) => db,
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
};
if cmd_args.is_empty() {
print_help();
return;
}
match cmd_args[0].as_str() {
"tui" => run_tui(db),
"players" => cmd_players(&db, &cmd_args[1..]),
"settings" => cmd_settings(&db, &cmd_args[1..]),
"attitudes" => cmd_attitudes(&db, &cmd_args[1..]),
_ => {
eprintln!("Unknown command: {}", cmd_args[0]);
print_help();
}
}
}
fn print_help() {
eprintln!("mudtool - MUD Server Database Manager");
eprintln!();
eprintln!("Usage: mudtool [--db <path>] <command> [args...]");
eprintln!();
eprintln!("Commands:");
eprintln!(" tui Interactive TUI editor");
eprintln!(" players list List all players");
eprintln!(" players show <name> Show player details");
eprintln!(" players set-admin <name> <bool> Set admin flag");
eprintln!(" players delete <name> Delete a player");
eprintln!(" settings list List all settings");
eprintln!(" settings get <key> Get a setting value");
eprintln!(" settings set <key> <value> Set a setting value");
eprintln!(" attitudes list <player> List NPC attitudes");
eprintln!(" attitudes set <player> <npc> <v> Set attitude value");
eprintln!();
eprintln!("Options:");
eprintln!(" --db, -d <path> Database path (default: ./mudserver.db)");
}
// ============ CLI Commands ============
fn cmd_players(db: &SqliteDb, args: &[String]) {
if args.is_empty() {
eprintln!("Usage: mudtool players <list|show|set-admin|delete> [args]");
return;
}
match args[0].as_str() {
"list" | "ls" => {
let players = db.list_all_players();
if players.is_empty() {
println!("No players found.");
return;
}
println!("{:<20} {:<12} {:<12} {:<5} {:<10} {:<20} {}", "NAME", "RACE", "CLASS", "LVL", "HP", "ROOM", "ADMIN");
println!("{}", "-".repeat(90));
for p in &players {
println!("{:<20} {:<12} {:<12} {:<5} {:<10} {:<20} {}",
p.name, short_id(&p.race_id), short_id(&p.class_id),
p.level, format!("{}/{}", p.hp, p.max_hp), p.room_id,
if p.is_admin { "YES" } else { "no" });
}
println!("\n{} player(s) total.", players.len());
}
"show" => {
let name = args.get(1).map(|s| s.as_str()).unwrap_or("");
if name.is_empty() { eprintln!("Usage: mudtool players show <name>"); return; }
match db.load_player(name) {
Some(p) => {
println!("Player: {}", p.name);
println!(" Race: {} | Class: {}", p.race_id, p.class_id);
println!(" Level: {} | XP: {}", p.level, p.xp);
println!(" HP: {}/{} | ATK: {} | DEF: {}", p.hp, p.max_hp, p.attack, p.defense);
println!(" Room: {}", p.room_id);
println!(" Admin: {}", p.is_admin);
println!(" Inventory: {}", p.inventory_json);
if let Some(ref w) = p.equipped_weapon_json { println!(" Weapon: {w}"); }
if let Some(ref a) = p.equipped_armor_json { println!(" Armor: {a}"); }
let attitudes = db.load_attitudes(name);
if !attitudes.is_empty() {
println!(" Attitudes:");
for att in &attitudes {
println!(" {}: {} ({})", att.npc_id, att.value, Attitude::from_value(att.value).label());
}
}
}
None => eprintln!("Player '{name}' not found."),
}
}
"set-admin" => {
let name = args.get(1).map(|s| s.as_str()).unwrap_or("");
let val = args.get(2).map(|s| s.as_str()).unwrap_or("");
if name.is_empty() || val.is_empty() { eprintln!("Usage: mudtool players set-admin <name> <true|false>"); return; }
let is_admin = matches!(val, "true" | "1" | "yes");
if db.set_admin(name, is_admin) {
println!("Set {name} admin = {is_admin}");
} else {
eprintln!("Player '{name}' not found.");
}
}
"delete" => {
let name = args.get(1).map(|s| s.as_str()).unwrap_or("");
if name.is_empty() { eprintln!("Usage: mudtool players delete <name>"); return; }
if db.load_player(name).is_some() {
db.delete_player(name);
println!("Deleted player '{name}' and their attitudes.");
} else {
eprintln!("Player '{name}' not found.");
}
}
_ => eprintln!("Unknown subcommand: players {}", args[0]),
}
}
fn cmd_settings(db: &SqliteDb, args: &[String]) {
if args.is_empty() {
eprintln!("Usage: mudtool settings <list|get|set> [args]");
return;
}
match args[0].as_str() {
"list" | "ls" => {
let settings = db.list_settings();
if settings.is_empty() { println!("No settings configured."); return; }
println!("{:<30} {}", "KEY", "VALUE");
println!("{}", "-".repeat(50));
for (k, v) in &settings { println!("{:<30} {v}", k); }
}
"get" => {
let key = args.get(1).map(|s| s.as_str()).unwrap_or("");
if key.is_empty() { eprintln!("Usage: mudtool settings get <key>"); return; }
match db.get_setting(key) {
Some(v) => println!("{key} = {v}"),
None => println!("{key} is not set (will use default)."),
}
}
"set" => {
let key = args.get(1).map(|s| s.as_str()).unwrap_or("");
let val = args.get(2).map(|s| s.as_str()).unwrap_or("");
if key.is_empty() || val.is_empty() { eprintln!("Usage: mudtool settings set <key> <value>"); return; }
db.set_setting(key, val);
println!("Set {key} = {val}");
}
_ => eprintln!("Unknown subcommand: settings {}", args[0]),
}
}
fn cmd_attitudes(db: &SqliteDb, args: &[String]) {
if args.is_empty() {
eprintln!("Usage: mudtool attitudes <list|set> [args]");
return;
}
match args[0].as_str() {
"list" | "ls" => {
let player = args.get(1).map(|s| s.as_str()).unwrap_or("");
if player.is_empty() { eprintln!("Usage: mudtool attitudes list <player>"); return; }
let attitudes = db.load_attitudes(player);
if attitudes.is_empty() { println!("No attitudes for '{player}'."); return; }
println!("Attitudes for {player}:");
println!("{:<30} {:<8} {}", "NPC", "VALUE", "LABEL");
println!("{}", "-".repeat(50));
for att in &attitudes {
println!("{:<30} {:<8} {}", att.npc_id, att.value, Attitude::from_value(att.value).label());
}
}
"set" => {
let player = args.get(1).map(|s| s.as_str()).unwrap_or("");
let npc = args.get(2).map(|s| s.as_str()).unwrap_or("");
let val = args.get(3).and_then(|s| s.parse::<i32>().ok());
if player.is_empty() || npc.is_empty() || val.is_none() {
eprintln!("Usage: mudtool attitudes set <player> <npc_id> <value>");
return;
}
let v = val.unwrap().clamp(-100, 100);
db.save_attitude(player, npc, v);
println!("Set {npc} attitude toward {player} = {v} ({})", Attitude::from_value(v).label());
}
_ => eprintln!("Unknown subcommand: attitudes {}", args[0]),
}
}
fn short_id(id: &str) -> &str {
id.split(':').last().unwrap_or(id)
}
// ============ TUI ============
struct App {
db: SqliteDb,
tab: usize,
running: bool,
players: Vec<SavedPlayer>,
player_state: TableState,
settings: Vec<(String, String)>,
setting_state: TableState,
attitude_players: Vec<String>,
attitude_player_idx: usize,
attitudes: Vec<NpcAttitudeRow>,
attitude_state: TableState,
mode: AppMode,
input_buf: String,
status: String,
}
#[derive(PartialEq)]
enum AppMode {
Normal,
EditSetting { key: String },
EditAttitude { npc_id: String },
ConfirmDelete { name: String },
}
impl App {
fn new(db: SqliteDb) -> Self {
let mut app = App {
db,
tab: 0,
running: true,
players: Vec::new(),
player_state: TableState::default(),
settings: Vec::new(),
setting_state: TableState::default(),
attitude_players: Vec::new(),
attitude_player_idx: 0,
attitudes: Vec::new(),
attitude_state: TableState::default(),
mode: AppMode::Normal,
input_buf: String::new(),
status: String::new(),
};
app.refresh_all();
app
}
fn refresh_all(&mut self) {
self.players = self.db.list_all_players();
if !self.players.is_empty() && self.player_state.selected().is_none() {
self.player_state.select(Some(0));
}
self.settings = self.db.list_settings();
if !self.settings.is_empty() && self.setting_state.selected().is_none() {
self.setting_state.select(Some(0));
}
self.attitude_players = self.players.iter().map(|p| p.name.clone()).collect();
self.refresh_attitudes();
}
fn refresh_attitudes(&mut self) {
if let Some(name) = self.attitude_players.get(self.attitude_player_idx) {
self.attitudes = self.db.load_attitudes(name);
} else {
self.attitudes.clear();
}
if !self.attitudes.is_empty() && self.attitude_state.selected().is_none() {
self.attitude_state.select(Some(0));
}
}
fn selected_player(&self) -> Option<&SavedPlayer> {
self.player_state.selected().and_then(|i| self.players.get(i))
}
}
fn run_tui(db: SqliteDb) {
enable_raw_mode().unwrap();
io::stdout().execute(EnterAlternateScreen).unwrap();
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new(db);
while app.running {
terminal.draw(|f| ui(f, &mut app)).unwrap();
if event::poll(Duration::from_millis(100)).unwrap() {
if let Event::Key(key) = event::read().unwrap() {
handle_key(&mut app, key);
}
}
}
disable_raw_mode().unwrap();
io::stdout().execute(LeaveAlternateScreen).unwrap();
}
fn ui(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(3),
])
.split(f.area());
// Tab bar
let tabs = Tabs::new(vec!["[1] Players", "[2] Settings", "[3] Attitudes"])
.select(app.tab)
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.block(Block::default().borders(Borders::ALL).title(" MUD Tool "));
f.render_widget(tabs, chunks[0]);
// Content
match app.tab {
0 => render_players(f, app, chunks[1]),
1 => render_settings(f, app, chunks[1]),
2 => render_attitudes(f, app, chunks[1]),
_ => {}
}
// Status bar
let status_text = if app.mode != AppMode::Normal {
match &app.mode {
AppMode::EditSetting { key } => format!("Edit {key}: {}_", app.input_buf),
AppMode::EditAttitude { npc_id } => format!("Set {npc_id} value: {}_", app.input_buf),
AppMode::ConfirmDelete { name } => format!("Delete '{name}'? (y/n)"),
_ => String::new(),
}
} else if !app.status.is_empty() {
app.status.clone()
} else {
match app.tab {
0 => "↑↓ nav | a toggle admin | d delete | 1/2/3 tabs | q quit".into(),
1 => "↑↓ nav | Enter edit | n new | d delete | 1/2/3 tabs | q quit".into(),
2 => "↑↓ nav | Enter edit | ←→ player | 1/2/3 tabs | q quit".into(),
_ => String::new(),
}
};
let status = Paragraph::new(status_text)
.block(Block::default().borders(Borders::ALL));
f.render_widget(status, chunks[2]);
}
fn render_players(f: &mut Frame, app: &mut App, area: Rect) {
let header = Row::new(vec!["Name", "Race", "Class", "Lvl", "HP", "Room", "Admin"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let rows: Vec<Row> = app.players.iter().map(|p| {
Row::new(vec![
p.name.clone(),
short_id(&p.race_id).to_string(),
short_id(&p.class_id).to_string(),
p.level.to_string(),
format!("{}/{}", p.hp, p.max_hp),
p.room_id.clone(),
if p.is_admin { "YES".into() } else { "no".into() },
])
}).collect();
let widths = [
Constraint::Min(18), Constraint::Length(10), Constraint::Length(10),
Constraint::Length(5), Constraint::Length(10), Constraint::Min(18),
Constraint::Length(6),
];
let table = Table::new(rows, widths)
.header(header)
.row_highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
.highlight_symbol("")
.block(Block::default().borders(Borders::ALL).title(format!(" Players ({}) ", app.players.len())));
f.render_stateful_widget(table, area, &mut app.player_state);
}
fn render_settings(f: &mut Frame, app: &mut App, area: Rect) {
let header = Row::new(vec!["Key", "Value"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let rows: Vec<Row> = app.settings.iter().map(|(k, v)| {
Row::new(vec![k.clone(), v.clone()])
}).collect();
let widths = [Constraint::Min(30), Constraint::Min(30)];
let table = Table::new(rows, widths)
.header(header)
.row_highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
.highlight_symbol("")
.block(Block::default().borders(Borders::ALL).title(format!(" Settings ({}) ", app.settings.len())));
f.render_stateful_widget(table, area, &mut app.setting_state);
}
fn render_attitudes(f: &mut Frame, app: &mut App, area: Rect) {
let player_name = app.attitude_players.get(app.attitude_player_idx)
.cloned().unwrap_or_else(|| "(none)".into());
let header = Row::new(vec!["NPC", "Value", "Attitude"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let rows: Vec<Row> = app.attitudes.iter().map(|att| {
let label = Attitude::from_value(att.value).label().to_string();
Row::new(vec![att.npc_id.clone(), att.value.to_string(), label])
}).collect();
let widths = [Constraint::Min(30), Constraint::Length(8), Constraint::Length(12)];
let title = format!(" Attitudes for: {} (←→ {}/{}) ", player_name,
app.attitude_player_idx + 1, app.attitude_players.len().max(1));
let table = Table::new(rows, widths)
.header(header)
.row_highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
.highlight_symbol("")
.block(Block::default().borders(Borders::ALL).title(title));
f.render_stateful_widget(table, area, &mut app.attitude_state);
}
fn handle_key(app: &mut App, key: event::KeyEvent) {
// Handle input modes first
match &app.mode {
AppMode::EditSetting { .. } | AppMode::EditAttitude { .. } => {
match key.code {
KeyCode::Enter => {
let val = app.input_buf.clone();
match std::mem::replace(&mut app.mode, AppMode::Normal) {
AppMode::EditSetting { key } => {
app.db.set_setting(&key, &val);
app.status = format!("Set {key} = {val}");
}
AppMode::EditAttitude { npc_id } => {
if let Ok(v) = val.parse::<i32>() {
let v = v.clamp(-100, 100);
if let Some(pname) = app.attitude_players.get(app.attitude_player_idx) {
app.db.save_attitude(pname, &npc_id, v);
app.status = format!("Set {npc_id} = {v}");
}
} else {
app.status = "Invalid number.".into();
}
}
_ => {}
}
app.input_buf.clear();
app.refresh_all();
}
KeyCode::Esc => {
app.mode = AppMode::Normal;
app.input_buf.clear();
app.status.clear();
}
KeyCode::Backspace => { app.input_buf.pop(); }
KeyCode::Char(c) => app.input_buf.push(c),
_ => {}
}
return;
}
AppMode::ConfirmDelete { .. } => {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
if let AppMode::ConfirmDelete { name } = std::mem::replace(&mut app.mode, AppMode::Normal) {
app.db.delete_player(&name);
app.status = format!("Deleted {name}.");
app.player_state.select(Some(0));
app.refresh_all();
}
}
_ => {
app.mode = AppMode::Normal;
app.status = "Cancelled.".into();
}
}
return;
}
AppMode::Normal => {}
}
// Normal mode
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
app.running = false;
return;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => app.running = false,
KeyCode::Char('1') => { app.tab = 0; app.status.clear(); }
KeyCode::Char('2') => { app.tab = 1; app.status.clear(); }
KeyCode::Char('3') => { app.tab = 2; app.status.clear(); }
KeyCode::Tab => { app.tab = (app.tab + 1) % 3; app.status.clear(); }
KeyCode::Up => nav_up(app),
KeyCode::Down => nav_down(app),
KeyCode::Left => {
if app.tab == 2 && !app.attitude_players.is_empty() {
app.attitude_player_idx = app.attitude_player_idx.checked_sub(1)
.unwrap_or(app.attitude_players.len() - 1);
app.attitude_state.select(Some(0));
app.refresh_attitudes();
}
}
KeyCode::Right => {
if app.tab == 2 && !app.attitude_players.is_empty() {
app.attitude_player_idx = (app.attitude_player_idx + 1) % app.attitude_players.len();
app.attitude_state.select(Some(0));
app.refresh_attitudes();
}
}
KeyCode::Char('a') if app.tab == 0 => {
if let Some(p) = app.selected_player() {
let name = p.name.clone();
let new_val = !p.is_admin;
app.db.set_admin(&name, new_val);
app.status = format!("{name} admin = {new_val}");
app.refresh_all();
}
}
KeyCode::Char('d') if app.tab == 0 => {
if let Some(p) = app.selected_player() {
app.mode = AppMode::ConfirmDelete { name: p.name.clone() };
}
}
KeyCode::Char('d') if app.tab == 1 => {
if let Some(idx) = app.setting_state.selected() {
if let Some((key, _)) = app.settings.get(idx) {
let key = key.clone();
// Delete by setting empty then removing via raw SQL isn't in trait, just set ""
app.db.set_setting(&key, "");
app.status = format!("Cleared {key}");
app.refresh_all();
}
}
}
KeyCode::Enter => {
match app.tab {
1 => {
if let Some(idx) = app.setting_state.selected() {
if let Some((key, val)) = app.settings.get(idx) {
app.mode = AppMode::EditSetting { key: key.clone() };
app.input_buf = val.clone();
}
}
}
2 => {
if let Some(idx) = app.attitude_state.selected() {
if let Some(att) = app.attitudes.get(idx) {
app.mode = AppMode::EditAttitude { npc_id: att.npc_id.clone() };
app.input_buf = att.value.to_string();
}
}
}
_ => {}
}
}
KeyCode::Char('n') if app.tab == 1 => {
app.mode = AppMode::EditSetting { key: "new_key".into() };
app.input_buf.clear();
app.status = "Enter value for 'new_key' (rename key later with CLI):".into();
}
_ => {}
}
}
fn nav_up(app: &mut App) {
let state = match app.tab {
0 => &mut app.player_state,
1 => &mut app.setting_state,
2 => &mut app.attitude_state,
_ => return,
};
let len = match app.tab {
0 => app.players.len(),
1 => app.settings.len(),
2 => app.attitudes.len(),
_ => 0,
};
if len == 0 { return; }
let i = state.selected().unwrap_or(0);
state.select(Some(if i == 0 { len - 1 } else { i - 1 }));
}
fn nav_down(app: &mut App) {
let state = match app.tab {
0 => &mut app.player_state,
1 => &mut app.setting_state,
2 => &mut app.attitude_state,
_ => return,
};
let len = match app.tab {
0 => app.players.len(),
1 => app.settings.len(),
2 => app.attitudes.len(),
_ => 0,
};
if len == 0 { return; }
let i = state.selected().unwrap_or(0);
state.select(Some((i + 1) % len));
}