Add SQLite persistence, per-player NPC attitude system, character creation, and combat

- Add trait-based DB layer (db.rs) with SQLite backend for easy future swapping
- Player state persisted to SQLite: stats, inventory, equipment, room position
- Returning players skip chargen and resume where they left off
- Replace boolean hostile flag with 5-tier attitude system (friendly/neutral/wary/aggressive/hostile)
- Per-player NPC attitudes stored in DB, shift on kills with faction propagation
- Add character creation flow (chargen.rs) with data-driven races and classes from TOML
- Add turn-based combat system (combat.rs) with XP, leveling, and NPC respawn
- Add commands: take, drop, inventory, equip, use, examine, talk, attack, flee, stats
- Add world data: 5 races, 4 classes, hostile NPCs (rat, thief), new items

Made-with: Cursor
This commit is contained in:
AI Agent
2026-03-14 13:58:22 -06:00
parent c82f57a720
commit 680f48477e
28 changed files with 1797 additions and 673 deletions

View File

@@ -1,5 +1,8 @@
mod ansi;
mod chargen;
mod combat;
mod commands;
mod db;
mod game;
mod ssh;
mod world;
@@ -14,6 +17,7 @@ use tokio::net::TcpListener;
const DEFAULT_PORT: u16 = 2222;
const DEFAULT_WORLD_DIR: &str = "./world";
const DEFAULT_DB_PATH: &str = "./mudserver.db";
#[tokio::main]
async fn main() {
@@ -21,6 +25,7 @@ async fn main() {
let mut port = DEFAULT_PORT;
let mut world_dir = PathBuf::from(DEFAULT_WORLD_DIR);
let mut db_path = PathBuf::from(DEFAULT_DB_PATH);
let args: Vec<String> = std::env::args().collect();
let mut i = 1;
@@ -28,26 +33,25 @@ async fn main() {
match args[i].as_str() {
"--port" | "-p" => {
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" => {
i += 1;
world_dir = PathBuf::from(
args.get(i).expect("--world requires a path"),
);
world_dir = PathBuf::from(args.get(i).expect("--world requires a path"));
}
"--db" | "-d" => {
i += 1;
db_path = PathBuf::from(args.get(i).expect("--db requires a path"));
}
"--help" => {
eprintln!("Usage: mudserver [--port PORT] [--world PATH]");
eprintln!("Usage: mudserver [OPTIONS]");
eprintln!(" --port, -p Listen port (default: {DEFAULT_PORT})");
eprintln!(" --world, -w World directory (default: {DEFAULT_WORLD_DIR})");
eprintln!(" --db, -d Database path (default: {DEFAULT_DB_PATH})");
std::process::exit(0);
}
other => {
eprintln!("Unknown argument: {other}");
eprintln!("Run with --help for usage.");
std::process::exit(1);
}
}
@@ -60,9 +64,14 @@ async fn main() {
std::process::exit(1);
});
let key =
russh::keys::PrivateKey::random(&mut OsRng, russh::keys::Algorithm::Ed25519).unwrap();
log::info!("Opening database: {}", db_path.display());
let database = db::SqliteDb::open(&db_path).unwrap_or_else(|e| {
eprintln!("Failed to open database: {e}");
std::process::exit(1);
});
let db: Arc<dyn db::GameDb> = Arc::new(database);
let key = russh::keys::PrivateKey::random(&mut OsRng, russh::keys::Algorithm::Ed25519).unwrap();
let config = russh::server::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(3600)),
auth_rejection_time: std::time::Duration::from_secs(1),
@@ -72,7 +81,7 @@ async fn main() {
};
let config = Arc::new(config);
let state = Arc::new(Mutex::new(game::GameState::new(loaded_world)));
let state = Arc::new(Mutex::new(game::GameState::new(loaded_world, db)));
let mut server = ssh::MudServer::new(state);
let listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap();