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:
206
src/game.rs
206
src/game.rs
@@ -29,16 +29,25 @@ pub struct Player {
|
||||
pub inventory: Vec<Object>,
|
||||
pub equipped_weapon: Option<Object>,
|
||||
pub equipped_armor: Option<Object>,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
pub fn effective_attack(&self) -> i32 {
|
||||
let bonus = self.equipped_weapon.as_ref().and_then(|w| w.stats.damage).unwrap_or(0);
|
||||
let bonus = self
|
||||
.equipped_weapon
|
||||
.as_ref()
|
||||
.and_then(|w| w.stats.damage)
|
||||
.unwrap_or(0);
|
||||
self.stats.attack + bonus
|
||||
}
|
||||
|
||||
pub fn effective_defense(&self) -> i32 {
|
||||
let bonus = self.equipped_armor.as_ref().and_then(|a| a.stats.armor).unwrap_or(0);
|
||||
let bonus = self
|
||||
.equipped_armor
|
||||
.as_ref()
|
||||
.and_then(|a| a.stats.armor)
|
||||
.unwrap_or(0);
|
||||
self.stats.defense + bonus
|
||||
}
|
||||
}
|
||||
@@ -74,24 +83,41 @@ impl GameState {
|
||||
let mut npc_instances = HashMap::new();
|
||||
for npc in world.npcs.values() {
|
||||
if let Some(ref combat) = npc.combat {
|
||||
npc_instances.insert(npc.id.clone(), NpcInstance {
|
||||
hp: combat.max_hp, alive: true, death_time: None,
|
||||
});
|
||||
npc_instances.insert(
|
||||
npc.id.clone(),
|
||||
NpcInstance {
|
||||
hp: combat.max_hp,
|
||||
alive: true,
|
||||
death_time: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
GameState { world, db, players: HashMap::new(), npc_instances }
|
||||
GameState {
|
||||
world,
|
||||
db,
|
||||
players: HashMap::new(),
|
||||
npc_instances,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_room(&self) -> &str {
|
||||
&self.world.spawn_room
|
||||
}
|
||||
|
||||
// Get effective attitude of an NPC towards a specific player
|
||||
pub fn is_registration_open(&self) -> bool {
|
||||
self.db
|
||||
.get_setting("registration_open")
|
||||
.map(|v| v != "false")
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn npc_attitude_toward(&self, npc_id: &str, player_name: &str) -> Attitude {
|
||||
if let Some(val) = self.db.get_attitude(player_name, npc_id) {
|
||||
return Attitude::from_value(val);
|
||||
}
|
||||
self.world.get_npc(npc_id)
|
||||
self.world
|
||||
.get_npc(npc_id)
|
||||
.map(|n| n.base_attitude)
|
||||
.unwrap_or(Attitude::Neutral)
|
||||
}
|
||||
@@ -100,7 +126,8 @@ impl GameState {
|
||||
if let Some(val) = self.db.get_attitude(player_name, npc_id) {
|
||||
return val;
|
||||
}
|
||||
self.world.get_npc(npc_id)
|
||||
self.world
|
||||
.get_npc(npc_id)
|
||||
.map(|n| n.base_attitude.default_value())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -111,7 +138,6 @@ impl GameState {
|
||||
self.db.save_attitude(player_name, npc_id, new_val);
|
||||
}
|
||||
|
||||
// Shift attitude for all NPCs in the same faction
|
||||
pub fn shift_faction_attitude(&self, faction: &str, player_name: &str, delta: i32) {
|
||||
for npc in self.world.npcs.values() {
|
||||
if npc.faction.as_deref() == Some(faction) {
|
||||
@@ -121,8 +147,13 @@ impl GameState {
|
||||
}
|
||||
|
||||
pub fn create_new_player(
|
||||
&mut self, id: usize, name: String, race_id: String, class_id: String,
|
||||
channel: ChannelId, handle: Handle,
|
||||
&mut self,
|
||||
id: usize,
|
||||
name: String,
|
||||
race_id: String,
|
||||
class_id: String,
|
||||
channel: ChannelId,
|
||||
handle: Handle,
|
||||
) {
|
||||
let room_id = self.world.spawn_room.clone();
|
||||
let race = self.world.races.iter().find(|r| r.id == race_id);
|
||||
@@ -142,23 +173,54 @@ impl GameState {
|
||||
let defense = base_def + con_mod / 2;
|
||||
|
||||
let stats = PlayerStats {
|
||||
max_hp, hp: max_hp, attack, defense, level: 1, xp: 0, xp_to_next: 100,
|
||||
max_hp,
|
||||
hp: max_hp,
|
||||
attack,
|
||||
defense,
|
||||
level: 1,
|
||||
xp: 0,
|
||||
xp_to_next: 100,
|
||||
};
|
||||
|
||||
self.players.insert(id, PlayerConnection {
|
||||
player: Player { name, race_id, class_id, room_id, stats, inventory: Vec::new(), equipped_weapon: None, equipped_armor: None },
|
||||
channel, handle, combat: None,
|
||||
});
|
||||
self.players.insert(
|
||||
id,
|
||||
PlayerConnection {
|
||||
player: Player {
|
||||
name,
|
||||
race_id,
|
||||
class_id,
|
||||
room_id,
|
||||
stats,
|
||||
inventory: Vec::new(),
|
||||
equipped_weapon: None,
|
||||
equipped_armor: None,
|
||||
is_admin: false,
|
||||
},
|
||||
channel,
|
||||
handle,
|
||||
combat: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn load_existing_player(
|
||||
&mut self, id: usize, saved: SavedPlayer, channel: ChannelId, handle: Handle,
|
||||
&mut self,
|
||||
id: usize,
|
||||
saved: SavedPlayer,
|
||||
channel: ChannelId,
|
||||
handle: Handle,
|
||||
) {
|
||||
let inventory: Vec<Object> = serde_json::from_str(&saved.inventory_json).unwrap_or_default();
|
||||
let equipped_weapon: Option<Object> = saved.equipped_weapon_json.as_deref().and_then(|j| serde_json::from_str(j).ok());
|
||||
let equipped_armor: Option<Object> = saved.equipped_armor_json.as_deref().and_then(|j| serde_json::from_str(j).ok());
|
||||
let inventory: Vec<Object> =
|
||||
serde_json::from_str(&saved.inventory_json).unwrap_or_default();
|
||||
let equipped_weapon: Option<Object> = saved
|
||||
.equipped_weapon_json
|
||||
.as_deref()
|
||||
.and_then(|j| serde_json::from_str(j).ok());
|
||||
let equipped_armor: Option<Object> = saved
|
||||
.equipped_armor_json
|
||||
.as_deref()
|
||||
.and_then(|j| serde_json::from_str(j).ok());
|
||||
|
||||
// Validate room still exists, else spawn
|
||||
let room_id = if self.world.rooms.contains_key(&saved.room_id) {
|
||||
saved.room_id
|
||||
} else {
|
||||
@@ -166,32 +228,65 @@ impl GameState {
|
||||
};
|
||||
|
||||
let stats = PlayerStats {
|
||||
max_hp: saved.max_hp, hp: saved.hp, attack: saved.attack, defense: saved.defense,
|
||||
level: saved.level, xp: saved.xp, xp_to_next: saved.level * 100,
|
||||
max_hp: saved.max_hp,
|
||||
hp: saved.hp,
|
||||
attack: saved.attack,
|
||||
defense: saved.defense,
|
||||
level: saved.level,
|
||||
xp: saved.xp,
|
||||
xp_to_next: saved.level * 100,
|
||||
};
|
||||
|
||||
self.players.insert(id, PlayerConnection {
|
||||
player: Player {
|
||||
name: saved.name, race_id: saved.race_id, class_id: saved.class_id,
|
||||
room_id, stats, inventory, equipped_weapon, equipped_armor,
|
||||
self.players.insert(
|
||||
id,
|
||||
PlayerConnection {
|
||||
player: Player {
|
||||
name: saved.name,
|
||||
race_id: saved.race_id,
|
||||
class_id: saved.class_id,
|
||||
room_id,
|
||||
stats,
|
||||
inventory,
|
||||
equipped_weapon,
|
||||
equipped_armor,
|
||||
is_admin: saved.is_admin,
|
||||
},
|
||||
channel,
|
||||
handle,
|
||||
combat: None,
|
||||
},
|
||||
channel, handle, combat: None,
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
pub fn save_player_to_db(&self, player_id: usize) {
|
||||
if let Some(conn) = self.players.get(&player_id) {
|
||||
let p = &conn.player;
|
||||
let inv_json = serde_json::to_string(&p.inventory).unwrap_or_else(|_| "[]".into());
|
||||
let weapon_json = p.equipped_weapon.as_ref().map(|w| serde_json::to_string(w).unwrap_or_else(|_| "null".into()));
|
||||
let armor_json = p.equipped_armor.as_ref().map(|a| serde_json::to_string(a).unwrap_or_else(|_| "null".into()));
|
||||
let inv_json =
|
||||
serde_json::to_string(&p.inventory).unwrap_or_else(|_| "[]".into());
|
||||
let weapon_json = p
|
||||
.equipped_weapon
|
||||
.as_ref()
|
||||
.map(|w| serde_json::to_string(w).unwrap_or_else(|_| "null".into()));
|
||||
let armor_json = p
|
||||
.equipped_armor
|
||||
.as_ref()
|
||||
.map(|a| serde_json::to_string(a).unwrap_or_else(|_| "null".into()));
|
||||
|
||||
self.db.save_player(&SavedPlayer {
|
||||
name: p.name.clone(), race_id: p.race_id.clone(), class_id: p.class_id.clone(),
|
||||
room_id: p.room_id.clone(), level: p.stats.level, xp: p.stats.xp,
|
||||
hp: p.stats.hp, max_hp: p.stats.max_hp, attack: p.stats.attack,
|
||||
defense: p.stats.defense, inventory_json: inv_json,
|
||||
equipped_weapon_json: weapon_json, equipped_armor_json: armor_json,
|
||||
name: p.name.clone(),
|
||||
race_id: p.race_id.clone(),
|
||||
class_id: p.class_id.clone(),
|
||||
room_id: p.room_id.clone(),
|
||||
level: p.stats.level,
|
||||
xp: p.stats.xp,
|
||||
hp: p.stats.hp,
|
||||
max_hp: p.stats.max_hp,
|
||||
attack: p.stats.attack,
|
||||
defense: p.stats.defense,
|
||||
inventory_json: inv_json,
|
||||
equipped_weapon_json: weapon_json,
|
||||
equipped_armor_json: armor_json,
|
||||
is_admin: p.is_admin,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -202,17 +297,27 @@ impl GameState {
|
||||
}
|
||||
|
||||
pub fn players_in_room(&self, room_id: &str, exclude_id: usize) -> Vec<&PlayerConnection> {
|
||||
self.players.iter()
|
||||
self.players
|
||||
.iter()
|
||||
.filter(|(&id, conn)| conn.player.room_id == room_id && id != exclude_id)
|
||||
.map(|(_, conn)| conn).collect()
|
||||
.map(|(_, conn)| conn)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn check_respawns(&mut self) {
|
||||
let now = Instant::now();
|
||||
for (npc_id, instance) in self.npc_instances.iter_mut() {
|
||||
if instance.alive { continue; }
|
||||
let npc = match self.world.npcs.get(npc_id) { Some(n) => n, None => continue };
|
||||
let respawn_secs = match npc.respawn_secs { Some(s) => s, None => continue };
|
||||
if instance.alive {
|
||||
continue;
|
||||
}
|
||||
let npc = match self.world.npcs.get(npc_id) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
let respawn_secs = match npc.respawn_secs {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
if let Some(death_time) = instance.death_time {
|
||||
if now.duration_since(death_time).as_secs() >= respawn_secs {
|
||||
if let Some(ref combat) = npc.combat {
|
||||
@@ -228,7 +333,9 @@ impl GameState {
|
||||
pub fn check_level_up(&mut self, player_id: usize) -> Option<String> {
|
||||
let conn = self.players.get_mut(&player_id)?;
|
||||
let player = &mut conn.player;
|
||||
if player.stats.xp < player.stats.xp_to_next { return None; }
|
||||
if player.stats.xp < player.stats.xp_to_next {
|
||||
return None;
|
||||
}
|
||||
|
||||
player.stats.xp -= player.stats.xp_to_next;
|
||||
player.stats.level += 1;
|
||||
@@ -236,7 +343,11 @@ impl GameState {
|
||||
|
||||
let class = self.world.classes.iter().find(|c| c.id == player.class_id);
|
||||
let (hp_g, atk_g, def_g) = match class {
|
||||
Some(c) => (c.growth.hp_per_level, c.growth.attack_per_level, c.growth.defense_per_level),
|
||||
Some(c) => (
|
||||
c.growth.hp_per_level,
|
||||
c.growth.attack_per_level,
|
||||
c.growth.defense_per_level,
|
||||
),
|
||||
None => (10, 2, 1),
|
||||
};
|
||||
player.stats.max_hp += hp_g;
|
||||
@@ -244,6 +355,9 @@ impl GameState {
|
||||
player.stats.attack += atk_g;
|
||||
player.stats.defense += def_g;
|
||||
|
||||
Some(format!("You are now level {}! HP:{} ATK:{} DEF:{}", player.stats.level, player.stats.max_hp, player.stats.attack, player.stats.defense))
|
||||
Some(format!(
|
||||
"You are now level {}! HP:{} ATK:{} DEF:{}",
|
||||
player.stats.level, player.stats.max_hp, player.stats.attack, player.stats.defense
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user