Some checks failed
Smoke tests / Build and smoke test (push) Failing after 34s
- Added a note in AGENTS.md about using `mudtool validate -w ./world` for schema validation before committing. - Updated TESTING.md to include a checklist item for the new validation command. - Modified smoke-tests.yml to run the world validation command as part of the CI workflow.
172 lines
10 KiB
Markdown
172 lines
10 KiB
Markdown
# 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.
|
|
- **Races are deeply data-driven**: body shape, equipment slots, natural weapons/armor, resistances, traits, regen rates — all defined in TOML. A dragon has different slots than a human.
|
|
- **Equipment is slot-based**: each race defines available body slots. Items declare their slot. The engine validates compatibility at equip time.
|
|
- **Items magically resize** — no size restrictions. A dragon can wield a human sword.
|
|
- **Guilds are data-driven**: defined in `world/guilds/*.toml`. Players can join multiple guilds. Guilds grant spells, stat growth, and resource pools.
|
|
- **Spells are separate files**: defined in `world/spells/*.toml`, referenced by guild TOML. A spell can be shared across guilds.
|
|
- **Classes seed initial guild**: each class TOML can reference a `guild` field. On character creation, the player auto-joins that guild at level 1.
|
|
- **Resources (mana/endurance)**: players have mana and endurance pools, derived from guild base values + racial stat modifiers. Regenerates out of combat.
|
|
|
|
## 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, cast
|
|
├── 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>`, `cast <spell>`)
|
|
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
|
|
7. `cast <spell>` in combat queues `CombatAction::Cast(spell_id)`, resolved on tick: deducts mana/endurance, applies cooldown, deals damage or heals
|
|
|
|
## 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 NPC
|
|
1. Create `world/<region>/npcs/<name>.toml`
|
|
2. Optional `race` and `class` fields pin the NPC to a specific race/class
|
|
3. If omitted, race is randomly chosen from non-hidden races at spawn time
|
|
4. If class is omitted, the race's `default_class` is used; if that's also unset, a random non-hidden class is picked
|
|
5. Race/class are re-rolled on each respawn for NPCs without fixed values
|
|
6. For animals: use `race = "race:beast"` and `class = "class:creature"`
|
|
|
|
### New race
|
|
1. Create `world/races/<name>.toml` — see `dragon.toml` for a complex example
|
|
2. Required: `name`, `description`
|
|
3. All other fields have sensible defaults via `#[serde(default)]`
|
|
4. Key sections: `[stats]` (7 stats), `[body]` (size, weight, slots), `[natural]` (armor, attacks), `[resistances]`, `[regen]`, `[misc]`, `[guild_compatibility]`
|
|
5. Set `hidden = true` for NPC-only races (e.g., `beast`) to exclude from character creation
|
|
6. Set `default_class` to reference a class ID for the race's default NPC class
|
|
5. `traits` and `disadvantages` are free-form string arrays
|
|
6. If `[body] slots` is empty, defaults to humanoid slots
|
|
7. Natural attacks: use `[natural.attacks.<name>]` with `damage`, `type`, optional `cooldown_ticks`
|
|
8. Resistances: damage_type → multiplier (0.0 = immune, 1.0 = normal, 1.5 = vulnerable)
|
|
9. `xp_rate` modifies XP gain (< 1.0 = slower leveling, for powerful races)
|
|
|
|
### New guild
|
|
1. Create `world/guilds/<name>.toml`
|
|
2. Required: `name`, `description`
|
|
3. Key fields: `max_level`, `resource` ("mana" or "endurance"), `base_mana`, `base_endurance`, `spells` (list of spell IDs), `min_player_level`, `race_restricted` (list of race IDs that can't join)
|
|
4. `[growth]` section: `hp_per_level`, `mana_per_level`, `endurance_per_level`, `attack_per_level`, `defense_per_level`
|
|
5. **TOML ordering matters**: put `spells`, `min_player_level`, `race_restricted` BEFORE any `[section]` headers
|
|
6. To link a class to a guild, add `guild = "guild:<filename_stem>"` to the class TOML
|
|
|
|
### New spell
|
|
1. Create `world/spells/<name>.toml`
|
|
2. Required: `name`, `description`
|
|
3. Key fields: `spell_type` ("offensive"/"heal"/"utility"), `damage`, `heal`, `damage_type`, `cost_mana`, `cost_endurance`, `cooldown_ticks`, `min_guild_level`
|
|
4. Optional: `effect` (status effect kind), `effect_duration`, `effect_magnitude`
|
|
5. Reference the spell ID (`spell:<filename_stem>`) from a guild's `spells` list
|
|
|
|
### New equipment slot
|
|
1. Add the slot name to a race's `[body] slots` array
|
|
2. Create objects with `slot = "<slot_name>"` in their TOML
|
|
3. The `kind` field (`weapon`/`armor`) still works as fallback for `main_hand`/`torso`
|
|
4. Items can also have an explicit `slot` field to target any slot
|
|
|
|
### 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
|
|
5. Run `mudtool validate -w ./world` to check schemas, references, and values before committing
|
|
|
|
### 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
|