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.
10 KiB
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
GameDbtrait 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
guildfield. 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
.awaitpoints that involve network I/O.
How Combat Works
- Player types
attack <npc>→ entersCombatStatewithaction: Some(Attack) - Player can queue a different action before the tick fires (
defend,flee,use <item>,cast <spell>) - Tick engine iterates all players in combat, calls
combat::resolve_combat_tick() - Resolution: execute player action → NPC counter-attacks → check death → clear action
- If no action was queued, default is
Attack - NPC auto-aggro: hostile NPCs initiate combat with players in their room on each tick
cast <spell>in combat queuesCombatAction::Cast(spell_id), resolved on tick: deducts mana/endurance, applies cooldown, deals damage or heals
How Status Effects Work
- Stored in
status_effectstable:(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
playerstable via DB - Effects cleared on player death
Adding New Features
New command
- Add handler function in
commands.rs(followcmd_*pattern) - Add match arm in the
execute()dispatch - If it's a combat action, queue it on
combat.actioninstead of executing immediately - Update
cmd_help()text - Add to TESTING.md checklist
New status effect kind
- Add a match arm in
tick.rsunder the effect processing loop - Handle both online (in-memory) and offline (DB-only) players
- The
kindfield is a free-form string — no enum needed
New NPC behavior
- NPC AI runs in
tick.rsat the top of the tick cycle - Currently: hostile NPCs auto-engage players in their room
- Add new behaviors there (e.g. NPC movement, dialogue triggers)
New NPC
- Create
world/<region>/npcs/<name>.toml - Optional
raceandclassfields pin the NPC to a specific race/class - If omitted, race is randomly chosen from non-hidden races at spawn time
- If class is omitted, the race's
default_classis used; if that's also unset, a random non-hidden class is picked - Race/class are re-rolled on each respawn for NPCs without fixed values
- For animals: use
race = "race:beast"andclass = "class:creature"
New race
- Create
world/races/<name>.toml— seedragon.tomlfor a complex example - Required:
name,description - All other fields have sensible defaults via
#[serde(default)] - Key sections:
[stats](7 stats),[body](size, weight, slots),[natural](armor, attacks),[resistances],[regen],[misc],[guild_compatibility] - Set
hidden = truefor NPC-only races (e.g.,beast) to exclude from character creation - Set
default_classto reference a class ID for the race's default NPC class traitsanddisadvantagesare free-form string arrays- If
[body] slotsis empty, defaults to humanoid slots - Natural attacks: use
[natural.attacks.<name>]withdamage,type, optionalcooldown_ticks - Resistances: damage_type → multiplier (0.0 = immune, 1.0 = normal, 1.5 = vulnerable)
xp_ratemodifies XP gain (< 1.0 = slower leveling, for powerful races)
New guild
- Create
world/guilds/<name>.toml - Required:
name,description - 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) [growth]section:hp_per_level,mana_per_level,endurance_per_level,attack_per_level,defense_per_level- TOML ordering matters: put
spells,min_player_level,race_restrictedBEFORE any[section]headers - To link a class to a guild, add
guild = "guild:<filename_stem>"to the class TOML
New spell
- Create
world/spells/<name>.toml - Required:
name,description - Key fields:
spell_type("offensive"/"heal"/"utility"),damage,heal,damage_type,cost_mana,cost_endurance,cooldown_ticks,min_guild_level - Optional:
effect(status effect kind),effect_duration,effect_magnitude - Reference the spell ID (
spell:<filename_stem>) from a guild'sspellslist
New equipment slot
- Add the slot name to a race's
[body] slotsarray - Create objects with
slot = "<slot_name>"in their TOML - The
kindfield (weapon/armor) still works as fallback formain_hand/torso - Items can also have an explicit
slotfield to target any slot
New world content
- Add TOML files under
world/<region>/— no code changes needed - NPCs without a
[combat]section get default stats (20hp/4atk/2def/5xp) - Room IDs are
<region_dir>:<filename_stem> - Cross-region exits work — just reference the full ID
- Run
mudtool validate -w ./worldto check schemas, references, and values before committing
New DB table
- Add
CREATE TABLE IF NOT EXISTSinSqliteDb::open() - Add trait methods to
GameDb - Implement for
SqliteDb - Update
mudtoolif 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. Seecmd_attackfor examples. - ANSI formatting: Use helpers in
ansi.rs. Don't hardcode escape codes elsewhere. - Error handling: Game logic uses
Option/early-return patterns, notResultchains. 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.mdcontains a manual checklist and smoke test script. Run through relevant sections before committing.
Common Pitfalls
- Holding the lock too long:
state.lock().awaitgrabs a tokio mutex. If you.awaitnetwork I/O while holding it, the tick engine and all other players will block. Collect data, drop the lock, then send. - Borrow splitting:
GameStateownsworld,players,npc_instances, anddb. You can't borrowplayersmutably while also readingworldthrough the same&mut GameState. Extract what you need fromworldfirst. - 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.mdwhen adding features - Run through the relevant test checklist sections before pushing