Compare commits
2 Commits
a083c38326
...
3f164e4697
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f164e4697 | ||
|
|
5fd2c10198 |
120
AGENTS.md
Normal file
120
AGENTS.md
Normal 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
276
README.md
Normal 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.
|
||||
13
TESTING.md
13
TESTING.md
@@ -39,21 +39,24 @@ Run through these checks before every commit to ensure consistent feature covera
|
||||
- [ ] Dead NPCs don't appear in room view
|
||||
|
||||
## Combat - Tick-Based
|
||||
- [ ] `attack <aggressive/hostile npc>` enters combat state
|
||||
- [ ] Can't attack friendly/neutral NPCs
|
||||
- [ ] `attack <npc>` enters combat state with any NPC that has combat stats
|
||||
- [ ] 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)
|
||||
- [ ] Player receives tick-by-tick combat output (damage dealt, damage taken)
|
||||
- [ ] Default combat action is "attack" if no other action queued
|
||||
- [ ] `defend` / `def` sets defensive stance (reduced incoming damage next tick)
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] `use <item>` in combat queues item use for next tick
|
||||
- [ ] Multiple ticks of combat resolve correctly without player input
|
||||
- [ ] Combat ends when NPC dies (player exits combat state)
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
|
||||
## 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 output is delivered to players promptly
|
||||
- [ ] Server remains responsive to immediate commands between ticks
|
||||
|
||||
256
src/combat.rs
256
src/combat.rs
@@ -1,87 +1,203 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::ansi;
|
||||
use crate::game::GameState;
|
||||
use crate::game::{CombatAction, GameState};
|
||||
|
||||
pub struct CombatRoundResult {
|
||||
pub output: String,
|
||||
pub npc_died: bool,
|
||||
pub player_died: bool,
|
||||
pub xp_gained: i32,
|
||||
pub fled: bool,
|
||||
}
|
||||
|
||||
pub fn do_attack(player_id: usize, npc_id: &str, state: &mut GameState) -> Option<CombatRoundResult> {
|
||||
let npc_template = state.world.get_npc(npc_id)?.clone();
|
||||
pub fn resolve_combat_tick(
|
||||
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 instance = state.npc_instances.get(npc_id)?;
|
||||
let instance = state.npc_instances.get(&npc_id)?;
|
||||
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 p_atk = conn.player.effective_attack();
|
||||
let p_def = conn.player.effective_defense();
|
||||
|
||||
// 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 _ = conn;
|
||||
|
||||
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 player_died = false;
|
||||
let mut xp_gained = 0;
|
||||
let mut fled = false;
|
||||
|
||||
if new_npc_hp <= 0 {
|
||||
// NPC dies
|
||||
if let Some(inst) = state.npc_instances.get_mut(npc_id) {
|
||||
inst.alive = false;
|
||||
inst.hp = 0;
|
||||
inst.death_time = Some(Instant::now());
|
||||
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 let Some(inst) = state.npc_instances.get_mut(&npc_id) {
|
||||
inst.alive = false;
|
||||
inst.hp = 0;
|
||||
inst.death_time = Some(Instant::now());
|
||||
}
|
||||
npc_died = true;
|
||||
xp_gained = npc_combat.xp_reward;
|
||||
|
||||
out.push_str(&format!(
|
||||
" {} {} collapses! You gain {} XP.\r\n",
|
||||
ansi::color(ansi::GREEN, "**"),
|
||||
ansi::color(ansi::RED, &npc_template.name),
|
||||
ansi::bold(&xp_gained.to_string()),
|
||||
));
|
||||
|
||||
if let Some(conn) = state.players.get_mut(&player_id) {
|
||||
conn.combat = None;
|
||||
conn.player.stats.xp += xp_gained;
|
||||
}
|
||||
} else {
|
||||
if let Some(inst) = state.npc_instances.get_mut(&npc_id) {
|
||||
inst.hp = new_npc_hp;
|
||||
}
|
||||
out.push_str(&format!(
|
||||
" {} {} HP: {}/{}\r\n",
|
||||
ansi::color(ansi::DIM, " "),
|
||||
npc_template.name,
|
||||
new_npc_hp,
|
||||
npc_combat.max_hp,
|
||||
));
|
||||
}
|
||||
}
|
||||
npc_died = true;
|
||||
xp_gained = npc_combat.xp_reward;
|
||||
|
||||
out.push_str(&format!(
|
||||
" {} {} collapses! You gain {} XP.\r\n",
|
||||
ansi::color(ansi::GREEN, "**"),
|
||||
ansi::color(ansi::RED, &npc_template.name),
|
||||
ansi::bold(&xp_gained.to_string()),
|
||||
));
|
||||
|
||||
// Clear combat state
|
||||
if let Some(conn) = state.players.get_mut(&player_id) {
|
||||
conn.combat = None;
|
||||
conn.player.stats.xp += xp_gained;
|
||||
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, "[]"),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// Update NPC HP
|
||||
if let Some(inst) = state.npc_instances.get_mut(npc_id) {
|
||||
inst.hp = new_npc_hp;
|
||||
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, "!!"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.push_str(&format!(
|
||||
" {} {} HP: {}/{}\r\n",
|
||||
ansi::color(ansi::DIM, " "),
|
||||
npc_template.name,
|
||||
new_npc_hp,
|
||||
npc_combat.max_hp,
|
||||
));
|
||||
// Clear the queued action
|
||||
if let Some(conn) = state.players.get_mut(&player_id) {
|
||||
if let Some(ref mut combat) = conn.combat {
|
||||
combat.action = None;
|
||||
}
|
||||
}
|
||||
|
||||
// NPC attacks player
|
||||
let npc_roll: i32 = (simple_random() % 6) as i32 + 1;
|
||||
let npc_dmg = (npc_combat.attack - p_def / 2 + npc_roll).max(1);
|
||||
// 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) {
|
||||
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;
|
||||
|
||||
out.push_str(&format!(
|
||||
" {} {} strikes you for {} damage!\r\n",
|
||||
" {} {} strikes you for {} damage!{}\r\n",
|
||||
ansi::color(ansi::RED, "<<"),
|
||||
ansi::color(ansi::RED, &npc_template.name),
|
||||
ansi::bold(&npc_dmg.to_string()),
|
||||
if is_defending { " (blocked some)" } else { "" },
|
||||
));
|
||||
|
||||
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;
|
||||
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,
|
||||
player_died,
|
||||
xp_gained,
|
||||
fled,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn player_death_respawn(player_id: usize, state: &mut GameState) -> 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) {
|
||||
conn.player.stats.hp = conn.player.stats.max_hp;
|
||||
conn.player.room_id = spawn_room;
|
||||
conn.combat = None;
|
||||
}
|
||||
|
||||
// Clear status effects on death
|
||||
state.db.clear_effects(&player_name);
|
||||
|
||||
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, " ║ YOU HAVE DIED! ║"),
|
||||
ansi::color(ansi::RED, " ╚═══════════════════════════╝"),
|
||||
) + &format!(
|
||||
"{}\r\n",
|
||||
ansi::system_msg("You awaken at the town square, fully healed.")
|
||||
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
|
||||
}
|
||||
|
||||
345
src/commands.rs
345
src/commands.rs
@@ -3,8 +3,7 @@ use russh::{ChannelId, CryptoVec};
|
||||
|
||||
use crate::admin;
|
||||
use crate::ansi;
|
||||
use crate::combat;
|
||||
use crate::game::{CombatState, SharedState};
|
||||
use crate::game::{CombatAction, CombatState, SharedState};
|
||||
use crate::world::Attitude;
|
||||
|
||||
pub struct BroadcastMsg {
|
||||
@@ -61,14 +60,15 @@ pub async fn execute(
|
||||
None => (input.to_lowercase(), String::new()),
|
||||
};
|
||||
|
||||
// Combat lockout
|
||||
// Combat lockout: only certain commands allowed
|
||||
{
|
||||
let st = state.lock().await;
|
||||
if let Some(conn) = st.players.get(&player_id) {
|
||||
if conn.combat.is_some()
|
||||
&& !matches!(
|
||||
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);
|
||||
@@ -77,7 +77,9 @@ pub async fn execute(
|
||||
channel,
|
||||
&format!(
|
||||
"{}\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()
|
||||
),
|
||||
)?;
|
||||
@@ -101,6 +103,7 @@ pub async fn execute(
|
||||
"examine" | "ex" | "x" => cmd_examine(player_id, &args, state).await,
|
||||
"talk" => cmd_talk(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,
|
||||
"stats" | "st" => cmd_stats(player_id, 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(),
|
||||
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 {
|
||||
output: render_room_view(&rid, pid, &st),
|
||||
output: out,
|
||||
broadcasts: Vec::new(),
|
||||
kick_targets: Vec::new(),
|
||||
quit: false,
|
||||
@@ -249,6 +279,16 @@ async fn cmd_go(pid: usize, direction: &str, state: &SharedState) -> CommandResu
|
||||
let direction = resolve_dir(&dl);
|
||||
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 conn = match st.players.get(&pid) {
|
||||
Some(c) => c,
|
||||
@@ -380,12 +420,14 @@ async fn cmd_who(pid: usize, state: &SharedState) -> CommandResult {
|
||||
.unwrap_or("???");
|
||||
let m = if c.player.name == sn { " (you)" } 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!(
|
||||
" {} — {}{}{}\r\n",
|
||||
" {} — {}{}{}{}\r\n",
|
||||
ansi::player_name(&c.player.name),
|
||||
ansi::room_name(rn),
|
||||
ansi::system_msg(m),
|
||||
ansi::color(ansi::YELLOW, admin_tag),
|
||||
ansi::color(ansi::RED, combat_tag),
|
||||
));
|
||||
}
|
||||
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];
|
||||
if obj.kind.as_deref() != Some("consumable") {
|
||||
return simple(&format!(
|
||||
@@ -800,118 +871,154 @@ async fn cmd_talk(pid: usize, target: &str, state: &SharedState) -> CommandResul
|
||||
async fn cmd_attack(pid: usize, target: &str, state: &SharedState) -> CommandResult {
|
||||
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 conn = match st.players.get(&pid) {
|
||||
Some(c) => c,
|
||||
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) {
|
||||
Some(r) => r,
|
||||
None => return simple("Void\r\n"),
|
||||
};
|
||||
let low = target.to_lowercase();
|
||||
let found = room.npcs.iter().find(|nid| {
|
||||
if let Some(npc) = st.world.get_npc(nid) {
|
||||
npc.name.to_lowercase().contains(&low) && npc.combat.is_some()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
let room = match st.world.get_room(&conn.player.room_id) {
|
||||
Some(r) => r,
|
||||
None => return simple("Void\r\n"),
|
||||
};
|
||||
let low = target.to_lowercase();
|
||||
let pname = &conn.player.name;
|
||||
let found = room.npcs.iter().find(|nid| {
|
||||
if let Some(npc) = st.world.get_npc(nid) {
|
||||
if !npc.name.to_lowercase().contains(&low) {
|
||||
return false;
|
||||
}
|
||||
let att = st.npc_attitude_toward(nid, pname);
|
||||
att.can_be_attacked() && npc.combat.is_some()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
match found {
|
||||
Some(id) => {
|
||||
if !st
|
||||
.npc_instances
|
||||
.get(id)
|
||||
.map(|i| i.alive)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return simple(&format!(
|
||||
"{}\r\n",
|
||||
ansi::error_msg("That target is already dead.")
|
||||
));
|
||||
}
|
||||
id.clone()
|
||||
}
|
||||
None => {
|
||||
});
|
||||
match found {
|
||||
Some(id) => {
|
||||
if !st
|
||||
.npc_instances
|
||||
.get(id)
|
||||
.map(|i| i.alive)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return simple(&format!(
|
||||
"{}\r\n",
|
||||
ansi::error_msg(&format!("No attackable target '{target}' here."))
|
||||
))
|
||||
ansi::error_msg("That target is already dead.")
|
||||
));
|
||||
}
|
||||
id.clone()
|
||||
}
|
||||
None => {
|
||||
return simple(&format!(
|
||||
"{}\r\n",
|
||||
ansi::error_msg(&format!("No attackable target '{target}' here."))
|
||||
))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if st
|
||||
.players
|
||||
.get(&pid)
|
||||
.map(|c| c.combat.is_none())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Some(c) = st.players.get_mut(&pid) {
|
||||
c.combat = Some(CombatState {
|
||||
npc_id: npc_id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let npc_name = st
|
||||
.world
|
||||
.get_npc(&npc_id)
|
||||
.map(|n| n.name.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
st.check_respawns();
|
||||
|
||||
let player_name = st
|
||||
// Attitude penalty for attacking non-hostile NPCs
|
||||
let pname = st
|
||||
.players
|
||||
.get(&pid)
|
||||
.map(|c| c.player.name.clone())
|
||||
.unwrap_or_default();
|
||||
let result = combat::do_attack(pid, &npc_id, &mut st);
|
||||
|
||||
match result {
|
||||
Some(round) => {
|
||||
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()) {
|
||||
st.shift_faction_attitude(&faction, &player_name, -5);
|
||||
}
|
||||
if let Some(msg) = st.check_level_up(pid) {
|
||||
out.push_str(&format!(
|
||||
"\r\n {} {}\r\n",
|
||||
ansi::color(ansi::GREEN, "***"),
|
||||
ansi::bold(&msg)
|
||||
));
|
||||
}
|
||||
}
|
||||
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 {
|
||||
output: out,
|
||||
broadcasts: Vec::new(),
|
||||
kick_targets: Vec::new(),
|
||||
quit: false,
|
||||
}
|
||||
let att = st.npc_attitude_toward(&npc_id, &pname);
|
||||
let mut extra_msg = String::new();
|
||||
if !att.is_hostile() {
|
||||
st.shift_attitude(&npc_id, &pname, -30);
|
||||
if let Some(faction) = st.world.get_npc(&npc_id).and_then(|n| n.faction.clone()) {
|
||||
st.shift_faction_attitude(&faction, &pname, -15);
|
||||
}
|
||||
None => simple(&format!(
|
||||
extra_msg = format!(
|
||||
"{}\r\n",
|
||||
ansi::error_msg("That target can't be attacked right now.")
|
||||
)),
|
||||
ansi::color(ansi::RED, " The locals look on in horror as you attack without provocation!")
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(c) = st.players.get_mut(&pid) {
|
||||
c.combat = Some(CombatState {
|
||||
npc_id: npc_id.clone(),
|
||||
action: Some(CombatAction::Attack),
|
||||
defending: false,
|
||||
});
|
||||
}
|
||||
|
||||
CommandResult {
|
||||
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(),
|
||||
kick_targets: Vec::new(),
|
||||
quit: false,
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
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.")
|
||||
));
|
||||
}
|
||||
conn.combat = None;
|
||||
if let Some(ref mut combat) = conn.combat {
|
||||
combat.action = Some(CombatAction::Flee);
|
||||
}
|
||||
CommandResult {
|
||||
output: format!(
|
||||
"{}\r\n",
|
||||
ansi::system_msg("You disengage and flee from combat!")
|
||||
ansi::system_msg("You prepare to flee... (resolves next tick)")
|
||||
),
|
||||
broadcasts: Vec::new(),
|
||||
kick_targets: Vec::new(),
|
||||
@@ -1011,6 +1120,35 @@ async fn cmd_stats(pid: usize, state: &SharedState) -> CommandResult {
|
||||
s.xp,
|
||||
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 {
|
||||
out.push_str(&format!(
|
||||
" {}\r\n",
|
||||
@@ -1066,8 +1204,9 @@ async fn cmd_help(pid: usize, state: &SharedState) -> CommandResult {
|
||||
("inventory, i", "View your inventory"),
|
||||
("equip <item>", "Equip a weapon or armor"),
|
||||
("use <item>", "Use a consumable item"),
|
||||
("attack <target>, a", "Attack a hostile NPC"),
|
||||
("flee", "Disengage from combat"),
|
||||
("attack <target>, a", "Engage/attack a hostile NPC (tick-based)"),
|
||||
("defend, def", "Defend next tick (reduces incoming damage)"),
|
||||
("flee", "Attempt to flee combat (tick-based)"),
|
||||
("stats, st", "View your character stats"),
|
||||
("help, h, ?", "Show this help"),
|
||||
("quit, exit", "Leave the game"),
|
||||
|
||||
115
src/db.rs
115
src/db.rs
@@ -24,6 +24,13 @@ pub struct NpcAttitudeRow {
|
||||
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 {
|
||||
fn load_player(&self, name: &str) -> Option<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 set_setting(&self, key: &str, value: &str);
|
||||
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 ---
|
||||
@@ -82,6 +95,14 @@ impl SqliteDb {
|
||||
CREATE TABLE IF NOT EXISTS server_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
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}"))?;
|
||||
@@ -93,7 +114,10 @@ impl SqliteDb {
|
||||
.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", []);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE players ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
log::info!("Database opened: {}", path.display());
|
||||
@@ -149,9 +173,19 @@ impl GameDb for SqliteDb {
|
||||
equipped_armor_json=excluded.equipped_armor_json,
|
||||
is_admin=excluded.is_admin",
|
||||
rusqlite::params![
|
||||
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.equipped_weapon_json, p.equipped_armor_json,
|
||||
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.equipped_weapon_json,
|
||||
p.equipped_armor_json,
|
||||
p.is_admin as i32,
|
||||
],
|
||||
);
|
||||
@@ -161,6 +195,7 @@ impl GameDb for SqliteDb {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
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 status_effects WHERE player_name = ?1", [name]);
|
||||
}
|
||||
|
||||
fn set_admin(&self, name: &str, is_admin: bool) -> bool {
|
||||
@@ -272,4 +307,76 @@ impl GameDb for SqliteDb {
|
||||
.filter_map(|r| r.ok())
|
||||
.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]);
|
||||
}
|
||||
}
|
||||
|
||||
47
src/game.rs
47
src/game.rs
@@ -52,8 +52,18 @@ impl Player {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CombatAction {
|
||||
Attack,
|
||||
Defend,
|
||||
Flee,
|
||||
UseItem(usize),
|
||||
}
|
||||
|
||||
pub struct CombatState {
|
||||
pub npc_id: String,
|
||||
pub action: Option<CombatAction>,
|
||||
pub defending: bool,
|
||||
}
|
||||
|
||||
pub struct NpcInstance {
|
||||
@@ -69,11 +79,42 @@ pub struct PlayerConnection {
|
||||
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 world: World,
|
||||
pub db: Arc<dyn GameDb>,
|
||||
pub players: HashMap<usize, PlayerConnection>,
|
||||
pub npc_instances: HashMap<String, NpcInstance>,
|
||||
pub rng: XorShift64,
|
||||
pub tick_count: u64,
|
||||
}
|
||||
|
||||
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 {
|
||||
world,
|
||||
db,
|
||||
players: HashMap::new(),
|
||||
npc_instances,
|
||||
rng: XorShift64::new(seed),
|
||||
tick_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,4 +6,5 @@ pub mod commands;
|
||||
pub mod db;
|
||||
pub mod game;
|
||||
pub mod ssh;
|
||||
pub mod tick;
|
||||
pub mod world;
|
||||
|
||||
@@ -9,6 +9,7 @@ use tokio::net::TcpListener;
|
||||
use mudserver::db;
|
||||
use mudserver::game;
|
||||
use mudserver::ssh;
|
||||
use mudserver::tick;
|
||||
use mudserver::world;
|
||||
|
||||
const DEFAULT_PORT: u16 = 2222;
|
||||
@@ -82,6 +83,13 @@ async fn main() {
|
||||
let config = Arc::new(config);
|
||||
|
||||
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 listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap();
|
||||
|
||||
281
src/tick.rs
Normal file
281
src/tick.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ impl Attitude {
|
||||
matches!(self, Attitude::Hostile)
|
||||
}
|
||||
|
||||
pub fn can_be_attacked(self) -> bool {
|
||||
pub fn is_hostile(self) -> bool {
|
||||
matches!(self, Attitude::Hostile | Attitude::Aggressive)
|
||||
}
|
||||
|
||||
@@ -315,7 +315,8 @@ impl World {
|
||||
|
||||
load_entities_from_dir(®ion_path.join("npcs"), ®ion_name, &mut |id, content| {
|
||||
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);
|
||||
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(())
|
||||
|
||||
Reference in New Issue
Block a user