- 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
632 lines
20 KiB
Rust
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(¤t)
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|