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:
1232
Cargo.lock
generated
1232
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mudserver"
|
name = "mudserver"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -10,5 +10,7 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
rusqlite = { version = "0.35", features = ["bundled"] }
|
rusqlite = { version = "0.35", features = ["bundled"] }
|
||||||
|
ratatui = "0.30"
|
||||||
|
crossterm = "0.28"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
|
|||||||
157
TESTING.md
Normal file
157
TESTING.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# Pre-Commit Test Checklist
|
||||||
|
|
||||||
|
Run through these checks before every commit to ensure consistent feature coverage.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
- [ ] `cargo build` succeeds with no errors
|
||||||
|
- [ ] `cargo build --bin mudtool` succeeds
|
||||||
|
|
||||||
|
## Server Startup
|
||||||
|
- [ ] Server starts: `RUST_LOG=info ./target/debug/mudserver`
|
||||||
|
- [ ] World loads all rooms, NPCs, objects, races, classes (check log output)
|
||||||
|
- [ ] Database opens (or creates) successfully
|
||||||
|
|
||||||
|
## Character Creation
|
||||||
|
- [ ] New player SSH → gets chargen flow (race + class selection)
|
||||||
|
- [ ] Chargen accepts both number and name input
|
||||||
|
- [ ] After chargen, player appears in spawn room with correct stats
|
||||||
|
- [ ] Player saved to DB after creation
|
||||||
|
|
||||||
|
## Player Persistence
|
||||||
|
- [ ] Reconnecting player skips chargen, sees "Welcome back!"
|
||||||
|
- [ ] Room, stats, inventory, equipment all restored from DB
|
||||||
|
- [ ] Verify with: `sqlite3 mudserver.db "SELECT * FROM players;"`
|
||||||
|
|
||||||
|
## Movement & Navigation
|
||||||
|
- [ ] `go north`, `n`, `south`, `s`, etc. all work
|
||||||
|
- [ ] Invalid direction shows error
|
||||||
|
- [ ] Room view shows: NPCs (colored by attitude), objects, exits, other players
|
||||||
|
|
||||||
|
## NPC Interaction
|
||||||
|
- [ ] `examine <npc>` shows description, stats, attitude label
|
||||||
|
- [ ] `talk <friendly>` shows greeting dialogue
|
||||||
|
- [ ] `talk <hostile>` shows snarl message
|
||||||
|
- [ ] Dead NPCs don't appear in room view
|
||||||
|
|
||||||
|
## Combat
|
||||||
|
- [ ] `attack <aggressive/hostile npc>` initiates combat
|
||||||
|
- [ ] Can't attack friendly/neutral NPCs
|
||||||
|
- [ ] Combat rounds show damage dealt and received
|
||||||
|
- [ ] NPC death: awards XP, shifts attitude -10, shifts faction -5
|
||||||
|
- [ ] Player death: respawns at spawn room with full HP
|
||||||
|
- [ ] NPCs respawn after configured time
|
||||||
|
- [ ] Combat lockout: can only attack/flee/look/quit during combat
|
||||||
|
- [ ] `flee` exits combat
|
||||||
|
|
||||||
|
## Items
|
||||||
|
- [ ] `take <item>` picks up takeable objects
|
||||||
|
- [ ] `drop <item>` places item in room
|
||||||
|
- [ ] `equip <weapon/armor>` works, old gear returns to inventory
|
||||||
|
- [ ] `use <consumable>` heals and removes item
|
||||||
|
- [ ] `inventory` shows equipped + bag items
|
||||||
|
|
||||||
|
## Attitude System
|
||||||
|
- [ ] Per-player NPC attitudes stored in DB
|
||||||
|
- [ ] `examine` shows attitude label per-player
|
||||||
|
- [ ] Killing NPC shifts attitude (individual -10, faction -5)
|
||||||
|
- [ ] Verify: `sqlite3 mudserver.db "SELECT * FROM npc_attitudes;"`
|
||||||
|
|
||||||
|
## Admin System
|
||||||
|
- [ ] Non-admin can't use `admin` commands (gets error)
|
||||||
|
- [ ] Set admin via mudtool: `mudtool players set-admin <name> true`
|
||||||
|
- [ ] `admin help` shows admin command list
|
||||||
|
- [ ] `admin promote <player>` grants admin (verify in DB)
|
||||||
|
- [ ] `admin demote <player>` revokes admin
|
||||||
|
- [ ] `admin kick <player>` disconnects target player
|
||||||
|
- [ ] `admin teleport <room_id>` warps to room (shows room list on invalid)
|
||||||
|
- [ ] `admin registration off` blocks new player creation
|
||||||
|
- [ ] `admin registration on` re-enables it
|
||||||
|
- [ ] `admin announce <msg>` broadcasts to all players
|
||||||
|
- [ ] `admin heal` heals self; `admin heal <player>` heals target
|
||||||
|
- [ ] `admin info <player>` shows detailed stats + attitudes
|
||||||
|
- [ ] `admin setattitude <player> <npc> <value>` modifies attitude
|
||||||
|
- [ ] `admin list` shows all players with online/offline status
|
||||||
|
|
||||||
|
## Registration Gate
|
||||||
|
- [ ] With registration open (default), new players can create characters
|
||||||
|
- [ ] With registration off, new SSH connections get rejection message
|
||||||
|
- [ ] Existing players can still log in when registration is closed
|
||||||
|
|
||||||
|
## MUD Tool - CLI
|
||||||
|
- [ ] `mudtool players list` shows all players
|
||||||
|
- [ ] `mudtool players show <name>` shows details
|
||||||
|
- [ ] `mudtool players set-admin <name> true` works
|
||||||
|
- [ ] `mudtool players delete <name>` removes player + attitudes
|
||||||
|
- [ ] `mudtool settings list` shows settings
|
||||||
|
- [ ] `mudtool settings set registration_open false` works
|
||||||
|
- [ ] `mudtool attitudes list <player>` shows attitudes
|
||||||
|
- [ ] `mudtool attitudes set <player> <npc> <value>` works
|
||||||
|
|
||||||
|
## MUD Tool - TUI
|
||||||
|
- [ ] `mudtool tui` launches interactive interface
|
||||||
|
- [ ] Tab/1/2/3 switches between Players, Settings, Attitudes tabs
|
||||||
|
- [ ] Arrow keys navigate rows
|
||||||
|
- [ ] 'a' toggles admin on Players tab
|
||||||
|
- [ ] 'd' prompts delete confirmation on Players tab
|
||||||
|
- [ ] Enter edits value on Settings and Attitudes tabs
|
||||||
|
- [ ] ←→ switches player on Attitudes tab
|
||||||
|
- [ ] 'q' exits TUI
|
||||||
|
|
||||||
|
## Quick Smoke Test Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start server in background
|
||||||
|
RUST_LOG=info ./target/debug/mudserver &
|
||||||
|
SERVER_PID=$!
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Test 1: New player creation + basic commands
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF'
|
||||||
|
1
|
||||||
|
1
|
||||||
|
look
|
||||||
|
stats
|
||||||
|
go north
|
||||||
|
talk barkeep
|
||||||
|
go south
|
||||||
|
go south
|
||||||
|
examine thief
|
||||||
|
attack thief
|
||||||
|
flee
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Test 2: Persistence - reconnect
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF'
|
||||||
|
look
|
||||||
|
stats
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Test 3: Admin via mudtool
|
||||||
|
./target/debug/mudtool players list
|
||||||
|
./target/debug/mudtool players set-admin smoketest true
|
||||||
|
./target/debug/mudtool players show smoketest
|
||||||
|
./target/debug/mudtool settings set registration_open false
|
||||||
|
./target/debug/mudtool settings list
|
||||||
|
|
||||||
|
# Test 4: Admin commands in-game
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF'
|
||||||
|
admin help
|
||||||
|
admin list
|
||||||
|
admin registration on
|
||||||
|
admin info smoketest
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Test 5: Registration gate
|
||||||
|
./target/debug/mudtool settings set registration_open false
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 newplayer@localhost <<'EOF'
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
./target/debug/mudtool settings set registration_open true
|
||||||
|
./target/debug/mudtool players delete smoketest
|
||||||
|
kill $SERVER_PID
|
||||||
|
```
|
||||||
633
src/admin.rs
Normal file
633
src/admin.rs
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
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));
|
||||||
|
out.push_str(&format!(
|
||||||
|
" Inventory: {} item(s) | Weapon: {} | Armor: {}\r\n",
|
||||||
|
p.inventory.len(),
|
||||||
|
p.equipped_weapon
|
||||||
|
.as_ref()
|
||||||
|
.map(|w| w.name.as_str())
|
||||||
|
.unwrap_or("none"),
|
||||||
|
p.equipped_armor
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| a.name.as_str())
|
||||||
|
.unwrap_or("none"),
|
||||||
|
));
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
625
src/bin/mudtool.rs
Normal file
625
src/bin/mudtool.rs
Normal 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));
|
||||||
|
}
|
||||||
911
src/commands.rs
911
src/commands.rs
File diff suppressed because it is too large
Load Diff
112
src/db.rs
112
src/db.rs
@@ -16,6 +16,7 @@ pub struct SavedPlayer {
|
|||||||
pub inventory_json: String,
|
pub inventory_json: String,
|
||||||
pub equipped_weapon_json: Option<String>,
|
pub equipped_weapon_json: Option<String>,
|
||||||
pub equipped_armor_json: Option<String>,
|
pub equipped_armor_json: Option<String>,
|
||||||
|
pub is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct NpcAttitudeRow {
|
pub struct NpcAttitudeRow {
|
||||||
@@ -27,10 +28,16 @@ pub trait GameDb: Send + Sync {
|
|||||||
fn load_player(&self, name: &str) -> Option<SavedPlayer>;
|
fn load_player(&self, name: &str) -> Option<SavedPlayer>;
|
||||||
fn save_player(&self, player: &SavedPlayer);
|
fn save_player(&self, player: &SavedPlayer);
|
||||||
fn delete_player(&self, name: &str);
|
fn delete_player(&self, name: &str);
|
||||||
|
fn set_admin(&self, name: &str, is_admin: bool) -> bool;
|
||||||
|
fn list_all_players(&self) -> Vec<SavedPlayer>;
|
||||||
|
|
||||||
fn load_attitudes(&self, player_name: &str) -> Vec<NpcAttitudeRow>;
|
fn load_attitudes(&self, player_name: &str) -> Vec<NpcAttitudeRow>;
|
||||||
fn save_attitude(&self, player_name: &str, npc_id: &str, value: i32);
|
fn save_attitude(&self, player_name: &str, npc_id: &str, value: i32);
|
||||||
fn get_attitude(&self, player_name: &str, npc_id: &str) -> Option<i32>;
|
fn get_attitude(&self, player_name: &str, npc_id: &str) -> Option<i32>;
|
||||||
|
|
||||||
|
fn get_setting(&self, key: &str) -> Option<String>;
|
||||||
|
fn set_setting(&self, key: &str, value: &str);
|
||||||
|
fn list_settings(&self) -> Vec<(String, String)>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SQLite implementation ---
|
// --- SQLite implementation ---
|
||||||
@@ -61,7 +68,8 @@ impl SqliteDb {
|
|||||||
defense INTEGER NOT NULL,
|
defense INTEGER NOT NULL,
|
||||||
inventory_json TEXT NOT NULL DEFAULT '[]',
|
inventory_json TEXT NOT NULL DEFAULT '[]',
|
||||||
equipped_weapon_json TEXT,
|
equipped_weapon_json TEXT,
|
||||||
equipped_armor_json TEXT
|
equipped_armor_json TEXT,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS npc_attitudes (
|
CREATE TABLE IF NOT EXISTS npc_attitudes (
|
||||||
@@ -69,10 +77,25 @@ impl SqliteDb {
|
|||||||
npc_id TEXT NOT NULL,
|
npc_id TEXT NOT NULL,
|
||||||
value INTEGER NOT NULL,
|
value INTEGER NOT NULL,
|
||||||
PRIMARY KEY (player_name, npc_id)
|
PRIMARY KEY (player_name, npc_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS server_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
);",
|
);",
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("Failed to create tables: {e}"))?;
|
.map_err(|e| format!("Failed to create tables: {e}"))?;
|
||||||
|
|
||||||
|
// Migration: add is_admin column if missing
|
||||||
|
let has_admin: bool = conn
|
||||||
|
.prepare("SELECT COUNT(*) FROM pragma_table_info('players') WHERE name='is_admin'")
|
||||||
|
.and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0)))
|
||||||
|
.map(|c| c > 0)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !has_admin {
|
||||||
|
let _ = conn.execute("ALTER TABLE players ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0", []);
|
||||||
|
}
|
||||||
|
|
||||||
log::info!("Database opened: {}", path.display());
|
log::info!("Database opened: {}", path.display());
|
||||||
Ok(SqliteDb {
|
Ok(SqliteDb {
|
||||||
conn: std::sync::Mutex::new(conn),
|
conn: std::sync::Mutex::new(conn),
|
||||||
@@ -85,7 +108,8 @@ impl GameDb for SqliteDb {
|
|||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
||||||
attack, defense, inventory_json, equipped_weapon_json, equipped_armor_json
|
attack, defense, inventory_json, equipped_weapon_json,
|
||||||
|
equipped_armor_json, is_admin
|
||||||
FROM players WHERE name = ?1",
|
FROM players WHERE name = ?1",
|
||||||
[name],
|
[name],
|
||||||
|row| {
|
|row| {
|
||||||
@@ -103,6 +127,7 @@ impl GameDb for SqliteDb {
|
|||||||
inventory_json: row.get(10)?,
|
inventory_json: row.get(10)?,
|
||||||
equipped_weapon_json: row.get(11)?,
|
equipped_weapon_json: row.get(11)?,
|
||||||
equipped_armor_json: row.get(12)?,
|
equipped_armor_json: row.get(12)?,
|
||||||
|
is_admin: row.get::<_, i32>(13)? != 0,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -113,18 +138,21 @@ impl GameDb for SqliteDb {
|
|||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let _ = conn.execute(
|
let _ = conn.execute(
|
||||||
"INSERT INTO players (name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
"INSERT INTO players (name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
||||||
attack, defense, inventory_json, equipped_weapon_json, equipped_armor_json)
|
attack, defense, inventory_json, equipped_weapon_json,
|
||||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)
|
equipped_armor_json, is_admin)
|
||||||
|
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)
|
||||||
ON CONFLICT(name) DO UPDATE SET
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
room_id=excluded.room_id, level=excluded.level, xp=excluded.xp,
|
room_id=excluded.room_id, level=excluded.level, xp=excluded.xp,
|
||||||
hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack,
|
hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack,
|
||||||
defense=excluded.defense, inventory_json=excluded.inventory_json,
|
defense=excluded.defense, inventory_json=excluded.inventory_json,
|
||||||
equipped_weapon_json=excluded.equipped_weapon_json,
|
equipped_weapon_json=excluded.equipped_weapon_json,
|
||||||
equipped_armor_json=excluded.equipped_armor_json",
|
equipped_armor_json=excluded.equipped_armor_json,
|
||||||
|
is_admin=excluded.is_admin",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
p.name, p.race_id, p.class_id, p.room_id, p.level, p.xp,
|
p.name, p.race_id, p.class_id, p.room_id, p.level, p.xp,
|
||||||
p.hp, p.max_hp, p.attack, p.defense, p.inventory_json,
|
p.hp, p.max_hp, p.attack, p.defense, p.inventory_json,
|
||||||
p.equipped_weapon_json, p.equipped_armor_json,
|
p.equipped_weapon_json, p.equipped_armor_json,
|
||||||
|
p.is_admin as i32,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -135,6 +163,50 @@ impl GameDb for SqliteDb {
|
|||||||
let _ = conn.execute("DELETE FROM npc_attitudes WHERE player_name = ?1", [name]);
|
let _ = conn.execute("DELETE FROM npc_attitudes WHERE player_name = ?1", [name]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_admin(&self, name: &str, is_admin: bool) -> bool {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let rows = conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE players SET is_admin = ?1 WHERE name = ?2",
|
||||||
|
rusqlite::params![is_admin as i32, name],
|
||||||
|
)
|
||||||
|
.unwrap_or(0);
|
||||||
|
rows > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_all_players(&self) -> Vec<SavedPlayer> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
||||||
|
attack, defense, inventory_json, equipped_weapon_json,
|
||||||
|
equipped_armor_json, is_admin
|
||||||
|
FROM players ORDER BY name",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
stmt.query_map([], |row| {
|
||||||
|
Ok(SavedPlayer {
|
||||||
|
name: row.get(0)?,
|
||||||
|
race_id: row.get(1)?,
|
||||||
|
class_id: row.get(2)?,
|
||||||
|
room_id: row.get(3)?,
|
||||||
|
level: row.get(4)?,
|
||||||
|
xp: row.get(5)?,
|
||||||
|
hp: row.get(6)?,
|
||||||
|
max_hp: row.get(7)?,
|
||||||
|
attack: row.get(8)?,
|
||||||
|
defense: row.get(9)?,
|
||||||
|
inventory_json: row.get(10)?,
|
||||||
|
equipped_weapon_json: row.get(11)?,
|
||||||
|
equipped_armor_json: row.get(12)?,
|
||||||
|
is_admin: row.get::<_, i32>(13)? != 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn load_attitudes(&self, player_name: &str) -> Vec<NpcAttitudeRow> {
|
fn load_attitudes(&self, player_name: &str) -> Vec<NpcAttitudeRow> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
@@ -170,4 +242,34 @@ impl GameDb for SqliteDb {
|
|||||||
)
|
)
|
||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_setting(&self, key: &str) -> Option<String> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT value FROM server_settings WHERE key = ?1",
|
||||||
|
[key],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_setting(&self, key: &str, value: &str) {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let _ = conn.execute(
|
||||||
|
"INSERT INTO server_settings (key, value) VALUES (?1, ?2)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||||
|
rusqlite::params![key, value],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_settings(&self) -> Vec<(String, String)> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT key, value FROM server_settings ORDER BY key")
|
||||||
|
.unwrap();
|
||||||
|
stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
204
src/game.rs
204
src/game.rs
@@ -29,16 +29,25 @@ pub struct Player {
|
|||||||
pub inventory: Vec<Object>,
|
pub inventory: Vec<Object>,
|
||||||
pub equipped_weapon: Option<Object>,
|
pub equipped_weapon: Option<Object>,
|
||||||
pub equipped_armor: Option<Object>,
|
pub equipped_armor: Option<Object>,
|
||||||
|
pub is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Player {
|
impl Player {
|
||||||
pub fn effective_attack(&self) -> i32 {
|
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
|
self.stats.attack + bonus
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn effective_defense(&self) -> i32 {
|
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
|
self.stats.defense + bonus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,24 +83,41 @@ impl GameState {
|
|||||||
let mut npc_instances = HashMap::new();
|
let mut npc_instances = HashMap::new();
|
||||||
for npc in world.npcs.values() {
|
for npc in world.npcs.values() {
|
||||||
if let Some(ref combat) = npc.combat {
|
if let Some(ref combat) = npc.combat {
|
||||||
npc_instances.insert(npc.id.clone(), NpcInstance {
|
npc_instances.insert(
|
||||||
hp: combat.max_hp, alive: true, death_time: None,
|
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 {
|
pub fn spawn_room(&self) -> &str {
|
||||||
&self.world.spawn_room
|
&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 {
|
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) {
|
if let Some(val) = self.db.get_attitude(player_name, npc_id) {
|
||||||
return Attitude::from_value(val);
|
return Attitude::from_value(val);
|
||||||
}
|
}
|
||||||
self.world.get_npc(npc_id)
|
self.world
|
||||||
|
.get_npc(npc_id)
|
||||||
.map(|n| n.base_attitude)
|
.map(|n| n.base_attitude)
|
||||||
.unwrap_or(Attitude::Neutral)
|
.unwrap_or(Attitude::Neutral)
|
||||||
}
|
}
|
||||||
@@ -100,7 +126,8 @@ impl GameState {
|
|||||||
if let Some(val) = self.db.get_attitude(player_name, npc_id) {
|
if let Some(val) = self.db.get_attitude(player_name, npc_id) {
|
||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
self.world.get_npc(npc_id)
|
self.world
|
||||||
|
.get_npc(npc_id)
|
||||||
.map(|n| n.base_attitude.default_value())
|
.map(|n| n.base_attitude.default_value())
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
@@ -111,7 +138,6 @@ impl GameState {
|
|||||||
self.db.save_attitude(player_name, npc_id, new_val);
|
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) {
|
pub fn shift_faction_attitude(&self, faction: &str, player_name: &str, delta: i32) {
|
||||||
for npc in self.world.npcs.values() {
|
for npc in self.world.npcs.values() {
|
||||||
if npc.faction.as_deref() == Some(faction) {
|
if npc.faction.as_deref() == Some(faction) {
|
||||||
@@ -121,8 +147,13 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_new_player(
|
pub fn create_new_player(
|
||||||
&mut self, id: usize, name: String, race_id: String, class_id: String,
|
&mut self,
|
||||||
channel: ChannelId, handle: Handle,
|
id: usize,
|
||||||
|
name: String,
|
||||||
|
race_id: String,
|
||||||
|
class_id: String,
|
||||||
|
channel: ChannelId,
|
||||||
|
handle: Handle,
|
||||||
) {
|
) {
|
||||||
let room_id = self.world.spawn_room.clone();
|
let room_id = self.world.spawn_room.clone();
|
||||||
let race = self.world.races.iter().find(|r| r.id == race_id);
|
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 defense = base_def + con_mod / 2;
|
||||||
|
|
||||||
let stats = PlayerStats {
|
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 {
|
self.players.insert(
|
||||||
player: Player { name, race_id, class_id, room_id, stats, inventory: Vec::new(), equipped_weapon: None, equipped_armor: None },
|
id,
|
||||||
channel, handle, combat: None,
|
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(
|
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 inventory: Vec<Object> =
|
||||||
let equipped_weapon: Option<Object> = saved.equipped_weapon_json.as_deref().and_then(|j| serde_json::from_str(j).ok());
|
serde_json::from_str(&saved.inventory_json).unwrap_or_default();
|
||||||
let equipped_armor: Option<Object> = saved.equipped_armor_json.as_deref().and_then(|j| serde_json::from_str(j).ok());
|
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) {
|
let room_id = if self.world.rooms.contains_key(&saved.room_id) {
|
||||||
saved.room_id
|
saved.room_id
|
||||||
} else {
|
} else {
|
||||||
@@ -166,32 +228,65 @@ impl GameState {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let stats = PlayerStats {
|
let stats = PlayerStats {
|
||||||
max_hp: saved.max_hp, hp: saved.hp, attack: saved.attack, defense: saved.defense,
|
max_hp: saved.max_hp,
|
||||||
level: saved.level, xp: saved.xp, xp_to_next: saved.level * 100,
|
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 {
|
self.players.insert(
|
||||||
|
id,
|
||||||
|
PlayerConnection {
|
||||||
player: Player {
|
player: Player {
|
||||||
name: saved.name, race_id: saved.race_id, class_id: saved.class_id,
|
name: saved.name,
|
||||||
room_id, stats, inventory, equipped_weapon, equipped_armor,
|
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) {
|
pub fn save_player_to_db(&self, player_id: usize) {
|
||||||
if let Some(conn) = self.players.get(&player_id) {
|
if let Some(conn) = self.players.get(&player_id) {
|
||||||
let p = &conn.player;
|
let p = &conn.player;
|
||||||
let inv_json = serde_json::to_string(&p.inventory).unwrap_or_else(|_| "[]".into());
|
let inv_json =
|
||||||
let weapon_json = p.equipped_weapon.as_ref().map(|w| serde_json::to_string(w).unwrap_or_else(|_| "null".into()));
|
serde_json::to_string(&p.inventory).unwrap_or_else(|_| "[]".into());
|
||||||
let armor_json = p.equipped_armor.as_ref().map(|a| serde_json::to_string(a).unwrap_or_else(|_| "null".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 {
|
self.db.save_player(&SavedPlayer {
|
||||||
name: p.name.clone(), race_id: p.race_id.clone(), class_id: p.class_id.clone(),
|
name: p.name.clone(),
|
||||||
room_id: p.room_id.clone(), level: p.stats.level, xp: p.stats.xp,
|
race_id: p.race_id.clone(),
|
||||||
hp: p.stats.hp, max_hp: p.stats.max_hp, attack: p.stats.attack,
|
class_id: p.class_id.clone(),
|
||||||
defense: p.stats.defense, inventory_json: inv_json,
|
room_id: p.room_id.clone(),
|
||||||
equipped_weapon_json: weapon_json, equipped_armor_json: armor_json,
|
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> {
|
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)
|
.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) {
|
pub fn check_respawns(&mut self) {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
for (npc_id, instance) in self.npc_instances.iter_mut() {
|
for (npc_id, instance) in self.npc_instances.iter_mut() {
|
||||||
if instance.alive { continue; }
|
if instance.alive {
|
||||||
let npc = match self.world.npcs.get(npc_id) { Some(n) => n, None => continue };
|
continue;
|
||||||
let respawn_secs = match npc.respawn_secs { Some(s) => s, None => 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 let Some(death_time) = instance.death_time {
|
||||||
if now.duration_since(death_time).as_secs() >= respawn_secs {
|
if now.duration_since(death_time).as_secs() >= respawn_secs {
|
||||||
if let Some(ref combat) = npc.combat {
|
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> {
|
pub fn check_level_up(&mut self, player_id: usize) -> Option<String> {
|
||||||
let conn = self.players.get_mut(&player_id)?;
|
let conn = self.players.get_mut(&player_id)?;
|
||||||
let player = &mut conn.player;
|
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.xp -= player.stats.xp_to_next;
|
||||||
player.stats.level += 1;
|
player.stats.level += 1;
|
||||||
@@ -236,7 +343,11 @@ impl GameState {
|
|||||||
|
|
||||||
let class = self.world.classes.iter().find(|c| c.id == player.class_id);
|
let class = self.world.classes.iter().find(|c| c.id == player.class_id);
|
||||||
let (hp_g, atk_g, def_g) = match class {
|
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),
|
None => (10, 2, 1),
|
||||||
};
|
};
|
||||||
player.stats.max_hp += hp_g;
|
player.stats.max_hp += hp_g;
|
||||||
@@ -244,6 +355,9 @@ impl GameState {
|
|||||||
player.stats.attack += atk_g;
|
player.stats.attack += atk_g;
|
||||||
player.stats.defense += def_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
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/lib.rs
Normal file
9
src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
pub mod admin;
|
||||||
|
pub mod ansi;
|
||||||
|
pub mod chargen;
|
||||||
|
pub mod combat;
|
||||||
|
pub mod commands;
|
||||||
|
pub mod db;
|
||||||
|
pub mod game;
|
||||||
|
pub mod ssh;
|
||||||
|
pub mod world;
|
||||||
22
src/main.rs
22
src/main.rs
@@ -1,12 +1,3 @@
|
|||||||
mod ansi;
|
|
||||||
mod chargen;
|
|
||||||
mod combat;
|
|
||||||
mod commands;
|
|
||||||
mod db;
|
|
||||||
mod game;
|
|
||||||
mod ssh;
|
|
||||||
mod world;
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
@@ -15,6 +6,11 @@ use russh::keys::ssh_key::rand_core::OsRng;
|
|||||||
use russh::server::Server as _;
|
use russh::server::Server as _;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
use mudserver::db;
|
||||||
|
use mudserver::game;
|
||||||
|
use mudserver::ssh;
|
||||||
|
use mudserver::world;
|
||||||
|
|
||||||
const DEFAULT_PORT: u16 = 2222;
|
const DEFAULT_PORT: u16 = 2222;
|
||||||
const DEFAULT_WORLD_DIR: &str = "./world";
|
const DEFAULT_WORLD_DIR: &str = "./world";
|
||||||
const DEFAULT_DB_PATH: &str = "./mudserver.db";
|
const DEFAULT_DB_PATH: &str = "./mudserver.db";
|
||||||
@@ -33,7 +29,10 @@ async fn main() {
|
|||||||
match args[i].as_str() {
|
match args[i].as_str() {
|
||||||
"--port" | "-p" => {
|
"--port" | "-p" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
port = args.get(i).and_then(|s| s.parse().ok()).expect("--port requires a number");
|
port = args
|
||||||
|
.get(i)
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.expect("--port requires a number");
|
||||||
}
|
}
|
||||||
"--world" | "-w" => {
|
"--world" | "-w" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
@@ -71,7 +70,8 @@ async fn main() {
|
|||||||
});
|
});
|
||||||
let db: Arc<dyn db::GameDb> = Arc::new(database);
|
let db: Arc<dyn db::GameDb> = Arc::new(database);
|
||||||
|
|
||||||
let key = russh::keys::PrivateKey::random(&mut OsRng, russh::keys::Algorithm::Ed25519).unwrap();
|
let key =
|
||||||
|
russh::keys::PrivateKey::random(&mut OsRng, russh::keys::Algorithm::Ed25519).unwrap();
|
||||||
let config = russh::server::Config {
|
let config = russh::server::Config {
|
||||||
inactivity_timeout: Some(std::time::Duration::from_secs(3600)),
|
inactivity_timeout: Some(std::time::Duration::from_secs(3600)),
|
||||||
auth_rejection_time: std::time::Duration::from_secs(1),
|
auth_rejection_time: std::time::Duration::from_secs(1),
|
||||||
|
|||||||
249
src/ssh.rs
249
src/ssh.rs
@@ -15,7 +15,10 @@ pub struct MudServer {
|
|||||||
|
|
||||||
impl MudServer {
|
impl MudServer {
|
||||||
pub fn new(state: SharedState) -> Self {
|
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);
|
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
|
||||||
log::info!("New connection (id={id}) from {addr:?}");
|
log::info!("New connection (id={id}) from {addr:?}");
|
||||||
MudHandler {
|
MudHandler {
|
||||||
id, username: String::new(), channel: None, handle: None,
|
id,
|
||||||
line_buffer: String::new(), chargen: None, state: self.state.clone(),
|
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>,
|
channel: Option<ChannelId>,
|
||||||
handle: Option<Handle>,
|
handle: Option<Handle>,
|
||||||
line_buffer: String,
|
line_buffer: String,
|
||||||
// None = not yet determined, Some(None) = returning player, Some(Some(cg)) = in chargen
|
|
||||||
chargen: Option<Option<ChargenState>>,
|
chargen: Option<Option<ChargenState>>,
|
||||||
|
rejected: bool,
|
||||||
state: SharedState,
|
state: SharedState,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,32 +64,41 @@ impl MudHandler {
|
|||||||
async fn start_session(&mut self, session: &mut Session, channel: ChannelId) {
|
async fn start_session(&mut self, session: &mut Session, channel: ChannelId) {
|
||||||
let state = self.state.lock().await;
|
let state = self.state.lock().await;
|
||||||
let world_name = state.world.name.clone();
|
let world_name = state.world.name.clone();
|
||||||
|
|
||||||
// Check if returning player
|
|
||||||
let saved = state.db.load_player(&self.username);
|
let saved = state.db.load_player(&self.username);
|
||||||
|
let registration_open = state.is_registration_open();
|
||||||
drop(state);
|
drop(state);
|
||||||
|
|
||||||
let welcome = format!(
|
let welcome = format!(
|
||||||
"{}\r\n{}Welcome to {}, {}!\r\n",
|
"{}\r\n{}Welcome to {}, {}!\r\n",
|
||||||
ansi::CLEAR_SCREEN, ansi::welcome_banner(),
|
ansi::CLEAR_SCREEN,
|
||||||
ansi::bold(&world_name), ansi::player_name(&self.username),
|
ansi::welcome_banner(),
|
||||||
|
ansi::bold(&world_name),
|
||||||
|
ansi::player_name(&self.username),
|
||||||
);
|
);
|
||||||
self.send_text(session, channel, &welcome);
|
self.send_text(session, channel, &welcome);
|
||||||
|
|
||||||
if let Some(saved) = saved {
|
if let Some(saved) = saved {
|
||||||
// Returning player — load from DB
|
|
||||||
let handle = session.handle();
|
let handle = session.handle();
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock().await;
|
||||||
state.load_existing_player(self.id, saved, channel, handle);
|
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);
|
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;
|
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 {
|
} else {
|
||||||
// New player — start chargen
|
|
||||||
let cg = ChargenState::new();
|
let cg = ChargenState::new();
|
||||||
let state = self.state.lock().await;
|
let state = self.state.lock().await;
|
||||||
let prompt = cg.prompt_text(&state.world);
|
let prompt = cg.prompt_text(&state.world);
|
||||||
@@ -98,13 +116,20 @@ impl MudHandler {
|
|||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Broadcast arrival
|
|
||||||
let arrival = CryptoVec::from(
|
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);
|
let room_view = render_entry_room(&state, &room_id, &player_name, self.id);
|
||||||
drop(state);
|
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 handle = session.handle();
|
||||||
let mut state = self.state.lock().await;
|
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 race_name = state
|
||||||
let class_name = state.world.classes.iter().find(|c| c.id == class_id).map(|c| c.name.clone()).unwrap_or_default();
|
.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);
|
state.save_player_to_db(self.id);
|
||||||
|
|
||||||
drop(state);
|
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.send_text(session, channel, &msg);
|
||||||
|
|
||||||
self.chargen = Some(None);
|
self.chargen = Some(None);
|
||||||
@@ -138,26 +193,54 @@ impl MudHandler {
|
|||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock().await;
|
||||||
if let Some(conn) = state.remove_player(self.id) {
|
if let Some(conn) = state.remove_player(self.id) {
|
||||||
let departure = CryptoVec::from(
|
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);
|
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);
|
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 {
|
fn render_entry_room(
|
||||||
let room = match state.world.get_room(room_id) { Some(r) => r, None => return String::new() };
|
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();
|
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));
|
out.push_str(&format!(" {}\r\n", room.description));
|
||||||
|
|
||||||
let npc_strs: Vec<String> = room.npcs.iter().filter_map(|id| {
|
let npc_strs: Vec<String> = room
|
||||||
|
.npcs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| {
|
||||||
let npc = state.world.get_npc(id)?;
|
let npc = state.world.get_npc(id)?;
|
||||||
let alive = state.npc_instances.get(id).map(|i| i.alive).unwrap_or(true);
|
let alive = state.npc_instances.get(id).map(|i| i.alive).unwrap_or(true);
|
||||||
if !alive { return None; }
|
if !alive {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let att = state.npc_attitude_toward(id, player_name);
|
let att = state.npc_attitude_toward(id, player_name);
|
||||||
let color = match att {
|
let color = match att {
|
||||||
crate::world::Attitude::Friendly => ansi::GREEN,
|
crate::world::Attitude::Friendly => ansi::GREEN,
|
||||||
@@ -165,22 +248,38 @@ fn render_entry_room(state: &crate::game::GameState, room_id: &str, player_name:
|
|||||||
_ => ansi::RED,
|
_ => ansi::RED,
|
||||||
};
|
};
|
||||||
Some(ansi::color(color, &npc.name))
|
Some(ansi::color(color, &npc.name))
|
||||||
}).collect();
|
})
|
||||||
|
.collect();
|
||||||
if !npc_strs.is_empty() {
|
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);
|
let others = state.players_in_room(room_id, player_id);
|
||||||
if !others.is_empty() {
|
if !others.is_empty() {
|
||||||
let names: Vec<String> = others.iter().map(|c| ansi::player_name(&c.player.name)).collect();
|
let names: Vec<String> = others
|
||||||
out.push_str(&format!("{}{}\r\n", ansi::color(ansi::GREEN, "Players here: "), names.join(", ")));
|
.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() {
|
if !room.exits.is_empty() {
|
||||||
let mut dirs: Vec<&String> = room.exits.keys().collect();
|
let mut dirs: Vec<&String> = room.exits.keys().collect();
|
||||||
dirs.sort();
|
dirs.sort();
|
||||||
let dir_strs: Vec<String> = dirs.iter().map(|d| ansi::direction(d)).collect();
|
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.push_str(&ansi::prompt());
|
||||||
out
|
out
|
||||||
@@ -195,7 +294,11 @@ impl russh::server::Handler for MudHandler {
|
|||||||
Ok(Auth::Accept)
|
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();
|
self.username = user.to_string();
|
||||||
Ok(Auth::Accept)
|
Ok(Auth::Accept)
|
||||||
}
|
}
|
||||||
@@ -205,24 +308,52 @@ impl russh::server::Handler for MudHandler {
|
|||||||
Ok(Auth::Accept)
|
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.channel = Some(channel.id());
|
||||||
self.handle = Some(session.handle());
|
self.handle = Some(session.handle());
|
||||||
Ok(true)
|
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)?;
|
session.channel_success(channel)?;
|
||||||
Ok(())
|
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)?;
|
session.channel_success(channel)?;
|
||||||
self.start_session(session, channel).await;
|
self.start_session(session, channel).await;
|
||||||
Ok(())
|
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 {
|
for &byte in data {
|
||||||
match byte {
|
match byte {
|
||||||
3 | 4 => {
|
3 | 4 => {
|
||||||
@@ -237,7 +368,9 @@ impl russh::server::Handler for MudHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
b'\r' | b'\n' => {
|
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"[..]))?;
|
session.data(channel, CryptoVec::from(&b"\r\n"[..]))?;
|
||||||
let line = std::mem::take(&mut self.line_buffer);
|
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;
|
let state = self.state.lock().await;
|
||||||
cg.handle_input(&line, &state.world)
|
cg.handle_input(&line, &state.world)
|
||||||
};
|
};
|
||||||
let msg_text = match result { Ok(msg) | Err(msg) => msg };
|
let msg_text = match result {
|
||||||
let _ = session.data(channel, CryptoVec::from(msg_text.as_bytes()));
|
Ok(msg) | Err(msg) => msg,
|
||||||
|
};
|
||||||
|
let _ =
|
||||||
|
session.data(channel, CryptoVec::from(msg_text.as_bytes()));
|
||||||
if cg.is_done() {
|
if cg.is_done() {
|
||||||
chargen_done = cg.result();
|
chargen_done = cg.result();
|
||||||
}
|
}
|
||||||
@@ -260,16 +396,17 @@ impl russh::server::Handler for MudHandler {
|
|||||||
}
|
}
|
||||||
if let Some((race_id, class_id)) = chargen_done {
|
if let Some((race_id, class_id)) = chargen_done {
|
||||||
self.chargen = None;
|
self.chargen = None;
|
||||||
self.finish_chargen(race_id, class_id, session, channel).await;
|
self.finish_chargen(race_id, class_id, session, channel)
|
||||||
|
.await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if chargen_active {
|
if chargen_active {
|
||||||
// Still in chargen, show next prompt
|
|
||||||
if let Some(Some(ref cg)) = self.chargen {
|
if let Some(Some(ref cg)) = self.chargen {
|
||||||
let state = self.state.lock().await;
|
let state = self.state.lock().await;
|
||||||
let prompt = cg.prompt_text(&state.world);
|
let prompt = cg.prompt_text(&state.world);
|
||||||
drop(state);
|
drop(state);
|
||||||
let _ = session.data(channel, CryptoVec::from(prompt.as_bytes()));
|
let _ =
|
||||||
|
session.data(channel, CryptoVec::from(prompt.as_bytes()));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -277,7 +414,9 @@ impl russh::server::Handler for MudHandler {
|
|||||||
continue;
|
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 {
|
if !keep_going {
|
||||||
self.handle_disconnect().await;
|
self.handle_disconnect().await;
|
||||||
session.close(channel)?;
|
session.close(channel)?;
|
||||||
@@ -295,12 +434,20 @@ impl russh::server::Handler for MudHandler {
|
|||||||
Ok(())
|
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;
|
self.handle_disconnect().await;
|
||||||
Ok(())
|
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;
|
self.handle_disconnect().await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user