Compare commits

..

2 Commits

Author SHA1 Message Date
AI Agent
3f164e4697 Add README and agent guidelines documentation
README covers building, running, connecting, world data format,
game mechanics, database schema, mudtool usage, and admin system.
AGENTS.md provides architecture overview, concurrency model, coding
conventions, and step-by-step guides for extending the codebase.

Made-with: Cursor
2026-03-14 15:12:49 -06:00
AI Agent
5fd2c10198 Implement tick-based game loop, combat overhaul, and attack-any-NPC
Replace immediate combat with a 3-second tick engine that resolves
actions, NPC AI, status effects, respawns, and passive regeneration.
Players queue combat actions (attack/defend/flee/use) that resolve on
the next tick. Any NPC can now be attacked — non-hostile targets incur
attitude penalties instead of being blocked. Status effects persist in
the database and continue ticking while players are offline.

Made-with: Cursor
2026-03-14 15:12:44 -06:00
11 changed files with 1286 additions and 181 deletions

120
AGENTS.md Normal file
View File

@@ -0,0 +1,120 @@
# Agent Guidelines — MUD Server
Instructions for AI coding agents working on this codebase.
## Project Overview
This is a Rust MUD server that accepts SSH connections. The architecture separates the binary server/game engine from data-driven world content defined in TOML files.
**Key design decisions:**
- Game runs on a **tick-based loop** (~3s). Combat, status effects, NPC AI, and regen resolve on ticks, not on player input.
- Any NPC can be attacked. Hostility is a consequence system, not a permission system.
- Status effects persist in the database and continue ticking while players are offline.
- The `GameDb` trait abstracts the database backend (currently SQLite).
- World data is loaded from TOML at startup. Content changes don't require recompilation.
## Architecture
```
src/
├── main.rs Entry point: arg parsing, world/db init, tick spawn, SSH listen
├── lib.rs Library crate — exports all modules for shared use by mudserver + mudtool
├── ssh.rs russh server/handler: connection lifecycle, chargen flow, command dispatch
├── game.rs Core runtime state: Player, GameState, SharedState, XorShift64 RNG
├── commands.rs Player command parsing and execution (immediate + queued actions)
├── combat.rs Tick-based combat resolution: attack, defend, flee, item use
├── tick.rs Background tick engine: NPC AI, combat rounds, effects, respawns, regen
├── admin.rs Admin command implementations
├── chargen.rs Character creation state machine
├── db.rs GameDb trait + SqliteDb implementation
├── world.rs TOML schema types, runtime types, World::load()
├── ansi.rs ANSI escape code helpers
└── bin/
└── mudtool.rs External DB management tool (CLI + TUI)
```
## Concurrency Model
- `SharedState` = `Arc<Mutex<GameState>>` (tokio mutex)
- The tick engine and SSH handlers both lock `GameState`. Locks are held briefly.
- Player output from ticks is collected into a `HashMap<pid, String>`, then sent after dropping the lock.
- Never hold the game state lock across `.await` points that involve network I/O.
## How Combat Works
1. Player types `attack <npc>` → enters `CombatState` with `action: Some(Attack)`
2. Player can queue a different action before the tick fires (`defend`, `flee`, `use <item>`)
3. Tick engine iterates all players in combat, calls `combat::resolve_combat_tick()`
4. Resolution: execute player action → NPC counter-attacks → check death → clear action
5. If no action was queued, default is `Attack`
6. NPC auto-aggro: hostile NPCs initiate combat with players in their room on each tick
## How Status Effects Work
- Stored in `status_effects` table: `(player_name, kind, remaining_ticks, magnitude)`
- `tick_all_effects()` decrements all rows, returns them, then deletes expired ones
- Online players: HP modified in-memory + saved to DB
- Offline players: HP modified directly in `players` table via DB
- Effects cleared on player death
## Adding New Features
### New command
1. Add handler function in `commands.rs` (follow `cmd_*` pattern)
2. Add match arm in the `execute()` dispatch
3. If it's a combat action, queue it on `combat.action` instead of executing immediately
4. Update `cmd_help()` text
5. Add to TESTING.md checklist
### New status effect kind
1. Add a match arm in `tick.rs` under the effect processing loop
2. Handle both online (in-memory) and offline (DB-only) players
3. The `kind` field is a free-form string — no enum needed
### New NPC behavior
1. NPC AI runs in `tick.rs` at the top of the tick cycle
2. Currently: hostile NPCs auto-engage players in their room
3. Add new behaviors there (e.g. NPC movement, dialogue triggers)
### New world content
1. Add TOML files under `world/<region>/` — no code changes needed
2. NPCs without a `[combat]` section get default stats (20hp/4atk/2def/5xp)
3. Room IDs are `<region_dir>:<filename_stem>`
4. Cross-region exits work — just reference the full ID
### New DB table
1. Add `CREATE TABLE IF NOT EXISTS` in `SqliteDb::open()`
2. Add trait methods to `GameDb`
3. Implement for `SqliteDb`
4. Update `mudtool` if the data should be manageable externally
## Conventions
- **No unnecessary comments.** Don't narrate what code does. Comments explain non-obvious intent only.
- **KISS.** Don't add abstraction layers unless there's a concrete second use case.
- **Borrow checker patterns:** This codebase frequently needs to work around Rust's borrow rules when mutating `GameState`. Common pattern: read data into locals, drop the borrow, then mutate. See `cmd_attack` for examples.
- **ANSI formatting:** Use helpers in `ansi.rs`. Don't hardcode escape codes elsewhere.
- **Error handling:** Game logic uses `Option`/early-return patterns, not `Result` chains. DB errors are silently swallowed (logged at debug level) — the game should not crash from a failed DB write.
- **Testing:** There is no automated test suite. `TESTING.md` contains a manual checklist and smoke test script. Run through relevant sections before committing.
## Common Pitfalls
- **Holding the lock too long:** `state.lock().await` grabs a tokio mutex. If you `.await` network I/O while holding it, the tick engine and all other players will block. Collect data, drop the lock, then send.
- **Borrow splitting:** `GameState` owns `world`, `players`, `npc_instances`, and `db`. You can't borrow `players` mutably while also reading `world` through the same `&mut GameState`. Extract what you need from `world` first.
- **Tick engine ordering:** The tick processes in this order: respawns → NPC aggro → combat rounds → status effects → passive regen → send messages. Changing this order can create bugs (e.g. regen before combat means players heal before taking damage).
- **Offline player effects:** Status effects tick in the DB for ALL players. If you add a new effect, handle both the online path (modify in-memory player) and offline path (load/modify/save via `SavedPlayer`).
## Build & Run
```bash
cargo build
./target/debug/mudserver --world ./world --db ./mudserver.db --port 2222
ssh testplayer@localhost -p 2222
```
## Git
- Remote: `https://git.coven.systems/lily/mudserver`
- Commit messages: imperative mood, explain why not what
- Update `TESTING.md` when adding features
- Run through the relevant test checklist sections before pushing

276
README.md Normal file
View File

@@ -0,0 +1,276 @@
# MUD Server
A text-based multiplayer RPG (MUD) that accepts connections over SSH. Written in Rust with a data-driven world definition system — rooms, NPCs, objects, races, and classes are all defined in TOML files and can be changed without recompiling.
## Requirements
- Rust toolchain (edition 2021+)
- SQLite is bundled via `rusqlite` — no system SQLite needed
## Building
```bash
cargo build # builds both mudserver and mudtool
cargo build --release # optimized build
```
This produces two binaries:
- `mudserver` — the game server
- `mudtool` — database management CLI/TUI
## Running the Server
```bash
./target/debug/mudserver
```
### Options
| Flag | Default | Description |
|------|---------|-------------|
| `--port`, `-p` | `2222` | SSH listen port |
| `--world`, `-w` | `./world` | Path to world data directory |
| `--db`, `-d` | `./mudserver.db` | Path to SQLite database file |
The server generates a random SSH host key on each startup. The database is created automatically if it doesn't exist.
### Connecting
Any SSH client works. The username becomes the player's character name:
```bash
ssh mycharacter@localhost -p 2222
```
Password and key auth are both accepted (no real authentication — this is a game server, not a secure shell).
### Environment
Set `RUST_LOG` to control log verbosity:
```bash
RUST_LOG=info ./target/release/mudserver # default
RUST_LOG=debug ./target/release/mudserver # verbose
```
## World Data
The world is defined entirely in TOML files under a `world/` directory. The server reads this at startup — no recompilation needed to change content.
### Directory Structure
```
world/
├── manifest.toml # world name and spawn room
├── races/ # playable races
│ ├── dwarf.toml
│ ├── elf.toml
│ └── ...
├── classes/ # playable classes
│ ├── warrior.toml
│ ├── mage.toml
│ └── ...
└── <region>/ # one directory per region
├── region.toml # region metadata
├── rooms/
│ ├── town_square.toml
│ └── ...
├── npcs/
│ ├── barkeep.toml
│ └── ...
└── objects/
├── rusty_sword.toml
└── ...
```
### manifest.toml
```toml
name = "The Shattered Realm"
spawn_room = "town:town_square"
```
### Room
```toml
name = "Town Square"
description = "A cobblestone square with a fountain."
[exits]
north = "town:tavern"
south = "town:gate"
east = "town:market"
```
Room IDs are `<region>:<filename_stem>`.
### NPC
```toml
name = "Town Guard"
description = "A bored guard."
room = "town:gate"
base_attitude = "neutral" # friendly, neutral, wary, aggressive, hostile
faction = "guards" # optional — attitude shifts propagate to faction
respawn_secs = 90 # optional — respawn timer after death
[dialogue]
greeting = "Move along."
[combat] # optional — omit for weak default stats (20hp/4atk/2def/5xp)
max_hp = 60
attack = 10
defense = 8
xp_reward = 25
```
### Object
```toml
name = "Rusty Sword"
description = "A battered iron blade."
room = "town:cellar"
kind = "weapon" # weapon, armor, consumable, treasure, or omit
takeable = true
[stats]
damage = 5 # for weapons
# armor = 4 # for armor
# heal_amount = 30 # for consumables
```
### Race
```toml
name = "Dwarf"
description = "Stout and unyielding."
[stats]
strength = 1
dexterity = -1
constitution = 2
```
### Class
```toml
name = "Warrior"
description = "Masters of arms and armor."
[base_stats]
max_hp = 120
attack = 14
defense = 12
[growth]
hp_per_level = 15
attack_per_level = 3
defense_per_level = 2
```
## Game Mechanics
### Tick System
The game runs on a **3-second tick cycle**. Combat actions, status effects, NPC AI, and passive regeneration all resolve on ticks rather than immediately.
### Combat
Combat is tick-based. When a player enters combat (via `attack` or NPC aggro), they choose actions each tick:
| Command | Effect |
|---------|--------|
| `attack` / `a` | Strike the enemy (default if no action queued) |
| `defend` / `def` | Brace — doubles effective defense for the tick |
| `flee` | Attempt to escape (success chance based on DEF stat) |
| `use <item>` | Use a consumable during combat |
Any NPC can be attacked. Attacking non-hostile NPCs carries attitude penalties (-30 individual, -15 faction) and a warning message but is not blocked.
### Attitude System
Every NPC tracks a per-player attitude value from -100 to +100:
| Range | Label | Behavior |
|-------|-------|----------|
| 50 to 100 | Friendly | Will talk |
| 10 to 49 | Neutral | Will talk |
| -24 to 9 | Wary | Will talk |
| -25 to -74 | Aggressive | Won't talk, attackable |
| -75 to -100 | Hostile | Attacks on sight |
Attitudes shift from combat interactions and propagate through NPC factions.
### Status Effects
Effects like poison and regeneration are stored in the database and tick down every cycle — including while the player is offline. Effects are cleared on death.
### Passive Regeneration
Players out of combat regenerate 5% of max HP every 5 ticks (~15 seconds).
## Database
SQLite with WAL mode. Tables:
- `players` — character data (stats, inventory, equipment, room, admin flag)
- `npc_attitudes` — per-player, per-NPC attitude values
- `server_settings` — key-value config (e.g. `registration_open`)
- `status_effects` — active effects with remaining tick counters
The database is accessed through a `GameDb` trait, making backend swaps possible.
## mudtool
Database management tool with both CLI and TUI modes.
### CLI
```bash
mudtool --db ./mudserver.db players list
mudtool --db ./mudserver.db players show hero
mudtool --db ./mudserver.db players set-admin hero true
mudtool --db ./mudserver.db players delete hero
mudtool --db ./mudserver.db settings list
mudtool --db ./mudserver.db settings set registration_open false
mudtool --db ./mudserver.db attitudes list hero
mudtool --db ./mudserver.db attitudes set hero town:guard 50
```
### TUI
```bash
mudtool --db ./mudserver.db tui
```
Interactive interface with tabs for Players, Settings, and Attitudes. Navigate with arrow keys, Tab/1/2/3 to switch tabs, `a` to toggle admin, `d` to delete, Enter to edit values, `q` to quit.
## Admin System
Players with the `is_admin` flag can use in-game admin commands:
```
admin promote <player> Grant admin
admin demote <player> Revoke admin
admin kick <player> Disconnect player
admin teleport <room_id> Warp to room
admin registration on|off Toggle new player creation
admin announce <message> Broadcast to all
admin heal [player] Full heal (self or target)
admin info <player> Detailed player info
admin setattitude <player> <npc> <value> Set attitude
admin list All players (online + saved)
```
The first admin must be set via `mudtool players set-admin <name> true`.
## Registration Gate
New player creation can be toggled:
```bash
mudtool settings set registration_open false # block new players
mudtool settings set registration_open true # allow new players (default)
```
Or in-game: `admin registration off` / `admin registration on`. Existing players can always log in regardless of this setting.

View File

@@ -39,21 +39,24 @@ Run through these checks before every commit to ensure consistent feature covera
- [ ] Dead NPCs don't appear in room view - [ ] Dead NPCs don't appear in room view
## Combat - Tick-Based ## Combat - Tick-Based
- [ ] `attack <aggressive/hostile npc>` enters combat state - [ ] `attack <npc>` enters combat state with any NPC that has combat stats
- [ ] Can't attack friendly/neutral NPCs - [ ] Attacking friendly/neutral NPCs is allowed but incurs attitude penalties
- [ ] Attacking non-hostile NPC: attitude shift -30 individual, -15 faction
- [ ] "The locals look on in horror" message when attacking non-hostile
- [ ] Combat rounds resolve automatically on server ticks (not on command) - [ ] Combat rounds resolve automatically on server ticks (not on command)
- [ ] Player receives tick-by-tick combat output (damage dealt, damage taken) - [ ] Player receives tick-by-tick combat output (damage dealt, damage taken)
- [ ] Default combat action is "attack" if no other action queued - [ ] Default combat action is "attack" if no other action queued
- [ ] `defend` / `def` sets defensive stance (reduced incoming damage next tick) - [ ] `defend` / `def` sets defensive stance (reduced incoming damage next tick)
- [ ] NPC death: awards XP, shifts attitude -10, shifts faction -5 - [ ] NPC death: awards XP, shifts attitude -10, shifts faction -5
- [ ] Player death: respawns at spawn room with full HP, combat cleared - [ ] Player death: respawns at spawn room with full HP, combat cleared, effects cleared
- [ ] NPCs respawn after configured time - [ ] NPCs respawn after configured time
- [ ] Combat lockout: can only attack/defend/flee/look/use/quit during combat - [ ] Combat lockout: can only attack/defend/flee/look/stats/inv/use/quit during combat
- [ ] `flee` queues escape attempt — may fail based on stats - [ ] `flee` queues escape attempt — may fail based on stats
- [ ] `use <item>` in combat queues item use for next tick - [ ] `use <item>` in combat queues item use for next tick
- [ ] Multiple ticks of combat resolve correctly without player input - [ ] Multiple ticks of combat resolve correctly without player input
- [ ] Combat ends when NPC dies (player exits combat state) - [ ] Combat ends when NPC dies (player exits combat state)
- [ ] Combat ends when player flees successfully - [ ] Combat ends when player flees successfully
- [ ] NPCs without explicit [combat] section get default stats (20 HP, 4 ATK, 2 DEF, 5 XP)
## Combat - NPC AI ## Combat - NPC AI
- [ ] Hostile NPCs auto-engage players who enter their room - [ ] Hostile NPCs auto-engage players who enter their room
@@ -88,7 +91,7 @@ Run through these checks before every commit to ensure consistent feature covera
- [ ] HP does not exceed max_hp - [ ] HP does not exceed max_hp
## Tick Engine ## Tick Engine
- [ ] Tick runs at configured interval (~2 seconds) - [ ] Tick runs at configured interval (~3 seconds)
- [ ] Tick processes: NPC AI → combat rounds → status effects → respawns → regen - [ ] Tick processes: NPC AI → combat rounds → status effects → respawns → regen
- [ ] Tick output is delivered to players promptly - [ ] Tick output is delivered to players promptly
- [ ] Server remains responsive to immediate commands between ticks - [ ] Server remains responsive to immediate commands between ticks

View File

@@ -1,51 +1,75 @@
use std::time::Instant; use std::time::Instant;
use crate::ansi; use crate::ansi;
use crate::game::GameState; use crate::game::{CombatAction, GameState};
pub struct CombatRoundResult { pub struct CombatRoundResult {
pub output: String, pub output: String,
pub npc_died: bool, pub npc_died: bool,
pub player_died: bool, pub player_died: bool,
pub xp_gained: i32, pub xp_gained: i32,
pub fled: bool,
} }
pub fn do_attack(player_id: usize, npc_id: &str, state: &mut GameState) -> Option<CombatRoundResult> { pub fn resolve_combat_tick(
let npc_template = state.world.get_npc(npc_id)?.clone(); player_id: usize,
state: &mut GameState,
) -> Option<CombatRoundResult> {
let (npc_id, action, was_defending) = {
let conn = state.players.get(&player_id)?;
let combat = conn.combat.as_ref()?;
let action = combat.action.clone().unwrap_or(CombatAction::Attack);
(combat.npc_id.clone(), action, combat.defending)
};
let npc_template = state.world.get_npc(&npc_id)?.clone();
let npc_combat = npc_template.combat.as_ref()?; let npc_combat = npc_template.combat.as_ref()?;
let instance = state.npc_instances.get(npc_id)?; let instance = state.npc_instances.get(&npc_id)?;
if !instance.alive { if !instance.alive {
return None; if let Some(conn) = state.players.get_mut(&player_id) {
conn.combat = None;
}
return Some(CombatRoundResult {
output: format!(
" {} {} is already dead. Combat ended.\r\n",
ansi::color(ansi::DIM, "--"),
npc_template.name,
),
npc_died: false,
player_died: false,
xp_gained: 0,
fled: false,
});
} }
let npc_hp_before = instance.hp;
let npc_hp_before = instance.hp;
let conn = state.players.get(&player_id)?; let conn = state.players.get(&player_id)?;
let p_atk = conn.player.effective_attack(); let p_atk = conn.player.effective_attack();
let p_def = conn.player.effective_defense(); let p_def = conn.player.effective_defense();
let _ = conn;
// Player attacks NPC
let roll: i32 = (simple_random() % 6) as i32 + 1;
let player_dmg = (p_atk - npc_combat.defense / 2 + roll).max(1);
let new_npc_hp = (npc_hp_before - player_dmg).max(0);
let mut out = String::new(); let mut out = String::new();
out.push_str(&format!(
" {} You strike {} for {} damage!{}\r\n",
ansi::color(ansi::YELLOW, ">>"),
ansi::color(ansi::RED, &npc_template.name),
ansi::bold(&player_dmg.to_string()),
ansi::RESET,
));
let mut npc_died = false; let mut npc_died = false;
let mut player_died = false; let mut player_died = false;
let mut xp_gained = 0; let mut xp_gained = 0;
let mut fled = false;
match action {
CombatAction::Attack => {
let roll = state.rng.next_range(1, 6);
let player_dmg = (p_atk - npc_combat.defense / 2 + roll).max(1);
let new_npc_hp = (npc_hp_before - player_dmg).max(0);
out.push_str(&format!(
" {} You strike {} for {} damage!\r\n",
ansi::color(ansi::YELLOW, ">>"),
ansi::color(ansi::RED, &npc_template.name),
ansi::bold(&player_dmg.to_string()),
));
if new_npc_hp <= 0 { if new_npc_hp <= 0 {
// NPC dies if let Some(inst) = state.npc_instances.get_mut(&npc_id) {
if let Some(inst) = state.npc_instances.get_mut(npc_id) {
inst.alive = false; inst.alive = false;
inst.hp = 0; inst.hp = 0;
inst.death_time = Some(Instant::now()); inst.death_time = Some(Instant::now());
@@ -60,17 +84,14 @@ pub fn do_attack(player_id: usize, npc_id: &str, state: &mut GameState) -> Optio
ansi::bold(&xp_gained.to_string()), ansi::bold(&xp_gained.to_string()),
)); ));
// Clear combat state
if let Some(conn) = state.players.get_mut(&player_id) { if let Some(conn) = state.players.get_mut(&player_id) {
conn.combat = None; conn.combat = None;
conn.player.stats.xp += xp_gained; conn.player.stats.xp += xp_gained;
} }
} else { } else {
// Update NPC HP if let Some(inst) = state.npc_instances.get_mut(&npc_id) {
if let Some(inst) = state.npc_instances.get_mut(npc_id) {
inst.hp = new_npc_hp; inst.hp = new_npc_hp;
} }
out.push_str(&format!( out.push_str(&format!(
" {} {} HP: {}/{}\r\n", " {} {} HP: {}/{}\r\n",
ansi::color(ansi::DIM, " "), ansi::color(ansi::DIM, " "),
@@ -78,10 +99,105 @@ pub fn do_attack(player_id: usize, npc_id: &str, state: &mut GameState) -> Optio
new_npc_hp, new_npc_hp,
npc_combat.max_hp, npc_combat.max_hp,
)); ));
}
}
CombatAction::Defend => {
if let Some(conn) = state.players.get_mut(&player_id) {
if let Some(ref mut combat) = conn.combat {
combat.defending = true;
}
}
out.push_str(&format!(
" {} You brace yourself and raise your guard.\r\n",
ansi::color(ansi::CYAN, "[]"),
));
}
CombatAction::Flee => {
let flee_chance = 40 + (p_def / 2).min(30);
let roll = state.rng.next_range(1, 100);
if roll <= flee_chance {
if let Some(conn) = state.players.get_mut(&player_id) {
conn.combat = None;
}
fled = true;
out.push_str(&format!(
" {} You disengage and flee from combat!\r\n",
ansi::color(ansi::GREEN, "<<"),
));
} else {
out.push_str(&format!(
" {} You try to flee but {} blocks your escape!\r\n",
ansi::color(ansi::RED, "!!"),
ansi::color(ansi::RED, &npc_template.name),
));
}
}
CombatAction::UseItem(idx) => {
if let Some(conn) = state.players.get_mut(&player_id) {
if idx < conn.player.inventory.len() {
let obj = &conn.player.inventory[idx];
if obj.kind.as_deref() == Some("consumable") {
let heal = obj.stats.heal_amount.unwrap_or(0);
let name = obj.name.clone();
conn.player.inventory.remove(idx);
let old_hp = conn.player.stats.hp;
conn.player.stats.hp =
(conn.player.stats.hp + heal).min(conn.player.stats.max_hp);
let healed = conn.player.stats.hp - old_hp;
out.push_str(&format!(
" {} You use the {}. Restored {} HP.\r\n",
ansi::color(ansi::GREEN, "++"),
ansi::color(ansi::CYAN, &name),
ansi::bold(&healed.to_string()),
));
} else {
out.push_str(&format!(
" {} You can't use that in combat.\r\n",
ansi::color(ansi::RED, "!!"),
));
}
} else {
out.push_str(&format!(
" {} Item not found in inventory.\r\n",
ansi::color(ansi::RED, "!!"),
));
}
}
}
}
// NPC attacks player // Clear the queued action
let npc_roll: i32 = (simple_random() % 6) as i32 + 1; if let Some(conn) = state.players.get_mut(&player_id) {
let npc_dmg = (npc_combat.attack - p_def / 2 + npc_roll).max(1); if let Some(ref mut combat) = conn.combat {
combat.action = None;
}
}
// NPC counter-attack (if player is still in combat and NPC is alive)
let still_in_combat = state
.players
.get(&player_id)
.map(|c| c.combat.is_some())
.unwrap_or(false);
let npc_alive = state
.npc_instances
.get(&npc_id)
.map(|i| i.alive)
.unwrap_or(false);
if still_in_combat && npc_alive && !fled {
let is_defending = state
.players
.get(&player_id)
.and_then(|c| c.combat.as_ref())
.map(|c| c.defending)
.unwrap_or(was_defending);
let defense_mult = if is_defending { 2.0 } else { 1.0 };
let effective_def = (p_def as f32 * defense_mult) as i32;
let npc_roll = state.rng.next_range(1, 6);
let npc_dmg = (npc_combat.attack - effective_def / 2 + npc_roll).max(1);
if let Some(conn) = state.players.get_mut(&player_id) { if let Some(conn) = state.players.get_mut(&player_id) {
conn.player.stats.hp = (conn.player.stats.hp - npc_dmg).max(0); conn.player.stats.hp = (conn.player.stats.hp - npc_dmg).max(0);
@@ -89,10 +205,11 @@ pub fn do_attack(player_id: usize, npc_id: &str, state: &mut GameState) -> Optio
let max_hp = conn.player.stats.max_hp; let max_hp = conn.player.stats.max_hp;
out.push_str(&format!( out.push_str(&format!(
" {} {} strikes you for {} damage!\r\n", " {} {} strikes you for {} damage!{}\r\n",
ansi::color(ansi::RED, "<<"), ansi::color(ansi::RED, "<<"),
ansi::color(ansi::RED, &npc_template.name), ansi::color(ansi::RED, &npc_template.name),
ansi::bold(&npc_dmg.to_string()), ansi::bold(&npc_dmg.to_string()),
if is_defending { " (blocked some)" } else { "" },
)); ));
let hp_color = if hp * 3 < max_hp { let hp_color = if hp * 3 < max_hp {
@@ -115,6 +232,11 @@ pub fn do_attack(player_id: usize, npc_id: &str, state: &mut GameState) -> Optio
player_died = true; player_died = true;
conn.combat = None; conn.combat = None;
} }
// Reset defending after the round
if let Some(ref mut combat) = conn.combat {
combat.defending = false;
}
} }
} }
@@ -123,32 +245,32 @@ pub fn do_attack(player_id: usize, npc_id: &str, state: &mut GameState) -> Optio
npc_died, npc_died,
player_died, player_died,
xp_gained, xp_gained,
fled,
}) })
} }
pub fn player_death_respawn(player_id: usize, state: &mut GameState) -> String { pub fn player_death_respawn(player_id: usize, state: &mut GameState) -> String {
let spawn_room = state.spawn_room().to_string(); let spawn_room = state.spawn_room().to_string();
let player_name = state
.players
.get(&player_id)
.map(|c| c.player.name.clone())
.unwrap_or_default();
if let Some(conn) = state.players.get_mut(&player_id) { if let Some(conn) = state.players.get_mut(&player_id) {
conn.player.stats.hp = conn.player.stats.max_hp; conn.player.stats.hp = conn.player.stats.max_hp;
conn.player.room_id = spawn_room; conn.player.room_id = spawn_room;
conn.combat = None; conn.combat = None;
} }
// Clear status effects on death
state.db.clear_effects(&player_name);
format!( format!(
"\r\n{}\r\n{}\r\n{}\r\n", "\r\n{}\r\n{}\r\n{}\r\n{}\r\n",
ansi::color(ansi::RED, " ╔═══════════════════════════╗"), ansi::color(ansi::RED, " ╔═══════════════════════════╗"),
ansi::color(ansi::RED, " ║ YOU HAVE DIED! ║"), ansi::color(ansi::RED, " ║ YOU HAVE DIED! ║"),
ansi::color(ansi::RED, " ╚═══════════════════════════╝"), ansi::color(ansi::RED, " ╚═══════════════════════════╝"),
) + &format!( ansi::system_msg("You awaken at the town square, fully healed."),
"{}\r\n",
ansi::system_msg("You awaken at the town square, fully healed.")
) )
} }
fn simple_random() -> u32 {
use std::time::SystemTime;
let d = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
((d.as_nanos() >> 4) ^ (d.as_nanos() >> 16)) as u32
}

View File

@@ -3,8 +3,7 @@ use russh::{ChannelId, CryptoVec};
use crate::admin; use crate::admin;
use crate::ansi; use crate::ansi;
use crate::combat; use crate::game::{CombatAction, CombatState, SharedState};
use crate::game::{CombatState, SharedState};
use crate::world::Attitude; use crate::world::Attitude;
pub struct BroadcastMsg { pub struct BroadcastMsg {
@@ -61,14 +60,15 @@ pub async fn execute(
None => (input.to_lowercase(), String::new()), None => (input.to_lowercase(), String::new()),
}; };
// Combat lockout // Combat lockout: only certain commands allowed
{ {
let st = state.lock().await; let st = state.lock().await;
if let Some(conn) = st.players.get(&player_id) { if let Some(conn) = st.players.get(&player_id) {
if conn.combat.is_some() if conn.combat.is_some()
&& !matches!( && !matches!(
cmd.as_str(), cmd.as_str(),
"attack" | "a" | "flee" | "look" | "l" | "quit" | "exit" "attack" | "a" | "defend" | "def" | "flee" | "use" | "look" | "l"
| "stats" | "st" | "inventory" | "inv" | "i" | "quit" | "exit"
) )
{ {
drop(st); drop(st);
@@ -77,7 +77,9 @@ pub async fn execute(
channel, channel,
&format!( &format!(
"{}\r\n{}", "{}\r\n{}",
ansi::error_msg("You're in combat! Use 'attack', 'flee', or 'look'."), ansi::error_msg(
"You're in combat! Use 'attack', 'defend', 'flee', 'use', 'look', 'stats', or 'inventory'."
),
ansi::prompt() ansi::prompt()
), ),
)?; )?;
@@ -101,6 +103,7 @@ pub async fn execute(
"examine" | "ex" | "x" => cmd_examine(player_id, &args, state).await, "examine" | "ex" | "x" => cmd_examine(player_id, &args, state).await,
"talk" => cmd_talk(player_id, &args, state).await, "talk" => cmd_talk(player_id, &args, state).await,
"attack" | "a" => cmd_attack(player_id, &args, state).await, "attack" | "a" => cmd_attack(player_id, &args, state).await,
"defend" | "def" => cmd_defend(player_id, state).await,
"flee" => cmd_flee(player_id, state).await, "flee" => cmd_flee(player_id, state).await,
"stats" | "st" => cmd_stats(player_id, state).await, "stats" | "st" => cmd_stats(player_id, state).await,
"admin" => cmd_admin(player_id, &args, state).await, "admin" => cmd_admin(player_id, &args, state).await,
@@ -236,8 +239,35 @@ async fn cmd_look(pid: usize, state: &SharedState) -> CommandResult {
Some(c) => c.player.room_id.clone(), Some(c) => c.player.room_id.clone(),
None => return simple("Error\r\n"), None => return simple("Error\r\n"),
}; };
let mut out = render_room_view(&rid, pid, &st);
// Show combat status if in combat
if let Some(conn) = st.players.get(&pid) {
if let Some(ref combat) = conn.combat {
if let Some(npc) = st.world.get_npc(&combat.npc_id) {
let npc_hp = st
.npc_instances
.get(&combat.npc_id)
.map(|i| i.hp)
.unwrap_or(0);
let npc_max = npc
.combat
.as_ref()
.map(|c| c.max_hp)
.unwrap_or(1);
out.push_str(&format!(
"\r\n {} In combat with {} (HP: {}/{})\r\n",
ansi::color(ansi::RED, "!!"),
ansi::color(ansi::RED, &npc.name),
npc_hp,
npc_max,
));
}
}
}
CommandResult { CommandResult {
output: render_room_view(&rid, pid, &st), output: out,
broadcasts: Vec::new(), broadcasts: Vec::new(),
kick_targets: Vec::new(), kick_targets: Vec::new(),
quit: false, quit: false,
@@ -249,6 +279,16 @@ async fn cmd_go(pid: usize, direction: &str, state: &SharedState) -> CommandResu
let direction = resolve_dir(&dl); let direction = resolve_dir(&dl);
let mut st = state.lock().await; let mut st = state.lock().await;
// Block movement in combat
if let Some(conn) = st.players.get(&pid) {
if conn.combat.is_some() {
return simple(&format!(
"{}\r\n",
ansi::error_msg("You can't move while in combat! Use 'flee' to escape.")
));
}
}
let (old_rid, new_rid, pname) = { let (old_rid, new_rid, pname) = {
let conn = match st.players.get(&pid) { let conn = match st.players.get(&pid) {
Some(c) => c, Some(c) => c,
@@ -380,12 +420,14 @@ async fn cmd_who(pid: usize, state: &SharedState) -> CommandResult {
.unwrap_or("???"); .unwrap_or("???");
let m = if c.player.name == sn { " (you)" } else { "" }; let m = if c.player.name == sn { " (you)" } else { "" };
let admin_tag = if c.player.is_admin { " [ADMIN]" } else { "" }; let admin_tag = if c.player.is_admin { " [ADMIN]" } else { "" };
let combat_tag = if c.combat.is_some() { " [COMBAT]" } else { "" };
out.push_str(&format!( out.push_str(&format!(
" {}{}{}{}\r\n", " {}{}{}{}{}\r\n",
ansi::player_name(&c.player.name), ansi::player_name(&c.player.name),
ansi::room_name(rn), ansi::room_name(rn),
ansi::system_msg(m), ansi::system_msg(m),
ansi::color(ansi::YELLOW, admin_tag), ansi::color(ansi::YELLOW, admin_tag),
ansi::color(ansi::RED, combat_tag),
)); ));
} }
out.push_str(&format!( out.push_str(&format!(
@@ -635,6 +677,35 @@ async fn cmd_use(pid: usize, target: &str, state: &SharedState) -> CommandResult
)) ))
} }
}; };
// In combat: queue the use action for the next tick
if conn.combat.is_some() {
let obj = &conn.player.inventory[idx];
if obj.kind.as_deref() != Some("consumable") {
return simple(&format!(
"{}\r\n",
ansi::error_msg(&format!("You can't use the {} in combat.", obj.name))
));
}
if let Some(ref mut combat) = conn.combat {
combat.action = Some(CombatAction::UseItem(idx));
}
let name = obj.name.clone();
return CommandResult {
output: format!(
"{}\r\n",
ansi::system_msg(&format!(
"You prepare to use the {}... (resolves next tick)",
name
))
),
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
};
}
// Out of combat: use immediately
let obj = &conn.player.inventory[idx]; let obj = &conn.player.inventory[idx];
if obj.kind.as_deref() != Some("consumable") { if obj.kind.as_deref() != Some("consumable") {
return simple(&format!( return simple(&format!(
@@ -800,30 +871,57 @@ async fn cmd_talk(pid: usize, target: &str, state: &SharedState) -> CommandResul
async fn cmd_attack(pid: usize, target: &str, state: &SharedState) -> CommandResult { async fn cmd_attack(pid: usize, target: &str, state: &SharedState) -> CommandResult {
let mut st = state.lock().await; let mut st = state.lock().await;
// If already in combat, queue attack action
let already_in_combat = st
.players
.get(&pid)
.map(|c| c.combat.is_some())
.unwrap_or(false);
if already_in_combat {
let npc_name = st
.players
.get(&pid)
.and_then(|c| c.combat.as_ref())
.and_then(|combat| st.world.get_npc(&combat.npc_id))
.map(|n| n.name.clone())
.unwrap_or_else(|| "???".into());
if let Some(conn) = st.players.get_mut(&pid) {
if let Some(ref mut combat) = conn.combat {
combat.action = Some(CombatAction::Attack);
}
}
return CommandResult {
output: format!(
"{}\r\n",
ansi::system_msg(&format!(
"You ready an attack against {}... (resolves next tick)",
npc_name
))
),
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
};
}
// Not in combat: initiate combat
if target.is_empty() {
return simple("Attack what?\r\n");
}
let npc_id = { let npc_id = {
let conn = match st.players.get(&pid) { let conn = match st.players.get(&pid) {
Some(c) => c, Some(c) => c,
None => return simple("Error\r\n"), None => return simple("Error\r\n"),
}; };
if let Some(ref combat) = conn.combat {
combat.npc_id.clone()
} else {
if target.is_empty() {
return simple("Attack what?\r\n");
}
let room = match st.world.get_room(&conn.player.room_id) { let room = match st.world.get_room(&conn.player.room_id) {
Some(r) => r, Some(r) => r,
None => return simple("Void\r\n"), None => return simple("Void\r\n"),
}; };
let low = target.to_lowercase(); let low = target.to_lowercase();
let pname = &conn.player.name;
let found = room.npcs.iter().find(|nid| { let found = room.npcs.iter().find(|nid| {
if let Some(npc) = st.world.get_npc(nid) { if let Some(npc) = st.world.get_npc(nid) {
if !npc.name.to_lowercase().contains(&low) { npc.name.to_lowercase().contains(&low) && npc.combat.is_some()
return false;
}
let att = st.npc_attitude_toward(nid, pname);
att.can_be_attacked() && npc.combat.is_some()
} else { } else {
false false
} }
@@ -850,68 +948,77 @@ async fn cmd_attack(pid: usize, target: &str, state: &SharedState) -> CommandRes
)) ))
} }
} }
}
}; };
if st let npc_name = st
.players .world
.get(&pid) .get_npc(&npc_id)
.map(|c| c.combat.is_none()) .map(|n| n.name.clone())
.unwrap_or(false) .unwrap_or_default();
{
if let Some(c) = st.players.get_mut(&pid) {
c.combat = Some(CombatState {
npc_id: npc_id.clone(),
});
}
}
st.check_respawns(); // Attitude penalty for attacking non-hostile NPCs
let pname = st
let player_name = st
.players .players
.get(&pid) .get(&pid)
.map(|c| c.player.name.clone()) .map(|c| c.player.name.clone())
.unwrap_or_default(); .unwrap_or_default();
let result = combat::do_attack(pid, &npc_id, &mut st); let att = st.npc_attitude_toward(&npc_id, &pname);
let mut extra_msg = String::new();
match result { if !att.is_hostile() {
Some(round) => { st.shift_attitude(&npc_id, &pname, -30);
let mut out = round.output;
if round.npc_died {
st.shift_attitude(&npc_id, &player_name, -10);
if let Some(faction) = st.world.get_npc(&npc_id).and_then(|n| n.faction.clone()) { if let Some(faction) = st.world.get_npc(&npc_id).and_then(|n| n.faction.clone()) {
st.shift_faction_attitude(&faction, &player_name, -5); st.shift_faction_attitude(&faction, &pname, -15);
} }
if let Some(msg) = st.check_level_up(pid) { extra_msg = format!(
out.push_str(&format!( "{}\r\n",
"\r\n {} {}\r\n", ansi::color(ansi::RED, " The locals look on in horror as you attack without provocation!")
ansi::color(ansi::GREEN, "***"), );
ansi::bold(&msg)
));
} }
if let Some(c) = st.players.get_mut(&pid) {
c.combat = Some(CombatState {
npc_id: npc_id.clone(),
action: Some(CombatAction::Attack),
defending: false,
});
} }
if round.player_died {
out.push_str(&combat::player_death_respawn(pid, &mut st));
let rid = st
.players
.get(&pid)
.map(|c| c.player.room_id.clone())
.unwrap_or_default();
out.push_str(&render_room_view(&rid, pid, &st));
}
st.save_player_to_db(pid);
CommandResult { CommandResult {
output: out, output: format!(
"{}\r\n{}\r\n{}",
ansi::system_msg(&format!("You engage {} in combat!", npc_name)),
ansi::system_msg("Your attack will resolve on the next tick. Use 'attack', 'defend', 'flee', or 'use <item>'."),
extra_msg,
),
broadcasts: Vec::new(), broadcasts: Vec::new(),
kick_targets: Vec::new(), kick_targets: Vec::new(),
quit: false, quit: false,
} }
} }
None => simple(&format!(
async fn cmd_defend(pid: usize, state: &SharedState) -> CommandResult {
let mut st = state.lock().await;
let conn = match st.players.get_mut(&pid) {
Some(c) => c,
None => return simple("Error\r\n"),
};
if conn.combat.is_none() {
return simple(&format!(
"{}\r\n", "{}\r\n",
ansi::error_msg("That target can't be attacked right now.") ansi::error_msg("You're not in combat.")
)), ));
}
if let Some(ref mut combat) = conn.combat {
combat.action = Some(CombatAction::Defend);
}
CommandResult {
output: format!(
"{}\r\n",
ansi::system_msg("You prepare to defend... (resolves next tick)")
),
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
} }
} }
@@ -927,11 +1034,13 @@ async fn cmd_flee(pid: usize, state: &SharedState) -> CommandResult {
ansi::error_msg("You're not in combat.") ansi::error_msg("You're not in combat.")
)); ));
} }
conn.combat = None; if let Some(ref mut combat) = conn.combat {
combat.action = Some(CombatAction::Flee);
}
CommandResult { CommandResult {
output: format!( output: format!(
"{}\r\n", "{}\r\n",
ansi::system_msg("You disengage and flee from combat!") ansi::system_msg("You prepare to flee... (resolves next tick)")
), ),
broadcasts: Vec::new(), broadcasts: Vec::new(),
kick_targets: Vec::new(), kick_targets: Vec::new(),
@@ -1011,6 +1120,35 @@ async fn cmd_stats(pid: usize, state: &SharedState) -> CommandResult {
s.xp, s.xp,
s.xp_to_next s.xp_to_next
)); ));
// Show combat status
if let Some(ref combat) = conn.combat {
let npc_name = st
.world
.get_npc(&combat.npc_id)
.map(|n| n.name.clone())
.unwrap_or_else(|| "???".into());
out.push_str(&format!(
" {} {}\r\n",
ansi::color(ansi::RED, "Combat:"),
ansi::color(ansi::RED, &npc_name)
));
}
// Show active status effects
let effects = st.db.load_effects(&p.name);
if !effects.is_empty() {
out.push_str(&format!(" {}\r\n", ansi::color(ansi::DIM, "Effects:")));
for eff in &effects {
out.push_str(&format!(
" {} (mag: {}, {} ticks left)\r\n",
ansi::color(ansi::MAGENTA, &eff.kind),
eff.magnitude,
eff.remaining_ticks,
));
}
}
if p.is_admin { if p.is_admin {
out.push_str(&format!( out.push_str(&format!(
" {}\r\n", " {}\r\n",
@@ -1066,8 +1204,9 @@ async fn cmd_help(pid: usize, state: &SharedState) -> CommandResult {
("inventory, i", "View your inventory"), ("inventory, i", "View your inventory"),
("equip <item>", "Equip a weapon or armor"), ("equip <item>", "Equip a weapon or armor"),
("use <item>", "Use a consumable item"), ("use <item>", "Use a consumable item"),
("attack <target>, a", "Attack a hostile NPC"), ("attack <target>, a", "Engage/attack a hostile NPC (tick-based)"),
("flee", "Disengage from combat"), ("defend, def", "Defend next tick (reduces incoming damage)"),
("flee", "Attempt to flee combat (tick-based)"),
("stats, st", "View your character stats"), ("stats, st", "View your character stats"),
("help, h, ?", "Show this help"), ("help, h, ?", "Show this help"),
("quit, exit", "Leave the game"), ("quit, exit", "Leave the game"),

115
src/db.rs
View File

@@ -24,6 +24,13 @@ pub struct NpcAttitudeRow {
pub value: i32, pub value: i32,
} }
pub struct StatusEffectRow {
pub player_name: String,
pub kind: String,
pub remaining_ticks: i32,
pub magnitude: i32,
}
pub trait GameDb: Send + Sync { 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);
@@ -38,6 +45,12 @@ pub trait GameDb: Send + Sync {
fn get_setting(&self, key: &str) -> Option<String>; fn get_setting(&self, key: &str) -> Option<String>;
fn set_setting(&self, key: &str, value: &str); fn set_setting(&self, key: &str, value: &str);
fn list_settings(&self) -> Vec<(String, String)>; fn list_settings(&self) -> Vec<(String, String)>;
fn load_effects(&self, player_name: &str) -> Vec<StatusEffectRow>;
fn save_effect(&self, player_name: &str, kind: &str, remaining_ticks: i32, magnitude: i32);
fn load_all_effects(&self) -> Vec<StatusEffectRow>;
fn tick_all_effects(&self) -> Vec<StatusEffectRow>;
fn clear_effects(&self, player_name: &str);
} }
// --- SQLite implementation --- // --- SQLite implementation ---
@@ -82,6 +95,14 @@ impl SqliteDb {
CREATE TABLE IF NOT EXISTS server_settings ( CREATE TABLE IF NOT EXISTS server_settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS status_effects (
player_name TEXT NOT NULL,
kind TEXT NOT NULL,
remaining_ticks INTEGER NOT NULL,
magnitude INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (player_name, kind)
);", );",
) )
.map_err(|e| format!("Failed to create tables: {e}"))?; .map_err(|e| format!("Failed to create tables: {e}"))?;
@@ -93,7 +114,10 @@ impl SqliteDb {
.map(|c| c > 0) .map(|c| c > 0)
.unwrap_or(false); .unwrap_or(false);
if !has_admin { if !has_admin {
let _ = conn.execute("ALTER TABLE players ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0", []); 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());
@@ -149,9 +173,19 @@ impl GameDb for SqliteDb {
equipped_armor_json=excluded.equipped_armor_json, equipped_armor_json=excluded.equipped_armor_json,
is_admin=excluded.is_admin", 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.hp, p.max_hp, p.attack, p.defense, p.inventory_json, p.race_id,
p.equipped_weapon_json, p.equipped_armor_json, p.class_id,
p.room_id,
p.level,
p.xp,
p.hp,
p.max_hp,
p.attack,
p.defense,
p.inventory_json,
p.equipped_weapon_json,
p.equipped_armor_json,
p.is_admin as i32, p.is_admin as i32,
], ],
); );
@@ -161,6 +195,7 @@ impl GameDb for SqliteDb {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let _ = conn.execute("DELETE FROM players WHERE name = ?1", [name]); let _ = conn.execute("DELETE FROM players WHERE name = ?1", [name]);
let _ = conn.execute("DELETE FROM npc_attitudes WHERE player_name = ?1", [name]); let _ = conn.execute("DELETE FROM npc_attitudes WHERE player_name = ?1", [name]);
let _ = conn.execute("DELETE FROM status_effects WHERE player_name = ?1", [name]);
} }
fn set_admin(&self, name: &str, is_admin: bool) -> bool { fn set_admin(&self, name: &str, is_admin: bool) -> bool {
@@ -272,4 +307,76 @@ impl GameDb for SqliteDb {
.filter_map(|r| r.ok()) .filter_map(|r| r.ok())
.collect() .collect()
} }
fn load_effects(&self, player_name: &str) -> Vec<StatusEffectRow> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn
.prepare("SELECT player_name, kind, remaining_ticks, magnitude FROM status_effects WHERE player_name = ?1 AND remaining_ticks > 0")
.unwrap();
stmt.query_map([player_name], |row| {
Ok(StatusEffectRow {
player_name: row.get(0)?,
kind: row.get(1)?,
remaining_ticks: row.get(2)?,
magnitude: row.get(3)?,
})
})
.unwrap()
.filter_map(|r| r.ok())
.collect()
}
fn save_effect(&self, player_name: &str, kind: &str, remaining_ticks: i32, magnitude: i32) {
let conn = self.conn.lock().unwrap();
let _ = conn.execute(
"INSERT INTO status_effects (player_name, kind, remaining_ticks, magnitude)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(player_name, kind) DO UPDATE SET remaining_ticks=excluded.remaining_ticks, magnitude=excluded.magnitude",
rusqlite::params![player_name, kind, remaining_ticks, magnitude],
);
}
fn load_all_effects(&self) -> Vec<StatusEffectRow> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn
.prepare("SELECT player_name, kind, remaining_ticks, magnitude FROM status_effects WHERE remaining_ticks > 0")
.unwrap();
stmt.query_map([], |row| {
Ok(StatusEffectRow {
player_name: row.get(0)?,
kind: row.get(1)?,
remaining_ticks: row.get(2)?,
magnitude: row.get(3)?,
})
})
.unwrap()
.filter_map(|r| r.ok())
.collect()
}
fn tick_all_effects(&self) -> Vec<StatusEffectRow> {
let conn = self.conn.lock().unwrap();
let _ = conn.execute("UPDATE status_effects SET remaining_ticks = remaining_ticks - 1 WHERE remaining_ticks > 0", []);
let mut stmt = conn
.prepare("SELECT player_name, kind, remaining_ticks, magnitude FROM status_effects WHERE remaining_ticks >= 0")
.unwrap();
let effects: Vec<StatusEffectRow> = stmt.query_map([], |row| {
Ok(StatusEffectRow {
player_name: row.get(0)?,
kind: row.get(1)?,
remaining_ticks: row.get(2)?,
magnitude: row.get(3)?,
})
})
.unwrap()
.filter_map(|r| r.ok())
.collect();
let _ = conn.execute("DELETE FROM status_effects WHERE remaining_ticks <= 0", []);
effects
}
fn clear_effects(&self, player_name: &str) {
let conn = self.conn.lock().unwrap();
let _ = conn.execute("DELETE FROM status_effects WHERE player_name = ?1", [player_name]);
}
} }

View File

@@ -52,8 +52,18 @@ impl Player {
} }
} }
#[derive(Debug, Clone)]
pub enum CombatAction {
Attack,
Defend,
Flee,
UseItem(usize),
}
pub struct CombatState { pub struct CombatState {
pub npc_id: String, pub npc_id: String,
pub action: Option<CombatAction>,
pub defending: bool,
} }
pub struct NpcInstance { pub struct NpcInstance {
@@ -69,11 +79,42 @@ pub struct PlayerConnection {
pub combat: Option<CombatState>, pub combat: Option<CombatState>,
} }
pub struct XorShift64 {
state: u64,
}
impl XorShift64 {
pub fn new(seed: u64) -> Self {
XorShift64 {
state: if seed == 0 { 0xdeadbeefcafe1234 } else { seed },
}
}
pub fn next(&mut self) -> u64 {
let mut x = self.state;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.state = x;
x
}
pub fn next_range(&mut self, min: i32, max: i32) -> i32 {
if min >= max {
return min;
}
let range = (max - min + 1) as u64;
(self.next() % range) as i32 + min
}
}
pub struct GameState { pub struct GameState {
pub world: World, pub world: World,
pub db: Arc<dyn GameDb>, pub db: Arc<dyn GameDb>,
pub players: HashMap<usize, PlayerConnection>, pub players: HashMap<usize, PlayerConnection>,
pub npc_instances: HashMap<String, NpcInstance>, pub npc_instances: HashMap<String, NpcInstance>,
pub rng: XorShift64,
pub tick_count: u64,
} }
pub type SharedState = Arc<Mutex<GameState>>; pub type SharedState = Arc<Mutex<GameState>>;
@@ -93,11 +134,17 @@ impl GameState {
); );
} }
} }
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
GameState { GameState {
world, world,
db, db,
players: HashMap::new(), players: HashMap::new(),
npc_instances, npc_instances,
rng: XorShift64::new(seed),
tick_count: 0,
} }
} }

View File

@@ -6,4 +6,5 @@ pub mod commands;
pub mod db; pub mod db;
pub mod game; pub mod game;
pub mod ssh; pub mod ssh;
pub mod tick;
pub mod world; pub mod world;

View File

@@ -9,6 +9,7 @@ use tokio::net::TcpListener;
use mudserver::db; use mudserver::db;
use mudserver::game; use mudserver::game;
use mudserver::ssh; use mudserver::ssh;
use mudserver::tick;
use mudserver::world; use mudserver::world;
const DEFAULT_PORT: u16 = 2222; const DEFAULT_PORT: u16 = 2222;
@@ -82,6 +83,13 @@ async fn main() {
let config = Arc::new(config); let config = Arc::new(config);
let state = Arc::new(Mutex::new(game::GameState::new(loaded_world, db))); let state = Arc::new(Mutex::new(game::GameState::new(loaded_world, db)));
// Spawn tick engine
let tick_state = state.clone();
tokio::spawn(async move {
tick::run_tick_engine(tick_state).await;
});
let mut server = ssh::MudServer::new(state); let mut server = ssh::MudServer::new(state);
let listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap(); let listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap();

281
src/tick.rs Normal file
View File

@@ -0,0 +1,281 @@
use std::collections::HashMap;
use std::time::Duration;
use russh::CryptoVec;
use crate::ansi;
use crate::combat;
use crate::commands::render_room_view;
use crate::game::SharedState;
const TICK_INTERVAL_MS: u64 = 3000;
const REGEN_EVERY_N_TICKS: u64 = 5;
const REGEN_PERCENT: i32 = 5;
pub async fn run_tick_engine(state: SharedState) {
log::info!(
"Tick engine started (interval={}ms, regen every {} ticks)",
TICK_INTERVAL_MS,
REGEN_EVERY_N_TICKS,
);
loop {
tokio::time::sleep(Duration::from_millis(TICK_INTERVAL_MS)).await;
let mut st = state.lock().await;
st.tick_count += 1;
let tick = st.tick_count;
st.check_respawns();
// --- NPC auto-aggro: hostile NPCs initiate combat with players in their room ---
let mut new_combats: Vec<(usize, String)> = Vec::new();
for (pid, conn) in st.players.iter() {
if conn.combat.is_some() {
continue;
}
let room = match st.world.get_room(&conn.player.room_id) {
Some(r) => r,
None => continue,
};
for npc_id in &room.npcs {
let npc = match st.world.get_npc(npc_id) {
Some(n) => n,
None => continue,
};
if npc.combat.is_none() {
continue;
}
let alive = st.npc_instances.get(npc_id).map(|i| i.alive).unwrap_or(false);
if !alive {
continue;
}
let att = st.npc_attitude_toward(npc_id, &conn.player.name);
if att.will_attack() {
new_combats.push((*pid, npc_id.clone()));
break;
}
}
}
let mut messages: HashMap<usize, String> = HashMap::new();
for (pid, npc_id) in &new_combats {
let npc_name = st
.world
.get_npc(npc_id)
.map(|n| n.name.clone())
.unwrap_or_default();
if let Some(conn) = st.players.get_mut(pid) {
if conn.combat.is_none() {
conn.combat = Some(crate::game::CombatState {
npc_id: npc_id.clone(),
action: None,
defending: false,
});
messages.entry(*pid).or_default().push_str(&format!(
"\r\n {} {} attacks you!\r\n",
ansi::color(ansi::RED, "!!"),
ansi::color(ansi::RED, &npc_name),
));
}
}
}
// --- Resolve combat for all players in combat ---
let combat_players: Vec<usize> = st
.players
.iter()
.filter(|(_, c)| c.combat.is_some())
.map(|(&id, _)| id)
.collect();
for pid in combat_players {
if let Some(round) = combat::resolve_combat_tick(pid, &mut st) {
messages.entry(pid).or_default().push_str(&round.output);
if round.npc_died {
let npc_id = {
// NPC is dead, combat was cleared in resolve_combat_tick
// Get the npc_id from the round context
// We need to find which NPC just died - check npc_instances
// Actually let's track it differently: get it before combat resolution
String::new()
};
// We handle attitude shifts and level-ups after resolve
// The npc_id is already gone from combat state, so we need another approach
// Let's get player name and check level up
if let Some(msg) = st.check_level_up(pid) {
messages.entry(pid).or_default().push_str(&format!(
"\r\n {} {}\r\n",
ansi::color(ansi::GREEN, "***"),
ansi::bold(&msg),
));
}
let _ = npc_id;
}
if round.player_died {
let death_msg = combat::player_death_respawn(pid, &mut st);
messages.entry(pid).or_default().push_str(&death_msg);
let rid = st
.players
.get(&pid)
.map(|c| c.player.room_id.clone())
.unwrap_or_default();
if !rid.is_empty() {
messages
.entry(pid)
.or_default()
.push_str(&render_room_view(&rid, pid, &st));
}
}
st.save_player_to_db(pid);
}
}
// --- Process status effects (ticks down ALL effects in DB, including offline players) ---
let active_effects = st.db.tick_all_effects();
for eff in &active_effects {
match eff.kind.as_str() {
"poison" => {
let dmg = eff.magnitude;
let online_pid = st
.players
.iter()
.find(|(_, c)| c.player.name == eff.player_name)
.map(|(&id, _)| id);
if let Some(pid) = online_pid {
if let Some(conn) = st.players.get_mut(&pid) {
conn.player.stats.hp = (conn.player.stats.hp - dmg).max(0);
if eff.remaining_ticks > 0 {
messages.entry(pid).or_default().push_str(&format!(
"\r\n {} Poison deals {} damage! ({} ticks left)\r\n",
ansi::color(ansi::GREEN, "~*"),
dmg,
eff.remaining_ticks,
));
} else {
messages.entry(pid).or_default().push_str(&format!(
"\r\n {} The poison wears off.\r\n",
ansi::color(ansi::GREEN, "~*"),
));
}
if conn.player.stats.hp <= 0 {
let death_msg = combat::player_death_respawn(pid, &mut st);
messages.entry(pid).or_default().push_str(&death_msg);
}
}
st.save_player_to_db(pid);
} else {
// Offline player: apply damage directly to DB
if let Some(mut saved) = st.db.load_player(&eff.player_name) {
saved.hp = (saved.hp - dmg).max(0);
if saved.hp <= 0 {
saved.hp = saved.max_hp;
saved.room_id = st.spawn_room().to_string();
st.db.clear_effects(&eff.player_name);
}
st.db.save_player(&saved);
}
}
}
"regen" => {
let heal = eff.magnitude;
let online_pid = st
.players
.iter()
.find(|(_, c)| c.player.name == eff.player_name)
.map(|(&id, _)| id);
if let Some(pid) = online_pid {
if let Some(conn) = st.players.get_mut(&pid) {
let old = conn.player.stats.hp;
conn.player.stats.hp =
(conn.player.stats.hp + heal).min(conn.player.stats.max_hp);
let healed = conn.player.stats.hp - old;
if healed > 0 && eff.remaining_ticks > 0 {
messages.entry(pid).or_default().push_str(&format!(
"\r\n {} Regeneration heals {} HP.\r\n",
ansi::color(ansi::GREEN, "++"),
healed,
));
}
if eff.remaining_ticks <= 0 {
messages.entry(pid).or_default().push_str(&format!(
"\r\n {} The regeneration effect fades.\r\n",
ansi::color(ansi::DIM, "~~"),
));
}
}
st.save_player_to_db(pid);
} else {
if let Some(mut saved) = st.db.load_player(&eff.player_name) {
saved.hp = (saved.hp + heal).min(saved.max_hp);
st.db.save_player(&saved);
}
}
}
_ => {}
}
}
// --- Passive regen for online players not in combat ---
if tick % REGEN_EVERY_N_TICKS == 0 {
let regen_pids: Vec<usize> = st
.players
.iter()
.filter(|(_, c)| {
c.combat.is_none() && c.player.stats.hp < c.player.stats.max_hp
})
.map(|(&id, _)| id)
.collect();
for pid in regen_pids {
if let Some(conn) = st.players.get_mut(&pid) {
let heal =
(conn.player.stats.max_hp * REGEN_PERCENT / 100).max(1);
let old = conn.player.stats.hp;
conn.player.stats.hp =
(conn.player.stats.hp + heal).min(conn.player.stats.max_hp);
let healed = conn.player.stats.hp - old;
if healed > 0 {
messages.entry(pid).or_default().push_str(&format!(
"\r\n {} You recover {} HP. ({}/{})\r\n",
ansi::color(ansi::DIM, "~~"),
healed,
conn.player.stats.hp,
conn.player.stats.max_hp,
));
}
}
st.save_player_to_db(pid);
}
}
// --- Send accumulated messages to online players ---
let sends: Vec<(russh::ChannelId, russh::server::Handle, String)> = messages
.into_iter()
.filter_map(|(pid, msg)| {
if msg.is_empty() {
return None;
}
let conn = st.players.get(&pid)?;
Some((
conn.channel,
conn.handle.clone(),
format!("{}{}", msg, ansi::prompt()),
))
})
.collect();
drop(st);
for (ch, handle, text) in sends {
let _ = handle.data(ch, CryptoVec::from(text.as_bytes())).await;
}
}
}

View File

@@ -49,7 +49,7 @@ impl Attitude {
matches!(self, Attitude::Hostile) matches!(self, Attitude::Hostile)
} }
pub fn can_be_attacked(self) -> bool { pub fn is_hostile(self) -> bool {
matches!(self, Attitude::Hostile | Attitude::Aggressive) matches!(self, Attitude::Hostile | Attitude::Aggressive)
} }
@@ -315,7 +315,8 @@ impl World {
load_entities_from_dir(&region_path.join("npcs"), &region_name, &mut |id, content| { load_entities_from_dir(&region_path.join("npcs"), &region_name, &mut |id, content| {
let nf: NpcFile = toml::from_str(content).map_err(|e| format!("Bad npc {id}: {e}"))?; let nf: NpcFile = toml::from_str(content).map_err(|e| format!("Bad npc {id}: {e}"))?;
let combat = nf.combat.map(|c| NpcCombatStats { max_hp: c.max_hp, attack: c.attack, defense: c.defense, xp_reward: c.xp_reward }); let combat = Some(nf.combat.map(|c| NpcCombatStats { max_hp: c.max_hp, attack: c.attack, defense: c.defense, xp_reward: c.xp_reward })
.unwrap_or(NpcCombatStats { max_hp: 20, attack: 4, defense: 2, xp_reward: 5 }));
let greeting = nf.dialogue.and_then(|d| d.greeting); let greeting = nf.dialogue.and_then(|d| d.greeting);
npcs.insert(id.clone(), Npc { id: id.clone(), name: nf.name, description: nf.description, room: nf.room, base_attitude: nf.base_attitude, faction: nf.faction, respawn_secs: nf.respawn_secs, greeting, combat }); npcs.insert(id.clone(), Npc { id: id.clone(), name: nf.name, description: nf.description, room: nf.room, base_attitude: nf.base_attitude, faction: nf.faction, respawn_secs: nf.respawn_secs, greeting, combat });
Ok(()) Ok(())