Files
mudserver/src/admin.rs
AI Agent 005c4faf08 Flexible race system with slot-based equipment and dragon race
- Expand race TOML schema: 7 stats, body shape (size/weight/custom slots),
  natural armor and attacks with damage types, resistances, traits/disadvantages,
  regen multipliers, vision types, XP rate, guild compatibility
- Replace equipped_weapon/equipped_armor with slot-based HashMap<String, Object>
- Each race defines available equipment slots; default humanoid slots as fallback
- Combat uses natural weapons/armor from race when no gear equipped
- DB migration from old weapon/armor columns to equipped_json
- Add Dragon race: huge body, custom slots (forelegs/wings/tail), fire breath,
  natural armor 8, fire immune, slow XP rate for balance
- Update all existing races with expanded fields (traits, resistances, vision, regen)
- Objects gain optional slot field; kind=weapon/armor still works as fallback
- Update chargen to display race traits, size, natural attacks, vision
- Update stats display to show equipment and natural bonuses separately
- Update TESTING.md and AGENTS.md with race/slot system documentation

Made-with: Cursor
2026-03-14 15:37:20 -06:00

632 lines
20 KiB
Rust

use russh::CryptoVec;
use crate::ansi;
use crate::commands::{BroadcastMsg, CommandResult, KickTarget};
use crate::game::SharedState;
fn simple(msg: &str) -> CommandResult {
CommandResult {
output: msg.to_string(),
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
}
}
pub async fn execute_admin(args: &str, player_id: usize, state: &SharedState) -> CommandResult {
let (subcmd, subargs) = match args.split_once(' ') {
Some((c, a)) => (c.to_lowercase(), a.trim().to_string()),
None => (args.to_lowercase(), String::new()),
};
match subcmd.as_str() {
"help" | "h" | "" => admin_help(),
"promote" => admin_promote(&subargs, state).await,
"demote" => admin_demote(&subargs, state).await,
"kick" => admin_kick(&subargs, player_id, state).await,
"teleport" | "tp" => admin_teleport(&subargs, player_id, state).await,
"registration" | "reg" => admin_registration(&subargs, state).await,
"announce" => admin_announce(&subargs, player_id, state).await,
"heal" => admin_heal(&subargs, player_id, state).await,
"info" => admin_info(&subargs, state).await,
"setattitude" | "setatt" => admin_setattitude(&subargs, state).await,
"list" | "ls" => admin_list(player_id, state).await,
_ => simple(&format!(
"{}\r\n",
ansi::error_msg(&format!("Unknown admin command: '{subcmd}'. Use 'admin help'."))
)),
}
}
fn admin_help() -> CommandResult {
let mut out = format!("\r\n{}\r\n", ansi::bold("=== Admin Commands ==="));
let cmds = [
("admin promote <player>", "Grant admin privileges"),
("admin demote <player>", "Revoke admin privileges"),
("admin kick <player>", "Disconnect a player"),
("admin teleport <room_id>", "Teleport to a room"),
("admin registration on|off", "Toggle new player registration"),
("admin announce <msg>", "Broadcast to all players"),
("admin heal [player]", "Fully heal self or another player"),
("admin info <player>", "View detailed player info"),
(
"admin setattitude <player> <npc> <val>",
"Set NPC attitude",
),
("admin list", "List all players (online + saved)"),
];
for (c, d) in cmds {
out.push_str(&format!(
" {:<42} {}\r\n",
ansi::color(ansi::YELLOW, c),
ansi::color(ansi::DIM, d)
));
}
CommandResult {
output: out,
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
}
}
async fn admin_promote(target: &str, state: &SharedState) -> CommandResult {
if target.is_empty() {
return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin promote <player>")));
}
let st = state.lock().await;
if st.db.set_admin(target, true) {
// Also update in-memory if online
for conn in st.players.values() {
if conn.player.name == target {
// Can't mutate through shared ref, but DB is updated.
// They'll get the flag on next login. Notify them.
let msg = CryptoVec::from(
format!(
"\r\n{}\r\n{}",
ansi::system_msg("*** You have been granted admin privileges. ***"),
ansi::prompt()
)
.as_bytes(),
);
return CommandResult {
output: format!(
"{}\r\n",
ansi::system_msg(&format!("{target} has been promoted to admin."))
),
broadcasts: vec![BroadcastMsg {
channel: conn.channel,
handle: conn.handle.clone(),
data: msg,
}],
kick_targets: Vec::new(),
quit: false,
};
}
}
simple(&format!(
"{}\r\n",
ansi::system_msg(&format!(
"{target} promoted to admin (offline, effective next login)."
))
))
} else {
simple(&format!(
"{}\r\n",
ansi::error_msg(&format!("Player '{target}' not found in database."))
))
}
}
async fn admin_demote(target: &str, state: &SharedState) -> CommandResult {
if target.is_empty() {
return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin demote <player>")));
}
let st = state.lock().await;
if st.db.set_admin(target, false) {
simple(&format!(
"{}\r\n",
ansi::system_msg(&format!("{target} has been demoted from admin."))
))
} else {
simple(&format!(
"{}\r\n",
ansi::error_msg(&format!("Player '{target}' not found in database."))
))
}
}
async fn admin_kick(target: &str, player_id: usize, state: &SharedState) -> CommandResult {
if target.is_empty() {
return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin kick <player>")));
}
let mut st = state.lock().await;
let low = target.to_lowercase();
let target_id = st
.players
.iter()
.find(|(_, c)| c.player.name.to_lowercase() == low)
.map(|(&id, _)| id);
let tid = match target_id {
Some(id) => id,
None => {
return simple(&format!(
"{}\r\n",
ansi::error_msg(&format!("Player '{target}' is not online."))
))
}
};
if tid == player_id {
return simple(&format!("{}\r\n", ansi::error_msg("You can't kick yourself.")));
}
let kick_msg = CryptoVec::from(
format!(
"\r\n{}\r\n",
ansi::error_msg("*** You have been kicked by an admin. ***")
)
.as_bytes(),
);
let conn = st.remove_player(tid);
match conn {
Some(c) => {
let name = c.player.name.clone();
let room_id = c.player.room_id.clone();
let departure = CryptoVec::from(
format!(
"\r\n{}\r\n{}",
ansi::system_msg(&format!("{name} has been kicked.")),
ansi::prompt()
)
.as_bytes(),
);
let mut bcast: Vec<BroadcastMsg> = st
.players_in_room(&room_id, player_id)
.iter()
.map(|p| BroadcastMsg {
channel: p.channel,
handle: p.handle.clone(),
data: departure.clone(),
})
.collect();
// Send kick message to the target before closing
bcast.push(BroadcastMsg {
channel: c.channel,
handle: c.handle.clone(),
data: kick_msg,
});
CommandResult {
output: format!(
"{}\r\n",
ansi::system_msg(&format!("Kicked {name} from the server."))
),
broadcasts: bcast,
kick_targets: vec![KickTarget {
channel: c.channel,
handle: c.handle.clone(),
}],
quit: false,
}
}
None => simple(&format!("{}\r\n", ansi::error_msg("Failed to kick player."))),
}
}
async fn admin_teleport(room_id: &str, player_id: usize, state: &SharedState) -> CommandResult {
if room_id.is_empty() {
return simple(&format!(
"{}\r\n",
ansi::error_msg("Usage: admin teleport <room_id>")
));
}
let mut st = state.lock().await;
if st.world.get_room(room_id).is_none() {
let rooms: Vec<&String> = st.world.rooms.keys().collect();
let mut sorted = rooms;
sorted.sort();
let list = sorted
.iter()
.map(|r| ansi::color(ansi::CYAN, r))
.collect::<Vec<_>>()
.join(", ");
return simple(&format!(
"{}\r\nAvailable rooms: {}\r\n",
ansi::error_msg(&format!("Room '{room_id}' not found.")),
list
));
}
let old_rid = st
.players
.get(&player_id)
.map(|c| c.player.room_id.clone())
.unwrap_or_default();
let pname = st
.players
.get(&player_id)
.map(|c| c.player.name.clone())
.unwrap_or_default();
// Departure broadcast
let leave = CryptoVec::from(
format!(
"\r\n{}\r\n{}",
ansi::system_msg(&format!("{pname} vanishes in a flash of light.")),
ansi::prompt()
)
.as_bytes(),
);
let mut bcast: Vec<BroadcastMsg> = st
.players_in_room(&old_rid, player_id)
.iter()
.map(|c| BroadcastMsg {
channel: c.channel,
handle: c.handle.clone(),
data: leave.clone(),
})
.collect();
if let Some(c) = st.players.get_mut(&player_id) {
c.player.room_id = room_id.to_string();
}
// Arrival broadcast
let arrive = CryptoVec::from(
format!(
"\r\n{}\r\n{}",
ansi::system_msg(&format!("{pname} appears in a flash of light.")),
ansi::prompt()
)
.as_bytes(),
);
for c in st.players_in_room(room_id, player_id) {
bcast.push(BroadcastMsg {
channel: c.channel,
handle: c.handle.clone(),
data: arrive.clone(),
});
}
st.save_player_to_db(player_id);
let view = crate::commands::render_room_view(room_id, player_id, &st);
CommandResult {
output: format!(
"{}\r\n{}",
ansi::system_msg(&format!("Teleported to {room_id}.")),
view
),
broadcasts: bcast,
kick_targets: Vec::new(),
quit: false,
}
}
async fn admin_registration(args: &str, state: &SharedState) -> CommandResult {
let st = state.lock().await;
match args.to_lowercase().as_str() {
"on" | "true" | "open" => {
st.db.set_setting("registration_open", "true");
simple(&format!(
"{}\r\n",
ansi::system_msg("Registration is now OPEN.")
))
}
"off" | "false" | "closed" => {
st.db.set_setting("registration_open", "false");
simple(&format!(
"{}\r\n",
ansi::system_msg("Registration is now CLOSED.")
))
}
_ => {
let current = st
.db
.get_setting("registration_open")
.unwrap_or_else(|| "true".into());
simple(&format!(
"Registration is currently: {}\r\nUsage: admin registration on|off\r\n",
ansi::bold(&current)
))
}
}
}
async fn admin_announce(msg: &str, player_id: usize, state: &SharedState) -> CommandResult {
if msg.is_empty() {
return simple(&format!(
"{}\r\n",
ansi::error_msg("Usage: admin announce <message>")
));
}
let st = state.lock().await;
let announcement = CryptoVec::from(
format!(
"\r\n{}\r\n {}\r\n{}",
ansi::color(ansi::YELLOW, "*** ANNOUNCEMENT ***"),
ansi::bold(msg),
ansi::prompt()
)
.as_bytes(),
);
let bcast: Vec<BroadcastMsg> = st
.players
.iter()
.filter(|(&id, _)| id != player_id)
.map(|(_, c)| BroadcastMsg {
channel: c.channel,
handle: c.handle.clone(),
data: announcement.clone(),
})
.collect();
CommandResult {
output: format!(
"{}\r\n",
ansi::system_msg(&format!("Announced to {} player(s).", bcast.len()))
),
broadcasts: bcast,
kick_targets: Vec::new(),
quit: false,
}
}
async fn admin_heal(args: &str, player_id: usize, state: &SharedState) -> CommandResult {
let mut st = state.lock().await;
if args.is_empty() {
if let Some(c) = st.players.get_mut(&player_id) {
c.player.stats.hp = c.player.stats.max_hp;
let hp = c.player.stats.max_hp;
let _ = c;
st.save_player_to_db(player_id);
return simple(&format!(
"{}\r\n",
ansi::system_msg(&format!("You have been fully healed. HP: {hp}/{hp}"))
));
}
return simple(&format!("{}\r\n", ansi::error_msg("Error.")));
}
let low = args.to_lowercase();
let target_id = st
.players
.iter()
.find(|(_, c)| c.player.name.to_lowercase() == low)
.map(|(&id, _)| id);
match target_id {
Some(tid) => {
if let Some(c) = st.players.get_mut(&tid) {
c.player.stats.hp = c.player.stats.max_hp;
let name = c.player.name.clone();
let hp = c.player.stats.max_hp;
let notify = CryptoVec::from(
format!(
"\r\n{}\r\n{}",
ansi::system_msg(&format!("An admin has fully healed you. HP: {hp}/{hp}")),
ansi::prompt()
)
.as_bytes(),
);
let bcast = vec![BroadcastMsg {
channel: c.channel,
handle: c.handle.clone(),
data: notify,
}];
let _ = c;
st.save_player_to_db(tid);
return CommandResult {
output: format!(
"{}\r\n",
ansi::system_msg(&format!("Healed {name}. HP: {hp}/{hp}"))
),
broadcasts: bcast,
kick_targets: Vec::new(),
quit: false,
};
}
simple(&format!("{}\r\n", ansi::error_msg("Error.")))
}
None => simple(&format!(
"{}\r\n",
ansi::error_msg(&format!("Player '{args}' is not online."))
)),
}
}
async fn admin_info(target: &str, state: &SharedState) -> CommandResult {
if target.is_empty() {
return simple(&format!(
"{}\r\n",
ansi::error_msg("Usage: admin info <player>")
));
}
let st = state.lock().await;
// Check online first
let online = st
.players
.values()
.find(|c| c.player.name.to_lowercase() == target.to_lowercase());
if let Some(conn) = online {
let p = &conn.player;
let s = &p.stats;
let mut out = format!("\r\n{} {}\r\n", ansi::bold(&p.name), ansi::color(ansi::GREEN, "(online)"));
out.push_str(&format!(
" Race: {} | Class: {} | Admin: {}\r\n",
p.race_id, p.class_id, p.is_admin
));
out.push_str(&format!(
" HP: {}/{} | ATK: {} | DEF: {} | Level: {} | XP: {}/{}\r\n",
s.hp, s.max_hp, s.attack, s.defense, s.level, s.xp, s.xp_to_next
));
out.push_str(&format!(" Room: {}\r\n", p.room_id));
let equipped_str = if p.equipped.is_empty() {
"none".to_string()
} else {
p.equipped.iter().map(|(s, o)| format!("{}={}", s, o.name)).collect::<Vec<_>>().join(", ")
};
out.push_str(&format!(
" Inventory: {} item(s) | Equipped: {}\r\n",
p.inventory.len(),
equipped_str,
));
let attitudes = st.db.load_attitudes(&p.name);
if !attitudes.is_empty() {
out.push_str(&format!(" Attitudes ({}):\r\n", attitudes.len()));
for att in &attitudes {
let label = crate::world::Attitude::from_value(att.value).label();
out.push_str(&format!(" {}: {} ({})\r\n", att.npc_id, att.value, label));
}
}
return CommandResult {
output: out,
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
};
}
// Check DB
if let Some(saved) = st.db.load_player(target) {
let mut out = format!(
"\r\n{} {}\r\n",
ansi::bold(&saved.name),
ansi::color(ansi::DIM, "(offline)")
);
out.push_str(&format!(
" Race: {} | Class: {} | Admin: {}\r\n",
saved.race_id, saved.class_id, saved.is_admin
));
out.push_str(&format!(
" HP: {}/{} | ATK: {} | DEF: {} | Level: {} | XP: {}\r\n",
saved.hp, saved.max_hp, saved.attack, saved.defense, saved.level, saved.xp
));
out.push_str(&format!(" Room: {}\r\n", saved.room_id));
let attitudes = st.db.load_attitudes(&saved.name);
if !attitudes.is_empty() {
out.push_str(&format!(" Attitudes ({}):\r\n", attitudes.len()));
for att in &attitudes {
let label = crate::world::Attitude::from_value(att.value).label();
out.push_str(&format!(" {}: {} ({})\r\n", att.npc_id, att.value, label));
}
}
return CommandResult {
output: out,
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
};
}
simple(&format!(
"{}\r\n",
ansi::error_msg(&format!("Player '{target}' not found."))
))
}
async fn admin_setattitude(args: &str, state: &SharedState) -> CommandResult {
let parts: Vec<&str> = args.splitn(3, ' ').collect();
if parts.len() < 3 {
return simple(&format!(
"{}\r\n",
ansi::error_msg("Usage: admin setattitude <player> <npc_id> <value>")
));
}
let player_name = parts[0];
let npc_id = parts[1];
let value: i32 = match parts[2].parse() {
Ok(v) => v,
Err(_) => {
return simple(&format!(
"{}\r\n",
ansi::error_msg("Value must be a number (-100 to 100).")
))
}
};
let value = value.clamp(-100, 100);
let st = state.lock().await;
if st.db.load_player(player_name).is_none() {
return simple(&format!(
"{}\r\n",
ansi::error_msg(&format!("Player '{player_name}' not found."))
));
}
st.db.save_attitude(player_name, npc_id, value);
let label = crate::world::Attitude::from_value(value).label();
simple(&format!(
"{}\r\n",
ansi::system_msg(&format!(
"Set {npc_id} attitude toward {player_name} to {value} ({label})."
))
))
}
async fn admin_list(_player_id: usize, state: &SharedState) -> CommandResult {
let st = state.lock().await;
let all_saved = st.db.list_all_players();
let online_names: Vec<String> = st.players.values().map(|c| c.player.name.clone()).collect();
let mut out = format!("\r\n{}\r\n", ansi::bold("=== All Players ==="));
out.push_str(&format!(
" {:<20} {:<10} {:<10} {:<5} {:<12} {:<6} {}\r\n",
ansi::color(ansi::DIM, "Name"),
ansi::color(ansi::DIM, "Race"),
ansi::color(ansi::DIM, "Class"),
ansi::color(ansi::DIM, "Lvl"),
ansi::color(ansi::DIM, "HP"),
ansi::color(ansi::DIM, "Admin"),
ansi::color(ansi::DIM, "Status"),
));
for p in &all_saved {
let status = if online_names.contains(&p.name) {
ansi::color(ansi::GREEN, "online")
} else {
ansi::color(ansi::DIM, "offline")
};
let admin_str = if p.is_admin { "YES" } else { "no" };
let name_str = if online_names.contains(&p.name) {
ansi::player_name(&p.name)
} else {
p.name.clone()
};
out.push_str(&format!(
" {:<20} {:<10} {:<10} {:<5} {:<12} {:<6} {}\r\n",
name_str,
p.race_id.split(':').last().unwrap_or(&p.race_id),
p.class_id.split(':').last().unwrap_or(&p.class_id),
p.level,
format!("{}/{}", p.hp, p.max_hp),
admin_str,
status,
));
}
out.push_str(&format!(
"{}\r\n",
ansi::system_msg(&format!(
"{} total, {} online",
all_saved.len(),
online_names.len()
))
));
CommandResult {
output: out,
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
}
}