Files
mudserver/AGENTS.md
AI Agent a2ffee0f94
Some checks failed
Smoke tests / Build and smoke test (push) Failing after 34s
Update documentation and CI to include world validation checks
- 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.
2026-03-14 18:22:34 -06:00

10 KiB

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
  7. traits and disadvantages are free-form string arrays
  8. If [body] slots is empty, defaults to humanoid slots
  9. Natural attacks: use [natural.attacks.<name>] with damage, type, optional cooldown_ticks
  10. Resistances: damage_type → multiplier (0.0 = immune, 1.0 = normal, 1.5 = vulnerable)
  11. 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

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