Compare commits
42 Commits
3f164e4697
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 394a9b8355 | |||
| 7bab50b431 | |||
|
|
b81362d4d1 | ||
| 93b1c1e301 | |||
| d9f2929c0c | |||
|
|
2689f9e29e | ||
| 8b49ef2c46 | |||
|
|
1f4955db82 | ||
| 03122f2901 | |||
|
|
678543dd9a | ||
|
|
0914b5a32b | ||
|
|
1a545bbae7 | ||
|
|
3a2a606c4a | ||
|
|
f183daa16c | ||
| 3baa0091f9 | |||
|
|
87baaee46f | ||
|
|
52b333fa48 | ||
|
|
0722a2f1d7 | ||
|
|
2e1794b799 | ||
| df757ba37d | |||
|
|
ebdfa16aa5 | ||
|
|
dd517d8851 | ||
|
|
4e41038555 | ||
| def077c645 | |||
|
|
2157b45486 | ||
|
|
f2f1699351 | ||
|
|
014730e2f7 | ||
|
|
b5fb56c50c | ||
|
|
a2ffee0f94 | ||
|
|
93862c3c34 | ||
|
|
7c50bbf01a | ||
|
|
9c286823e6 | ||
|
|
09ff51c2b0 | ||
|
|
98967ebe59 | ||
|
|
5d290a8396 | ||
|
|
3a51ad115e | ||
|
|
156410bc39 | ||
|
|
e5e7057650 | ||
|
|
7b6829b1e8 | ||
|
|
598360ac95 | ||
|
|
bdd1a85ea2 | ||
|
|
005c4faf08 |
231
.gitea/workflows/smoke-tests.yml
Normal file
231
.gitea/workflows/smoke-tests.yml
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
name: Smoke tests
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
smoke:
|
||||||
|
name: Build and smoke test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
TEST_DB: ./mudserver.db.test
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install needed tools
|
||||||
|
env:
|
||||||
|
DEBIAN_FRONTEND: noninteractive
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends openssh-client netcat-openbsd ca-certificates curl
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cargo build
|
||||||
|
|
||||||
|
- name: Validate world data
|
||||||
|
run: ./target/debug/mudtool validate -w ./world
|
||||||
|
|
||||||
|
- name: Reset smoke database
|
||||||
|
run: rm -f "$TEST_DB"
|
||||||
|
|
||||||
|
- name: Smoke - new player and basics
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RUST_LOG=info ./target/debug/mudserver -d "$TEST_DB" &
|
||||||
|
MUD_PID=$!
|
||||||
|
trap 'kill $MUD_PID 2>/dev/null || true' EXIT
|
||||||
|
bash scripts/ci/wait-for-tcp.sh 127.0.0.1 2222
|
||||||
|
set +e
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF'
|
||||||
|
1
|
||||||
|
1
|
||||||
|
look
|
||||||
|
stats
|
||||||
|
go south
|
||||||
|
go down
|
||||||
|
go north
|
||||||
|
talk barkeep
|
||||||
|
go south
|
||||||
|
go south
|
||||||
|
examine thief
|
||||||
|
attack thief
|
||||||
|
flee
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
r=$?
|
||||||
|
set -e
|
||||||
|
[[ $r -eq 0 || $r -eq 255 ]]
|
||||||
|
|
||||||
|
- name: Smoke - weather and time
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RUST_LOG=info ./target/debug/mudserver -d "$TEST_DB" &
|
||||||
|
MUD_PID=$!
|
||||||
|
trap 'kill $MUD_PID 2>/dev/null || true' EXIT
|
||||||
|
bash scripts/ci/wait-for-tcp.sh 127.0.0.1 2222
|
||||||
|
set +e
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF' > weather_test.out
|
||||||
|
go south
|
||||||
|
go down
|
||||||
|
look
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
r=$?
|
||||||
|
set -e
|
||||||
|
[[ $r -eq 0 || $r -eq 255 ]]
|
||||||
|
if ! grep -q "The sky is\|raining\|storm\|snow\|fog" weather_test.out; then
|
||||||
|
echo "Error: Weather info not found in look output"
|
||||||
|
cat weather_test.out
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q "\[Night\]\|\[Morning\]\|\[Afternoon\]\|\[Evening\]" weather_test.out; then
|
||||||
|
echo "Error: Time of day info not found in look output"
|
||||||
|
cat weather_test.out
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm weather_test.out
|
||||||
|
|
||||||
|
- name: Smoke - persistence (reconnect)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RUST_LOG=info ./target/debug/mudserver -d "$TEST_DB" &
|
||||||
|
MUD_PID=$!
|
||||||
|
trap 'kill $MUD_PID 2>/dev/null || true' EXIT
|
||||||
|
bash scripts/ci/wait-for-tcp.sh 127.0.0.1 2222
|
||||||
|
set +e
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF'
|
||||||
|
look
|
||||||
|
stats
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
r=$?
|
||||||
|
set -e
|
||||||
|
[[ $r -eq 0 || $r -eq 255 ]]
|
||||||
|
|
||||||
|
- name: Smoke - mudtool admin
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players list
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players set-admin smoketest true
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players show smoketest
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" settings set registration_open false
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" settings list
|
||||||
|
|
||||||
|
- name: Smoke - in-game admin
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RUST_LOG=info ./target/debug/mudserver -d "$TEST_DB" &
|
||||||
|
MUD_PID=$!
|
||||||
|
trap 'kill $MUD_PID 2>/dev/null || true' EXIT
|
||||||
|
bash scripts/ci/wait-for-tcp.sh 127.0.0.1 2222
|
||||||
|
set +e
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF'
|
||||||
|
admin help
|
||||||
|
admin list
|
||||||
|
admin registration on
|
||||||
|
admin info smoketest
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
r=$?
|
||||||
|
set -e
|
||||||
|
[[ $r -eq 0 || $r -eq 255 ]]
|
||||||
|
|
||||||
|
- name: Smoke - registration gate
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" settings set registration_open false
|
||||||
|
RUST_LOG=info ./target/debug/mudserver -d "$TEST_DB" &
|
||||||
|
MUD_PID=$!
|
||||||
|
trap 'kill $MUD_PID 2>/dev/null || true' EXIT
|
||||||
|
bash scripts/ci/wait-for-tcp.sh 127.0.0.1 2222
|
||||||
|
set +e
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 newplayer@localhost <<'EOF'
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
r=$?
|
||||||
|
set -e
|
||||||
|
[[ $r -eq 0 || $r -eq 255 ]]
|
||||||
|
|
||||||
|
- name: Smoke - tick-based combat
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" settings set registration_open true
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players delete smoketest
|
||||||
|
RUST_LOG=info ./target/debug/mudserver -d "$TEST_DB" &
|
||||||
|
MUD_PID=$!
|
||||||
|
trap 'kill $MUD_PID 2>/dev/null || true' EXIT
|
||||||
|
bash scripts/ci/wait-for-tcp.sh 127.0.0.1 2222
|
||||||
|
set +e
|
||||||
|
(
|
||||||
|
echo "1"
|
||||||
|
echo "1"
|
||||||
|
echo "go south"
|
||||||
|
echo "go down"
|
||||||
|
echo "go south"
|
||||||
|
echo "attack thief"
|
||||||
|
sleep 15
|
||||||
|
echo "stats"
|
||||||
|
echo "quit"
|
||||||
|
) | ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost
|
||||||
|
r=$?
|
||||||
|
set -e
|
||||||
|
[[ $r -eq 0 || $r -eq 255 ]]
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" settings set registration_open true
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players delete smoketest
|
||||||
|
|
||||||
|
- name: Smoke - JSON-RPC list_commands
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RUST_LOG=info ./target/debug/mudserver -d "$TEST_DB" &
|
||||||
|
MUD_PID=$!
|
||||||
|
trap 'kill $MUD_PID 2>/dev/null || true' EXIT
|
||||||
|
bash scripts/ci/wait-for-tcp.sh 127.0.0.1 2222
|
||||||
|
set +e
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 rpctest@localhost <<'EOF'
|
||||||
|
1
|
||||||
|
1
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
r=$?
|
||||||
|
set -e
|
||||||
|
[[ $r -eq 0 || $r -eq 255 ]]
|
||||||
|
echo '{"_jsonrpc": "2.0", "method": "login", "params": {"username": "rpctest"}, "id": 1}' | nc -w 2 localhost 2223 > rpc_resp.json
|
||||||
|
echo '{"_jsonrpc": "2.0", "method": "list_commands", "params": {}, "id": 2}' | nc -w 2 localhost 2223 >> rpc_resp.json
|
||||||
|
grep -q '"shop"' rpc_resp.json
|
||||||
|
rm rpc_resp.json
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players delete rpctest
|
||||||
|
|
||||||
|
- name: Verify logging
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
if [ ! -d "logs" ]; then
|
||||||
|
echo "Error: logs directory not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
FAILED=0
|
||||||
|
|
||||||
|
echo "Checking mudserver logs..."
|
||||||
|
grep -q "World '.*': .* rooms" logs/mudserver_*.log || { echo "Failed: World loading log missing"; FAILED=1; }
|
||||||
|
grep -q "MUD server listening on" logs/mudserver_*.log || { echo "Failed: Listen log missing"; FAILED=1; }
|
||||||
|
grep -q "New character created: smoketest" logs/mudserver_*.log || { echo "Failed: smoketest creation log missing"; FAILED=1; }
|
||||||
|
grep -q "Admin action: registration setting updated: '.*'" logs/mudserver_*.log || { echo "Failed: Admin action log missing"; FAILED=1; }
|
||||||
|
|
||||||
|
echo "Checking combat logs..."
|
||||||
|
grep -q "Combat: Player 'smoketest' (ID .*) engaged NPC 'Shadowy Thief'" logs/combat_*.log || { echo "Failed: Combat engagement log missing"; FAILED=1; }
|
||||||
|
grep -q "Combat: Player 'smoketest' (ID .*) killed NPC 'Shadowy Thief'" logs/combat_*.log || { echo "Failed: NPC kill log missing"; FAILED=1; }
|
||||||
|
|
||||||
|
if [ $FAILED -ne 0 ]; then
|
||||||
|
echo "--- LOG VERIFICATION FAILED ---"
|
||||||
|
echo "--- MUDSERVER LOG CONTENTS ---"
|
||||||
|
cat logs/mudserver_*.log
|
||||||
|
echo "--- COMBAT LOG CONTENTS ---"
|
||||||
|
cat logs/combat_*.log
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Logging verification passed."
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,3 +2,5 @@
|
|||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
/logs
|
||||||
|
/manual_logs
|
||||||
|
|||||||
55
AGENTS.md
55
AGENTS.md
@@ -12,6 +12,13 @@ This is a Rust MUD server that accepts SSH connections. The architecture separat
|
|||||||
- Status effects persist in the database and continue ticking while players are offline.
|
- Status effects persist in the database and continue ticking while players are offline.
|
||||||
- The `GameDb` trait abstracts the database backend (currently SQLite).
|
- The `GameDb` trait abstracts the database backend (currently SQLite).
|
||||||
- World data is loaded from TOML at startup. Content changes don't require recompilation.
|
- 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
|
## Architecture
|
||||||
|
|
||||||
@@ -22,7 +29,7 @@ src/
|
|||||||
├── ssh.rs russh server/handler: connection lifecycle, chargen flow, command dispatch
|
├── ssh.rs russh server/handler: connection lifecycle, chargen flow, command dispatch
|
||||||
├── game.rs Core runtime state: Player, GameState, SharedState, XorShift64 RNG
|
├── game.rs Core runtime state: Player, GameState, SharedState, XorShift64 RNG
|
||||||
├── commands.rs Player command parsing and execution (immediate + queued actions)
|
├── commands.rs Player command parsing and execution (immediate + queued actions)
|
||||||
├── combat.rs Tick-based combat resolution: attack, defend, flee, item use
|
├── combat.rs Tick-based combat resolution: attack, defend, flee, item use, cast
|
||||||
├── tick.rs Background tick engine: NPC AI, combat rounds, effects, respawns, regen
|
├── tick.rs Background tick engine: NPC AI, combat rounds, effects, respawns, regen
|
||||||
├── admin.rs Admin command implementations
|
├── admin.rs Admin command implementations
|
||||||
├── chargen.rs Character creation state machine
|
├── chargen.rs Character creation state machine
|
||||||
@@ -43,11 +50,12 @@ src/
|
|||||||
## How Combat Works
|
## How Combat Works
|
||||||
|
|
||||||
1. Player types `attack <npc>` → enters `CombatState` with `action: Some(Attack)`
|
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>`)
|
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()`
|
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
|
4. Resolution: execute player action → NPC counter-attacks → check death → clear action
|
||||||
5. If no action was queued, default is `Attack`
|
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
|
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
|
## How Status Effects Work
|
||||||
|
|
||||||
@@ -76,11 +84,54 @@ src/
|
|||||||
2. Currently: hostile NPCs auto-engage players in their room
|
2. Currently: hostile NPCs auto-engage players in their room
|
||||||
3. Add new behaviors there (e.g. NPC movement, dialogue triggers)
|
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
|
### New world content
|
||||||
1. Add TOML files under `world/<region>/` — no code changes needed
|
1. Add TOML files under `world/<region>/` — no code changes needed
|
||||||
2. NPCs without a `[combat]` section get default stats (20hp/4atk/2def/5xp)
|
2. NPCs without a `[combat]` section get default stats (20hp/4atk/2def/5xp)
|
||||||
3. Room IDs are `<region_dir>:<filename_stem>`
|
3. Room IDs are `<region_dir>:<filename_stem>`
|
||||||
4. Cross-region exits work — just reference the full ID
|
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
|
### New DB table
|
||||||
1. Add `CREATE TABLE IF NOT EXISTS` in `SqliteDb::open()`
|
1. Add `CREATE TABLE IF NOT EXISTS` in `SqliteDb::open()`
|
||||||
|
|||||||
176
Cargo.lock
generated
176
Cargo.lock
generated
@@ -61,56 +61,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstream"
|
|
||||||
version = "0.6.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
|
||||||
dependencies = [
|
|
||||||
"anstyle",
|
|
||||||
"anstyle-parse",
|
|
||||||
"anstyle-query",
|
|
||||||
"anstyle-wincon",
|
|
||||||
"colorchoice",
|
|
||||||
"is_terminal_polyfill",
|
|
||||||
"utf8parse",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle"
|
|
||||||
version = "1.0.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle-parse"
|
|
||||||
version = "0.2.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
|
||||||
dependencies = [
|
|
||||||
"utf8parse",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle-query"
|
|
||||||
version = "1.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
|
||||||
dependencies = [
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle-wincon"
|
|
||||||
version = "3.0.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|
||||||
dependencies = [
|
|
||||||
"anstyle",
|
|
||||||
"once_cell_polyfill",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.102"
|
||||||
@@ -335,12 +285,6 @@ dependencies = [
|
|||||||
"inout",
|
"inout",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "colorchoice"
|
|
||||||
version = "1.0.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compact_str"
|
name = "compact_str"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -385,6 +329,30 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-channel"
|
||||||
|
version = "0.5.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-queue"
|
||||||
|
version = "0.3.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-utils"
|
||||||
|
version = "0.8.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossterm"
|
name = "crossterm"
|
||||||
version = "0.28.1"
|
version = "0.28.1"
|
||||||
@@ -694,29 +662,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "env_filter"
|
|
||||||
version = "1.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"regex",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "env_logger"
|
|
||||||
version = "0.11.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
|
|
||||||
dependencies = [
|
|
||||||
"anstream",
|
|
||||||
"anstyle",
|
|
||||||
"env_filter",
|
|
||||||
"jiff",
|
|
||||||
"log",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -809,6 +754,21 @@ version = "0.4.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flexi_logger"
|
||||||
|
version = "0.29.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "88a5a6882b2e137c4f2664562995865084eb5a00611fba30c582ef10354c4ad8"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"crossbeam-channel",
|
||||||
|
"crossbeam-queue",
|
||||||
|
"log",
|
||||||
|
"nu-ansi-term",
|
||||||
|
"regex",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fnv"
|
name = "fnv"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -1166,12 +1126,6 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "is_terminal_polyfill"
|
|
||||||
version = "1.70.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -1187,30 +1141,6 @@ version = "1.0.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jiff"
|
|
||||||
version = "0.2.23"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
|
|
||||||
dependencies = [
|
|
||||||
"jiff-static",
|
|
||||||
"log",
|
|
||||||
"portable-atomic",
|
|
||||||
"portable-atomic-util",
|
|
||||||
"serde_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jiff-static"
|
|
||||||
version = "0.2.23"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.91"
|
version = "0.3.91"
|
||||||
@@ -1387,9 +1317,11 @@ name = "mudserver"
|
|||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossterm 0.28.1",
|
"crossterm 0.28.1",
|
||||||
"env_logger",
|
"flexi_logger",
|
||||||
"log",
|
"log",
|
||||||
|
"rand",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
|
"regex",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"russh",
|
"russh",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1421,6 +1353,15 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"minimal-lexical",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nu-ansi-term"
|
||||||
|
version = "0.50.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-bigint"
|
name = "num-bigint"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@@ -1510,12 +1451,6 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "once_cell_polyfill"
|
|
||||||
version = "1.70.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opaque-debug"
|
name = "opaque-debug"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1813,15 +1748,6 @@ version = "1.13.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "portable-atomic-util"
|
|
||||||
version = "0.2.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
|
|
||||||
dependencies = [
|
|
||||||
"portable-atomic",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "powerfmt"
|
name = "powerfmt"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|||||||
@@ -13,4 +13,6 @@ rusqlite = { version = "0.35", features = ["bundled"] }
|
|||||||
ratatui = "0.30"
|
ratatui = "0.30"
|
||||||
crossterm = "0.28"
|
crossterm = "0.28"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
flexi_logger = { version = "0.29", features = ["async"] }
|
||||||
|
regex = "1"
|
||||||
|
rand = "0.8"
|
||||||
|
|||||||
34
PLANNED.md
Normal file
34
PLANNED.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
## Completed
|
||||||
|
|
||||||
|
- **Shops / economy** — NPCs that buy and sell; currency and pricing.
|
||||||
|
- **Enhanced NPC Interactions** — Keyword-based dialogue system.
|
||||||
|
- **Aggressive NPC AI** — NPCs with Aggressive attitude now correctly initiate combat.
|
||||||
|
- **Weather** — Weather system (e.g., rain, snow, fog) affecting areas or atmosphere.
|
||||||
|
- **Day/night or time of day** — Time cycle affecting room descriptions, spawns, or NPC behavior.
|
||||||
|
|
||||||
|
## Easy
|
||||||
|
|
||||||
|
Content-only or minimal code; add TOML/data and existing systems already support it.
|
||||||
|
|
||||||
|
- **More classes** — Additional character classes beyond current set; add via `world/classes/*.toml`.
|
||||||
|
- **More areas** — Additional regions/areas; add via new `world/<region>/` directories and content.
|
||||||
|
- **More races** — Additional playable or NPC races; add via `world/races/*.toml`.
|
||||||
|
- **More guilds** — Additional spell progressions and roles; add via `world/guilds/*.toml`.
|
||||||
|
- **More spells** — Additional spells for combat and utility; add via `world/spells/*.toml`.
|
||||||
|
|
||||||
|
## Medium
|
||||||
|
|
||||||
|
New state, commands, or mechanics with bounded scope.
|
||||||
|
|
||||||
|
- **Robust Logging** — Structured logging, rotation, and persistence; better visibility into server state and player actions.
|
||||||
|
- **Quests or objectives** — Simple “kill X” / “bring Y” goals; quest state in DB and hooks in combat/loot/NPCs.
|
||||||
|
- **Player parties** — Group formation, shared objectives, party-only chat or visibility; new state and commands.
|
||||||
|
- **PvP** — Player-vs-player combat; consent/flagging, safe zones, and balance TBD.
|
||||||
|
|
||||||
|
## Hard
|
||||||
|
|
||||||
|
Larger or open-ended systems; more design and implementation.
|
||||||
|
|
||||||
|
- **Crafting** — Recipes and combining items; new item kinds and possibly tables.
|
||||||
|
- **Pets / followers** — Combat or utility companions; new state and AI.
|
||||||
|
- **Player mail / messaging** — Offline messages between characters; DB table and commands.
|
||||||
123
README.md
123
README.md
@@ -55,118 +55,39 @@ RUST_LOG=debug ./target/release/mudserver # verbose
|
|||||||
|
|
||||||
## World Data
|
## 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.
|
The world is defined in TOML files under `world/`. The server loads them at startup — no recompilation needed to change content.
|
||||||
|
|
||||||
### Directory Structure
|
### Directory structure
|
||||||
|
|
||||||
```
|
```
|
||||||
world/
|
world/
|
||||||
├── manifest.toml # world name and spawn room
|
├── manifest.toml
|
||||||
├── races/ # playable races
|
├── races/
|
||||||
│ ├── dwarf.toml
|
├── classes/
|
||||||
│ ├── elf.toml
|
├── guilds/
|
||||||
│ └── ...
|
├── spells/
|
||||||
├── classes/ # playable classes
|
└── <region>/
|
||||||
│ ├── warrior.toml
|
├── region.toml
|
||||||
│ ├── mage.toml
|
|
||||||
│ └── ...
|
|
||||||
└── <region>/ # one directory per region
|
|
||||||
├── region.toml # region metadata
|
|
||||||
├── rooms/
|
├── rooms/
|
||||||
│ ├── town_square.toml
|
|
||||||
│ └── ...
|
|
||||||
├── npcs/
|
├── npcs/
|
||||||
│ ├── barkeep.toml
|
|
||||||
│ └── ...
|
|
||||||
└── objects/
|
└── objects/
|
||||||
├── rusty_sword.toml
|
|
||||||
└── ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### manifest.toml
|
### Schema reference
|
||||||
|
|
||||||
```toml
|
Each folder contains a reference doc listing every TOML option:
|
||||||
name = "The Shattered Realm"
|
|
||||||
spawn_room = "town:town_square"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Room
|
| Location | Reference |
|
||||||
|
|----------|-----------|
|
||||||
```toml
|
| `world/` | [MANIFEST.md](world/MANIFEST.md) — world name, spawn room, layout |
|
||||||
name = "Town Square"
|
| `world/races/` | [RACES.md](world/races/RACES.md) — stats, body, natural attacks, resistances, etc. |
|
||||||
description = "A cobblestone square with a fountain."
|
| `world/classes/` | [CLASSES.md](world/classes/CLASSES.md) — base stats, growth, hidden, guild |
|
||||||
|
| `world/guilds/` | [GUILDS.md](world/guilds/GUILDS.md) — spells, growth, race restrictions |
|
||||||
[exits]
|
| `world/spells/` | [SPELLS.md](world/spells/SPELLS.md) — damage, cost, cooldown, effects |
|
||||||
north = "town:tavern"
|
| `world/<region>/` | [REGION.md](world/town/REGION.md) — region metadata |
|
||||||
south = "town:gate"
|
| `world/<region>/rooms/` | [ROOMS.md](world/town/rooms/ROOMS.md) — name, description, exits |
|
||||||
east = "town:market"
|
| `world/<region>/npcs/` | [NPCS.md](world/town/npcs/NPCS.md) — attitude, race/class, combat, dialogue |
|
||||||
```
|
| `world/<region>/objects/` | [OBJECTS.md](world/town/objects/OBJECTS.md) — slot, stats, takeable |
|
||||||
|
|
||||||
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
|
## Game Mechanics
|
||||||
|
|
||||||
|
|||||||
193
TESTING.md
193
TESTING.md
@@ -1,6 +1,18 @@
|
|||||||
# Pre-Commit Test Checklist
|
# Pre-Commit Test Checklist
|
||||||
|
|
||||||
Run through these checks before every commit to ensure consistent feature coverage.
|
**Automated smoke test (same as CI):** run from the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run-tests.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds the server and mudtool, starts the server with a temporary DB, and runs the same sequence as the smoke steps in [`.gitea/workflows/smoke-tests.yml`](.gitea/workflows/smoke-tests.yml) (new player, persistence, mudtool admin, in-game admin, registration gate, tick combat), then cleans up. Use `MUD_TEST_DB` (default `./mudserver.db.test`) so you do not overwrite your normal `mudserver.db`.
|
||||||
|
|
||||||
|
Prerequisites: Rust toolchain (cargo), OpenSSH client, and OpenBSD `nc` (`netcat-openbsd` on Debian/Ubuntu) for [`scripts/ci/wait-for-tcp.sh`](scripts/ci/wait-for-tcp.sh). CI installs these explicitly before the smoke steps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Run through the checks below before every commit to ensure consistent feature coverage.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
- [ ] `cargo build` succeeds with no errors
|
- [ ] `cargo build` succeeds with no errors
|
||||||
@@ -15,7 +27,10 @@ Run through these checks before every commit to ensure consistent feature covera
|
|||||||
## Character Creation
|
## Character Creation
|
||||||
- [ ] New player SSH → gets chargen flow (race + class selection)
|
- [ ] New player SSH → gets chargen flow (race + class selection)
|
||||||
- [ ] Chargen accepts both number and name input
|
- [ ] Chargen accepts both number and name input
|
||||||
|
- [ ] All races display with expanded info (size, traits, natural attacks, vision)
|
||||||
|
- [ ] Dragon race shows custom body slots, natural armor, fire breath, vision types
|
||||||
- [ ] After chargen, player appears in spawn room with correct stats
|
- [ ] After chargen, player appears in spawn room with correct stats
|
||||||
|
- [ ] Stats reflect race modifiers (STR, DEX, CON, INT, WIS, PER, CHA)
|
||||||
- [ ] Player saved to DB after creation
|
- [ ] Player saved to DB after creation
|
||||||
|
|
||||||
## Player Persistence
|
## Player Persistence
|
||||||
@@ -26,6 +41,14 @@ Run through these checks before every commit to ensure consistent feature covera
|
|||||||
- [ ] On reconnect, expired effects are gone; active effects resume
|
- [ ] On reconnect, expired effects are gone; active effects resume
|
||||||
- [ ] Verify with: `sqlite3 mudserver.db "SELECT * FROM players;"`
|
- [ ] Verify with: `sqlite3 mudserver.db "SELECT * FROM players;"`
|
||||||
|
|
||||||
|
## Look Command
|
||||||
|
- [ ] `look` with no args shows the room (NPCs, objects, exits, players)
|
||||||
|
- [ ] `look <npc>` shows NPC description, stats, and attitude
|
||||||
|
- [ ] `look <object>` shows object description (floor or inventory)
|
||||||
|
- [ ] `look <exit>` shows where the exit leads
|
||||||
|
- [ ] `look <player>` shows player race, level, combat status
|
||||||
|
- [ ] `look <invalid>` shows "You don't see X here."
|
||||||
|
|
||||||
## Movement & Navigation
|
## Movement & Navigation
|
||||||
- [ ] `go north`, `n`, `south`, `s`, etc. all work
|
- [ ] `go north`, `n`, `south`, `s`, etc. all work
|
||||||
- [ ] Invalid direction shows error
|
- [ ] Invalid direction shows error
|
||||||
@@ -68,13 +91,18 @@ Run through these checks before every commit to ensure consistent feature covera
|
|||||||
- [ ] Damage varies between hits (not identical each time)
|
- [ ] Damage varies between hits (not identical each time)
|
||||||
- [ ] Multiple rapid attacks produce different damage values
|
- [ ] Multiple rapid attacks produce different damage values
|
||||||
|
|
||||||
## Items
|
## Items & Equipment Slots
|
||||||
- [ ] `take <item>` picks up takeable objects
|
- [ ] `take <item>` picks up takeable objects
|
||||||
- [ ] `drop <item>` places item in room
|
- [ ] `drop <item>` places item in room
|
||||||
- [ ] `equip <weapon/armor>` works, old gear returns to inventory
|
- [ ] `equip <weapon>` equips to `main_hand` slot (backwards-compat via kind)
|
||||||
|
- [ ] `equip <armor>` equips to appropriate slot (obj `slot` field or fallback)
|
||||||
|
- [ ] Equipping to an occupied slot returns old item to inventory
|
||||||
|
- [ ] `equip` fails if race doesn't have the required slot
|
||||||
|
- [ ] Objects with explicit `slot` field use that slot
|
||||||
- [ ] `use <consumable>` heals and removes item (immediate out of combat)
|
- [ ] `use <consumable>` heals and removes item (immediate out of combat)
|
||||||
- [ ] `use <consumable>` in combat queues for next tick
|
- [ ] `use <consumable>` in combat queues for next tick
|
||||||
- [ ] `inventory` shows equipped + bag items
|
- [ ] `inventory` shows equipped items by slot name + bag items
|
||||||
|
- [ ] `stats` shows equipment bonuses and natural bonuses separately
|
||||||
|
|
||||||
## Status Effects
|
## Status Effects
|
||||||
- [ ] Poison deals damage each tick, shows message to player
|
- [ ] Poison deals damage each tick, shows message to player
|
||||||
@@ -85,10 +113,55 @@ Run through these checks before every commit to ensure consistent feature covera
|
|||||||
- [ ] Negative status effects cleared on player death/respawn
|
- [ ] Negative status effects cleared on player death/respawn
|
||||||
- [ ] Status effects on offline players resolve by wall-clock time on next login
|
- [ ] Status effects on offline players resolve by wall-clock time on next login
|
||||||
|
|
||||||
|
## Weather & Time
|
||||||
|
- [ ] Outdoor rooms display time of day (e.g., `[Night]`, `[Morning]`).
|
||||||
|
- [ ] Outdoor rooms display current weather (e.g., `The sky is clear`, `It is raining`).
|
||||||
|
- [ ] Indoor rooms do not show weather or time of day.
|
||||||
|
- [ ] Rain or storm applies the `wet` status effect to players in outdoor rooms.
|
||||||
|
- [ ] Weather changes periodically and broadcasts messages to players in outdoor rooms.
|
||||||
|
|
||||||
|
## Guilds
|
||||||
|
- [ ] `guild list` shows all available guilds with descriptions
|
||||||
|
- [ ] `guild info <name>` shows guild details, growth stats, and spell list
|
||||||
|
- [ ] `guild join <name>` adds player to guild at level 1
|
||||||
|
- [ ] `guild join` grants base mana/endurance from guild
|
||||||
|
- [ ] `guild leave <name>` removes guild membership
|
||||||
|
- [ ] Cannot join a guild twice
|
||||||
|
- [ ] Cannot leave a guild you're not in
|
||||||
|
- [ ] Race-restricted guilds reject restricted races
|
||||||
|
- [ ] Player level requirement enforced on `guild join`
|
||||||
|
- [ ] Multi-guild: player can be in multiple guilds simultaneously
|
||||||
|
- [ ] Guild membership persists across logout/login (stored in player_guilds table)
|
||||||
|
- [ ] `stats` shows guild memberships with levels
|
||||||
|
|
||||||
|
## Spells & Casting
|
||||||
|
- [ ] `spells` lists known spells grouped by guild with cost and cooldown
|
||||||
|
- [ ] Spells filtered by guild level (only shows spells at or below current guild level)
|
||||||
|
- [ ] `cast <spell>` out of combat: heal/utility resolves immediately
|
||||||
|
- [ ] `cast <spell>` out of combat: offensive spells blocked ("enter combat first")
|
||||||
|
- [ ] `cast <spell>` in combat: queues as CombatAction, resolves on tick
|
||||||
|
- [ ] Spell resolves: offensive deals damage to NPC, shows damage + type
|
||||||
|
- [ ] Spell resolves: heal restores HP, shows amount healed
|
||||||
|
- [ ] Spell resolves: utility applies status effect
|
||||||
|
- [ ] Mana deducted on cast; blocked if insufficient ("Not enough mana")
|
||||||
|
- [ ] Endurance deducted on cast; blocked if insufficient
|
||||||
|
- [ ] Cooldowns applied after casting; blocked if on cooldown ("X ticks remaining")
|
||||||
|
- [ ] Cooldowns tick down each server tick
|
||||||
|
- [ ] NPC can die from spell damage (awards XP, ends combat)
|
||||||
|
- [ ] Spell partial name matching works ("magic" matches "Magic Missile")
|
||||||
|
|
||||||
|
## Chargen & Guild Seeding
|
||||||
|
- [ ] Class selection shows "→ joins <Guild Name>" when class has a guild
|
||||||
|
- [ ] Creating a character with a class that references a guild auto-joins that guild
|
||||||
|
- [ ] Starting mana/endurance calculated from guild base + racial stat modifiers
|
||||||
|
- [ ] Guild membership saved to DB at character creation
|
||||||
|
|
||||||
## Passive Regeneration
|
## Passive Regeneration
|
||||||
- [ ] Players out of combat slowly regenerate HP over ticks
|
- [ ] Players out of combat slowly regenerate HP over ticks
|
||||||
|
- [ ] Players out of combat slowly regenerate mana over ticks
|
||||||
|
- [ ] Players out of combat slowly regenerate endurance over ticks
|
||||||
- [ ] Regeneration does not occur while in combat
|
- [ ] Regeneration does not occur while in combat
|
||||||
- [ ] HP does not exceed max_hp
|
- [ ] HP/mana/endurance do not exceed their maximums
|
||||||
|
|
||||||
## Tick Engine
|
## Tick Engine
|
||||||
- [ ] Tick runs at configured interval (~3 seconds)
|
- [ ] Tick runs at configured interval (~3 seconds)
|
||||||
@@ -97,6 +170,33 @@ Run through these checks before every commit to ensure consistent feature covera
|
|||||||
- [ ] Server remains responsive to immediate commands between ticks
|
- [ ] Server remains responsive to immediate commands between ticks
|
||||||
- [ ] Multiple players in separate combats are processed independently per tick
|
- [ ] Multiple players in separate combats are processed independently per tick
|
||||||
|
|
||||||
|
## NPC Race & Class
|
||||||
|
- [ ] NPCs with fixed race/class in TOML show that race/class
|
||||||
|
- [ ] NPCs without race get a random non-hidden race at spawn
|
||||||
|
- [ ] NPCs without class: race default_class used, or random non-hidden if no default
|
||||||
|
- [ ] `look <npc>` shows NPC race and class
|
||||||
|
- [ ] `examine <npc>` shows NPC race and class
|
||||||
|
- [ ] Rat shows "Beast Creature" (fixed race/class)
|
||||||
|
- [ ] Barkeep shows a random race + Peasant (no fixed race, human default class)
|
||||||
|
- [ ] Thief shows random race + Rogue (no fixed race, fixed class)
|
||||||
|
- [ ] Guard shows random race + Warrior (no fixed race, fixed class)
|
||||||
|
- [ ] On NPC respawn, race/class re-rolled if not fixed in TOML
|
||||||
|
- [ ] Hidden races (Beast) do not appear in character creation
|
||||||
|
- [ ] Hidden classes (Peasant, Creature) do not appear in character creation
|
||||||
|
|
||||||
|
## Race System
|
||||||
|
- [ ] Existing races (Human, Elf, Dwarf, Orc, Halfling) load with expanded fields
|
||||||
|
- [ ] Dragon race loads with custom body, natural attacks, resistances, traits
|
||||||
|
- [ ] Dragon gets custom equipment slots (forelegs, hindlegs, wings, tail)
|
||||||
|
- [ ] Dragon's natural armor (8) shows in stats and affects defense
|
||||||
|
- [ ] Dragon's natural attacks (fire breath 15dmg) affect effective attack
|
||||||
|
- [ ] Items magically resize — no size restrictions on gear (dragon can use swords)
|
||||||
|
- [ ] Races without explicit [body.slots] get default humanoid slots
|
||||||
|
- [ ] Stat modifiers include PER (perception) and CHA (charisma)
|
||||||
|
- [ ] Race traits and disadvantages display during chargen
|
||||||
|
- [ ] XP rate modifier stored per race (dragon = 0.7x)
|
||||||
|
- [ ] Regen modifiers stored per race (dragon HP regen = 1.5x)
|
||||||
|
|
||||||
## Attitude System
|
## Attitude System
|
||||||
- [ ] Per-player NPC attitudes stored in DB
|
- [ ] Per-player NPC attitudes stored in DB
|
||||||
- [ ] `examine` shows attitude label per-player
|
- [ ] `examine` shows attitude label per-player
|
||||||
@@ -125,6 +225,7 @@ Run through these checks before every commit to ensure consistent feature covera
|
|||||||
- [ ] Existing players can still log in when registration is closed
|
- [ ] Existing players can still log in when registration is closed
|
||||||
|
|
||||||
## MUD Tool - CLI
|
## MUD Tool - CLI
|
||||||
|
- [ ] `mudtool validate -w ./world` checks world data (schemas, references, values)
|
||||||
- [ ] `mudtool players list` shows all players
|
- [ ] `mudtool players list` shows all players
|
||||||
- [ ] `mudtool players show <name>` shows details
|
- [ ] `mudtool players show <name>` shows details
|
||||||
- [ ] `mudtool players set-admin <name> true` works
|
- [ ] `mudtool players set-admin <name> true` works
|
||||||
@@ -144,79 +245,13 @@ Run through these checks before every commit to ensure consistent feature covera
|
|||||||
- [ ] ←→ switches player on Attitudes tab
|
- [ ] ←→ switches player on Attitudes tab
|
||||||
- [ ] 'q' exits TUI
|
- [ ] 'q' exits TUI
|
||||||
|
|
||||||
|
## JSON-RPC Interface
|
||||||
|
- [ ] `list_commands` returns the currently handleable command list
|
||||||
|
- [ ] New commands added in `commands.rs` are automatically discovered
|
||||||
|
- [ ] `login` accepts an existing player name (requires character to be created first)
|
||||||
|
- [ ] Command output is stripped of ANSI color codes for API consumption
|
||||||
|
- [ ] Verify manually with: `echo '{"_jsonrpc": "2.0", "method": "list_commands", "params": {}, "id": 1}' | nc localhost 2223`
|
||||||
|
|
||||||
## Quick Smoke Test Script
|
## Quick Smoke Test Script
|
||||||
|
|
||||||
```bash
|
**CI:** each scenario is a separate step in [`.gitea/workflows/smoke-tests.yml`](.gitea/workflows/smoke-tests.yml) (each SSH step starts `mudserver`, runs the block, stops it; the same `TEST_DB` file carries state between steps). The last step exercises JSON-RPC `login` / `list_commands` on port 2223 (expects `shop` in the command list). **Local:** **`./run-tests.sh`** runs the full sequence in one process. When you add or change coverage, update the workflow steps and `run-tests.sh` together, and keep the checklist sections above aligned.
|
||||||
# Start server in background
|
|
||||||
RUST_LOG=info ./target/debug/mudserver &
|
|
||||||
SERVER_PID=$!
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
# Test 1: New player creation + basic commands
|
|
||||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF'
|
|
||||||
1
|
|
||||||
1
|
|
||||||
look
|
|
||||||
stats
|
|
||||||
go north
|
|
||||||
talk barkeep
|
|
||||||
go south
|
|
||||||
go south
|
|
||||||
examine thief
|
|
||||||
attack thief
|
|
||||||
flee
|
|
||||||
quit
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Test 2: Persistence - reconnect
|
|
||||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF'
|
|
||||||
look
|
|
||||||
stats
|
|
||||||
quit
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Test 3: Admin via mudtool
|
|
||||||
./target/debug/mudtool players list
|
|
||||||
./target/debug/mudtool players set-admin smoketest true
|
|
||||||
./target/debug/mudtool players show smoketest
|
|
||||||
./target/debug/mudtool settings set registration_open false
|
|
||||||
./target/debug/mudtool settings list
|
|
||||||
|
|
||||||
# Test 4: Admin commands in-game
|
|
||||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF'
|
|
||||||
admin help
|
|
||||||
admin list
|
|
||||||
admin registration on
|
|
||||||
admin info smoketest
|
|
||||||
quit
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Test 5: Registration gate
|
|
||||||
./target/debug/mudtool settings set registration_open false
|
|
||||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 newplayer@localhost <<'EOF'
|
|
||||||
quit
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Test 6: Tick-based combat (connect and wait for ticks)
|
|
||||||
./target/debug/mudtool settings set registration_open true
|
|
||||||
./target/debug/mudtool players delete smoketest
|
|
||||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF'
|
|
||||||
1
|
|
||||||
1
|
|
||||||
go south
|
|
||||||
go south
|
|
||||||
attack thief
|
|
||||||
EOF
|
|
||||||
# Wait for several combat ticks to resolve
|
|
||||||
sleep 8
|
|
||||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF'
|
|
||||||
stats
|
|
||||||
quit
|
|
||||||
EOF
|
|
||||||
# Verify XP changed (combat happened via ticks)
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
./target/debug/mudtool settings set registration_open true
|
|
||||||
./target/debug/mudtool players delete smoketest
|
|
||||||
kill $SERVER_PID
|
|
||||||
```
|
|
||||||
|
|||||||
123
run-tests.sh
Executable file
123
run-tests.sh
Executable file
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -ex
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
TEST_DB=${MUD_TEST_DB:-./mudserver.db.test}
|
||||||
|
SERVER_PID=
|
||||||
|
|
||||||
|
ssh_mud() {
|
||||||
|
set +e
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 "$@"
|
||||||
|
r=$?
|
||||||
|
set -e
|
||||||
|
[[ $r -eq 0 || $r -eq 255 ]] || exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "${SERVER_PID:-}" ]; then
|
||||||
|
kill $SERVER_PID 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
cargo build
|
||||||
|
RUST_LOG=info ./target/debug/mudserver -d "$TEST_DB" &
|
||||||
|
SERVER_PID=$!
|
||||||
|
bash "$ROOT/scripts/ci/wait-for-tcp.sh" 127.0.0.1 2222
|
||||||
|
|
||||||
|
ssh_mud smoketest@localhost <<'EOF'
|
||||||
|
1
|
||||||
|
1
|
||||||
|
look
|
||||||
|
stats
|
||||||
|
go south
|
||||||
|
go down
|
||||||
|
go north
|
||||||
|
talk barkeep
|
||||||
|
go south
|
||||||
|
go south
|
||||||
|
examine thief
|
||||||
|
attack thief
|
||||||
|
flee
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
|
||||||
|
ssh_mud smoketest@localhost <<'EOF' > weather_test.out
|
||||||
|
go south
|
||||||
|
go down
|
||||||
|
look
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if ! grep -q "The sky is\|raining\|storm\|snow\|fog" weather_test.out; then
|
||||||
|
echo "Error: Weather info not found in look output"
|
||||||
|
cat weather_test.out
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q "\[Night\]\|\[Morning\]\|\[Afternoon\]\|\[Evening\]" weather_test.out; then
|
||||||
|
echo "Error: Time of day info not found in look output"
|
||||||
|
cat weather_test.out
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm weather_test.out
|
||||||
|
|
||||||
|
ssh_mud smoketest@localhost <<'EOF'
|
||||||
|
look
|
||||||
|
stats
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players list
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players set-admin smoketest true
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players show smoketest
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" settings set registration_open false
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" settings list
|
||||||
|
|
||||||
|
ssh_mud smoketest@localhost <<'EOF'
|
||||||
|
admin help
|
||||||
|
admin list
|
||||||
|
admin registration on
|
||||||
|
admin info smoketest
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" settings set registration_open false
|
||||||
|
ssh_mud newplayer@localhost <<'EOF'
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" settings set registration_open true
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players delete smoketest
|
||||||
|
(
|
||||||
|
echo "1"
|
||||||
|
echo "1"
|
||||||
|
echo "go south"
|
||||||
|
echo "go down"
|
||||||
|
echo "go south"
|
||||||
|
echo "attack thief"
|
||||||
|
sleep 8
|
||||||
|
echo "stats"
|
||||||
|
echo "quit"
|
||||||
|
) | ssh_mud smoketest@localhost
|
||||||
|
|
||||||
|
ssh_mud rpctest@localhost <<'EOF'
|
||||||
|
1
|
||||||
|
1
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo '{"_jsonrpc": "2.0", "method": "login", "params": {"username": "rpctest"}, "id": 1}' | nc -w 2 localhost 2223 > rpc_resp.json
|
||||||
|
echo '{"_jsonrpc": "2.0", "method": "list_commands", "params": {}, "id": 2}' | nc -w 2 localhost 2223 >> rpc_resp.json
|
||||||
|
|
||||||
|
if ! grep -q '"shop"' rpc_resp.json; then
|
||||||
|
echo "Error: 'shop' command missing from JSON-RPC list_commands"
|
||||||
|
cat rpc_resp.json
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm rpc_resp.json
|
||||||
|
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" settings set registration_open true
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players delete smoketest
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players delete rpctest
|
||||||
13
scripts/ci/wait-for-tcp.sh
Executable file
13
scripts/ci/wait-for-tcp.sh
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
host=${1:-127.0.0.1}
|
||||||
|
port=${2:-2222}
|
||||||
|
max_attempts=${3:-30}
|
||||||
|
for _ in $(seq 1 "$max_attempts"); do
|
||||||
|
if nc -z -w 1 "$host" "$port" 2>/dev/null; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "timeout waiting for $host:$port" >&2
|
||||||
|
exit 1
|
||||||
99
src/admin.rs
99
src/admin.rs
@@ -74,6 +74,7 @@ async fn admin_promote(target: &str, state: &SharedState) -> CommandResult {
|
|||||||
if target.is_empty() {
|
if target.is_empty() {
|
||||||
return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin promote <player>")));
|
return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin promote <player>")));
|
||||||
}
|
}
|
||||||
|
log::info!("Admin action: promote player '{}'", target);
|
||||||
let st = state.lock().await;
|
let st = state.lock().await;
|
||||||
if st.db.set_admin(target, true) {
|
if st.db.set_admin(target, true) {
|
||||||
// Also update in-memory if online
|
// Also update in-memory if online
|
||||||
@@ -89,14 +90,15 @@ async fn admin_promote(target: &str, state: &SharedState) -> CommandResult {
|
|||||||
)
|
)
|
||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
);
|
);
|
||||||
|
if let (Some(ch), Some(h)) = (conn.channel, &conn.handle) {
|
||||||
return CommandResult {
|
return CommandResult {
|
||||||
output: format!(
|
output: format!(
|
||||||
"{}\r\n",
|
"{}\r\n",
|
||||||
ansi::system_msg(&format!("{target} has been promoted to admin."))
|
ansi::system_msg(&format!("{target} has been promoted to admin."))
|
||||||
),
|
),
|
||||||
broadcasts: vec![BroadcastMsg {
|
broadcasts: vec![BroadcastMsg {
|
||||||
channel: conn.channel,
|
channel: ch,
|
||||||
handle: conn.handle.clone(),
|
handle: h.clone(),
|
||||||
data: msg,
|
data: msg,
|
||||||
}],
|
}],
|
||||||
kick_targets: Vec::new(),
|
kick_targets: Vec::new(),
|
||||||
@@ -104,6 +106,7 @@ async fn admin_promote(target: &str, state: &SharedState) -> CommandResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
simple(&format!(
|
simple(&format!(
|
||||||
"{}\r\n",
|
"{}\r\n",
|
||||||
ansi::system_msg(&format!(
|
ansi::system_msg(&format!(
|
||||||
@@ -122,6 +125,7 @@ async fn admin_demote(target: &str, state: &SharedState) -> CommandResult {
|
|||||||
if target.is_empty() {
|
if target.is_empty() {
|
||||||
return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin demote <player>")));
|
return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin demote <player>")));
|
||||||
}
|
}
|
||||||
|
log::info!("Admin action: demote player '{}'", target);
|
||||||
let st = state.lock().await;
|
let st = state.lock().await;
|
||||||
if st.db.set_admin(target, false) {
|
if st.db.set_admin(target, false) {
|
||||||
simple(&format!(
|
simple(&format!(
|
||||||
@@ -140,6 +144,7 @@ async fn admin_kick(target: &str, player_id: usize, state: &SharedState) -> Comm
|
|||||||
if target.is_empty() {
|
if target.is_empty() {
|
||||||
return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin kick <player>")));
|
return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin kick <player>")));
|
||||||
}
|
}
|
||||||
|
log::info!("Admin action: kick player '{}'", target);
|
||||||
let mut st = state.lock().await;
|
let mut st = state.lock().await;
|
||||||
let low = target.to_lowercase();
|
let low = target.to_lowercase();
|
||||||
|
|
||||||
@@ -188,18 +193,31 @@ async fn admin_kick(target: &str, player_id: usize, state: &SharedState) -> Comm
|
|||||||
let mut bcast: Vec<BroadcastMsg> = st
|
let mut bcast: Vec<BroadcastMsg> = st
|
||||||
.players_in_room(&room_id, player_id)
|
.players_in_room(&room_id, player_id)
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| BroadcastMsg {
|
.filter_map(|p| {
|
||||||
channel: p.channel,
|
if let (Some(ch), Some(h)) = (p.channel, &p.handle) {
|
||||||
handle: p.handle.clone(),
|
Some(BroadcastMsg {
|
||||||
|
channel: ch,
|
||||||
|
handle: h.clone(),
|
||||||
data: departure.clone(),
|
data: departure.clone(),
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
// Send kick message to the target before closing
|
// Send kick message to the target before closing
|
||||||
|
let mut kick_targets = Vec::new();
|
||||||
|
if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
|
||||||
bcast.push(BroadcastMsg {
|
bcast.push(BroadcastMsg {
|
||||||
channel: c.channel,
|
channel: ch,
|
||||||
handle: c.handle.clone(),
|
handle: h.clone(),
|
||||||
data: kick_msg,
|
data: kick_msg,
|
||||||
});
|
});
|
||||||
|
kick_targets.push(KickTarget {
|
||||||
|
channel: ch,
|
||||||
|
handle: h.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
CommandResult {
|
CommandResult {
|
||||||
output: format!(
|
output: format!(
|
||||||
@@ -207,10 +225,7 @@ async fn admin_kick(target: &str, player_id: usize, state: &SharedState) -> Comm
|
|||||||
ansi::system_msg(&format!("Kicked {name} from the server."))
|
ansi::system_msg(&format!("Kicked {name} from the server."))
|
||||||
),
|
),
|
||||||
broadcasts: bcast,
|
broadcasts: bcast,
|
||||||
kick_targets: vec![KickTarget {
|
kick_targets,
|
||||||
channel: c.channel,
|
|
||||||
handle: c.handle.clone(),
|
|
||||||
}],
|
|
||||||
quit: false,
|
quit: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,6 +240,7 @@ async fn admin_teleport(room_id: &str, player_id: usize, state: &SharedState) ->
|
|||||||
ansi::error_msg("Usage: admin teleport <room_id>")
|
ansi::error_msg("Usage: admin teleport <room_id>")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
log::info!("Admin action: teleport player ID {} to '{}'", player_id, room_id);
|
||||||
let mut st = state.lock().await;
|
let mut st = state.lock().await;
|
||||||
if st.world.get_room(room_id).is_none() {
|
if st.world.get_room(room_id).is_none() {
|
||||||
let rooms: Vec<&String> = st.world.rooms.keys().collect();
|
let rooms: Vec<&String> = st.world.rooms.keys().collect();
|
||||||
@@ -265,11 +281,17 @@ async fn admin_teleport(room_id: &str, player_id: usize, state: &SharedState) ->
|
|||||||
let mut bcast: Vec<BroadcastMsg> = st
|
let mut bcast: Vec<BroadcastMsg> = st
|
||||||
.players_in_room(&old_rid, player_id)
|
.players_in_room(&old_rid, player_id)
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| BroadcastMsg {
|
.filter_map(|c| {
|
||||||
channel: c.channel,
|
if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
|
||||||
handle: c.handle.clone(),
|
Some(BroadcastMsg {
|
||||||
|
channel: ch,
|
||||||
|
handle: h.clone(),
|
||||||
data: leave.clone(),
|
data: leave.clone(),
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if let Some(c) = st.players.get_mut(&player_id) {
|
if let Some(c) = st.players.get_mut(&player_id) {
|
||||||
@@ -286,12 +308,14 @@ async fn admin_teleport(room_id: &str, player_id: usize, state: &SharedState) ->
|
|||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
);
|
);
|
||||||
for c in st.players_in_room(room_id, player_id) {
|
for c in st.players_in_room(room_id, player_id) {
|
||||||
|
if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
|
||||||
bcast.push(BroadcastMsg {
|
bcast.push(BroadcastMsg {
|
||||||
channel: c.channel,
|
channel: ch,
|
||||||
handle: c.handle.clone(),
|
handle: h.clone(),
|
||||||
data: arrive.clone(),
|
data: arrive.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
st.save_player_to_db(player_id);
|
st.save_player_to_db(player_id);
|
||||||
let view = crate::commands::render_room_view(room_id, player_id, &st);
|
let view = crate::commands::render_room_view(room_id, player_id, &st);
|
||||||
@@ -309,6 +333,7 @@ async fn admin_teleport(room_id: &str, player_id: usize, state: &SharedState) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_registration(args: &str, state: &SharedState) -> CommandResult {
|
async fn admin_registration(args: &str, state: &SharedState) -> CommandResult {
|
||||||
|
log::info!("Admin action: registration setting updated: '{}'", args);
|
||||||
let st = state.lock().await;
|
let st = state.lock().await;
|
||||||
match args.to_lowercase().as_str() {
|
match args.to_lowercase().as_str() {
|
||||||
"on" | "true" | "open" => {
|
"on" | "true" | "open" => {
|
||||||
@@ -345,6 +370,7 @@ async fn admin_announce(msg: &str, player_id: usize, state: &SharedState) -> Com
|
|||||||
ansi::error_msg("Usage: admin announce <message>")
|
ansi::error_msg("Usage: admin announce <message>")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
log::info!("Admin action: announcement by player ID {}: '{}'", player_id, msg);
|
||||||
let st = state.lock().await;
|
let st = state.lock().await;
|
||||||
let announcement = CryptoVec::from(
|
let announcement = CryptoVec::from(
|
||||||
format!(
|
format!(
|
||||||
@@ -360,11 +386,17 @@ async fn admin_announce(msg: &str, player_id: usize, state: &SharedState) -> Com
|
|||||||
.players
|
.players
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(&id, _)| id != player_id)
|
.filter(|(&id, _)| id != player_id)
|
||||||
.map(|(_, c)| BroadcastMsg {
|
.filter_map(|(_, c)| {
|
||||||
channel: c.channel,
|
if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
|
||||||
handle: c.handle.clone(),
|
Some(BroadcastMsg {
|
||||||
|
channel: ch,
|
||||||
|
handle: h.clone(),
|
||||||
data: announcement.clone(),
|
data: announcement.clone(),
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
CommandResult {
|
CommandResult {
|
||||||
@@ -379,6 +411,7 @@ async fn admin_announce(msg: &str, player_id: usize, state: &SharedState) -> Com
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_heal(args: &str, player_id: usize, state: &SharedState) -> CommandResult {
|
async fn admin_heal(args: &str, player_id: usize, state: &SharedState) -> CommandResult {
|
||||||
|
log::info!("Admin action: heal player '{}' (empty means self)", args);
|
||||||
let mut st = state.lock().await;
|
let mut st = state.lock().await;
|
||||||
|
|
||||||
if args.is_empty() {
|
if args.is_empty() {
|
||||||
@@ -408,6 +441,8 @@ async fn admin_heal(args: &str, player_id: usize, state: &SharedState) -> Comman
|
|||||||
c.player.stats.hp = c.player.stats.max_hp;
|
c.player.stats.hp = c.player.stats.max_hp;
|
||||||
let name = c.player.name.clone();
|
let name = c.player.name.clone();
|
||||||
let hp = c.player.stats.max_hp;
|
let hp = c.player.stats.max_hp;
|
||||||
|
let mut bcast = Vec::new();
|
||||||
|
if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
|
||||||
let notify = CryptoVec::from(
|
let notify = CryptoVec::from(
|
||||||
format!(
|
format!(
|
||||||
"\r\n{}\r\n{}",
|
"\r\n{}\r\n{}",
|
||||||
@@ -416,11 +451,12 @@ async fn admin_heal(args: &str, player_id: usize, state: &SharedState) -> Comman
|
|||||||
)
|
)
|
||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
);
|
);
|
||||||
let bcast = vec![BroadcastMsg {
|
bcast.push(BroadcastMsg {
|
||||||
channel: c.channel,
|
channel: ch,
|
||||||
handle: c.handle.clone(),
|
handle: h.clone(),
|
||||||
data: notify,
|
data: notify,
|
||||||
}];
|
});
|
||||||
|
}
|
||||||
let _ = c;
|
let _ = c;
|
||||||
st.save_player_to_db(tid);
|
st.save_player_to_db(tid);
|
||||||
return CommandResult {
|
return CommandResult {
|
||||||
@@ -470,17 +506,15 @@ async fn admin_info(target: &str, state: &SharedState) -> CommandResult {
|
|||||||
s.hp, s.max_hp, s.attack, s.defense, s.level, s.xp, s.xp_to_next
|
s.hp, s.max_hp, s.attack, s.defense, s.level, s.xp, s.xp_to_next
|
||||||
));
|
));
|
||||||
out.push_str(&format!(" Room: {}\r\n", p.room_id));
|
out.push_str(&format!(" Room: {}\r\n", p.room_id));
|
||||||
|
let equipped_str = if p.equipped.is_empty() {
|
||||||
|
"none".to_string()
|
||||||
|
} else {
|
||||||
|
p.equipped.iter().map(|(s, o)| format!("{}={}", s, o.name)).collect::<Vec<_>>().join(", ")
|
||||||
|
};
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
" Inventory: {} item(s) | Weapon: {} | Armor: {}\r\n",
|
" Inventory: {} item(s) | Equipped: {}\r\n",
|
||||||
p.inventory.len(),
|
p.inventory.len(),
|
||||||
p.equipped_weapon
|
equipped_str,
|
||||||
.as_ref()
|
|
||||||
.map(|w| w.name.as_str())
|
|
||||||
.unwrap_or("none"),
|
|
||||||
p.equipped_armor
|
|
||||||
.as_ref()
|
|
||||||
.map(|a| a.name.as_str())
|
|
||||||
.unwrap_or("none"),
|
|
||||||
));
|
));
|
||||||
let attitudes = st.db.load_attitudes(&p.name);
|
let attitudes = st.db.load_attitudes(&p.name);
|
||||||
if !attitudes.is_empty() {
|
if !attitudes.is_empty() {
|
||||||
@@ -537,6 +571,7 @@ async fn admin_info(target: &str, state: &SharedState) -> CommandResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_setattitude(args: &str, state: &SharedState) -> CommandResult {
|
async fn admin_setattitude(args: &str, state: &SharedState) -> CommandResult {
|
||||||
|
log::info!("Admin action: setattitude '{}'", args);
|
||||||
let parts: Vec<&str> = args.splitn(3, ' ').collect();
|
let parts: Vec<&str> = args.splitn(3, ' ').collect();
|
||||||
if parts.len() < 3 {
|
if parts.len() < 3 {
|
||||||
return simple(&format!(
|
return simple(&format!(
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ use ratatui::prelude::*;
|
|||||||
use ratatui::widgets::*;
|
use ratatui::widgets::*;
|
||||||
|
|
||||||
use mudserver::db::{GameDb, NpcAttitudeRow, SavedPlayer, SqliteDb};
|
use mudserver::db::{GameDb, NpcAttitudeRow, SavedPlayer, SqliteDb};
|
||||||
use mudserver::world::Attitude;
|
use mudserver::world::{Attitude, World};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
let mut db_path = PathBuf::from("./mudserver.db");
|
let mut db_path = PathBuf::from("./mudserver.db");
|
||||||
|
let mut world_path = PathBuf::from("./world");
|
||||||
let mut cmd_args: Vec<String> = Vec::new();
|
let mut cmd_args: Vec<String> = Vec::new();
|
||||||
|
|
||||||
let mut i = 1;
|
let mut i = 1;
|
||||||
@@ -23,6 +24,10 @@ fn main() {
|
|||||||
i += 1;
|
i += 1;
|
||||||
db_path = PathBuf::from(args.get(i).expect("--db requires a path"));
|
db_path = PathBuf::from(args.get(i).expect("--db requires a path"));
|
||||||
}
|
}
|
||||||
|
"--world" | "-w" => {
|
||||||
|
i += 1;
|
||||||
|
world_path = PathBuf::from(args.get(i).expect("--world requires a path"));
|
||||||
|
}
|
||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
print_help();
|
print_help();
|
||||||
return;
|
return;
|
||||||
@@ -32,6 +37,16 @@ fn main() {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cmd_args.is_empty() {
|
||||||
|
print_help();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd_args[0] == "validate" {
|
||||||
|
cmd_validate(&world_path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let db = match SqliteDb::open(&db_path) {
|
let db = match SqliteDb::open(&db_path) {
|
||||||
Ok(db) => db,
|
Ok(db) => db,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -40,11 +55,6 @@ fn main() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if cmd_args.is_empty() {
|
|
||||||
print_help();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
match cmd_args[0].as_str() {
|
match cmd_args[0].as_str() {
|
||||||
"tui" => run_tui(db),
|
"tui" => run_tui(db),
|
||||||
"players" => cmd_players(&db, &cmd_args[1..]),
|
"players" => cmd_players(&db, &cmd_args[1..]),
|
||||||
@@ -60,9 +70,10 @@ fn main() {
|
|||||||
fn print_help() {
|
fn print_help() {
|
||||||
eprintln!("mudtool - MUD Server Database Manager");
|
eprintln!("mudtool - MUD Server Database Manager");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Usage: mudtool [--db <path>] <command> [args...]");
|
eprintln!("Usage: mudtool [OPTIONS] <command> [args...]");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Commands:");
|
eprintln!("Commands:");
|
||||||
|
eprintln!(" validate Validate world data (schemas, references, values)");
|
||||||
eprintln!(" tui Interactive TUI editor");
|
eprintln!(" tui Interactive TUI editor");
|
||||||
eprintln!(" players list List all players");
|
eprintln!(" players list List all players");
|
||||||
eprintln!(" players show <name> Show player details");
|
eprintln!(" players show <name> Show player details");
|
||||||
@@ -76,6 +87,98 @@ fn print_help() {
|
|||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Options:");
|
eprintln!("Options:");
|
||||||
eprintln!(" --db, -d <path> Database path (default: ./mudserver.db)");
|
eprintln!(" --db, -d <path> Database path (default: ./mudserver.db)");
|
||||||
|
eprintln!(" --world, -w <path> World directory for validate (default: ./world)");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_validate(world_path: &std::path::Path) {
|
||||||
|
let world = match World::load(world_path) {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Validation failed (load): {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
for npc in world.npcs.values() {
|
||||||
|
if !world.rooms.contains_key(&npc.room) {
|
||||||
|
errors.push(format!("NPC {} room '{}' does not exist", npc.id, npc.room));
|
||||||
|
}
|
||||||
|
if let Some(ref rid) = npc.fixed_race {
|
||||||
|
if !world.races.iter().any(|r| r.id == *rid) {
|
||||||
|
errors.push(format!("NPC {} race '{}' does not exist", npc.id, rid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref cid) = npc.fixed_class {
|
||||||
|
if !world.classes.iter().any(|c| c.id == *cid) {
|
||||||
|
errors.push(format!("NPC {} class '{}' does not exist", npc.id, cid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref c) = npc.combat {
|
||||||
|
if c.max_hp <= 0 || c.attack < 0 || c.defense < 0 || c.xp_reward < 0 {
|
||||||
|
errors.push(format!("NPC {} has invalid combat stats (hp>0, atk/def/xp>=0)", npc.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for obj in world.objects.values() {
|
||||||
|
if let Some(ref rid) = obj.room {
|
||||||
|
if !world.rooms.contains_key(rid) {
|
||||||
|
errors.push(format!("Object {} room '{}' does not exist", obj.id, rid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for guild in world.guilds.values() {
|
||||||
|
for sid in &guild.spells {
|
||||||
|
if !world.spells.contains_key(sid) {
|
||||||
|
errors.push(format!("Guild {} spell '{}' does not exist", guild.id, sid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for rid in &guild.race_restricted {
|
||||||
|
if !world.races.iter().any(|r| r.id == *rid) {
|
||||||
|
errors.push(format!("Guild {} race_restricted '{}' does not exist", guild.id, rid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if guild.resource != "mana" && guild.resource != "endurance" {
|
||||||
|
errors.push(format!("Guild {} resource '{}' must be 'mana' or 'endurance'", guild.id, guild.resource));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for class in &world.classes {
|
||||||
|
if let Some(ref gid) = class.guild {
|
||||||
|
if !world.guilds.contains_key(gid) {
|
||||||
|
errors.push(format!("Class {} guild '{}' does not exist", class.id, gid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for race in &world.races {
|
||||||
|
if let Some(ref cid) = race.default_class {
|
||||||
|
if !world.classes.iter().any(|c| c.id == *cid) {
|
||||||
|
errors.push(format!("Race {} default_class '{}' does not exist", race.id, cid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for spell in world.spells.values() {
|
||||||
|
if !["offensive", "heal", "utility"].contains(&spell.spell_type.as_str()) {
|
||||||
|
errors.push(format!("Spell {} spell_type '{}' must be offensive/heal/utility", spell.id, spell.spell_type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.is_empty() {
|
||||||
|
println!("World validation OK: {} rooms, {} npcs, {} objects, {} races, {} classes, {} guilds, {} spells",
|
||||||
|
world.rooms.len(), world.npcs.len(), world.objects.len(),
|
||||||
|
world.races.len(), world.classes.len(), world.guilds.len(), world.spells.len());
|
||||||
|
} else {
|
||||||
|
for e in &errors {
|
||||||
|
eprintln!("Error: {e}");
|
||||||
|
}
|
||||||
|
eprintln!("\n{} validation error(s)", errors.len());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ CLI Commands ============
|
// ============ CLI Commands ============
|
||||||
@@ -114,8 +217,7 @@ fn cmd_players(db: &SqliteDb, args: &[String]) {
|
|||||||
println!(" Room: {}", p.room_id);
|
println!(" Room: {}", p.room_id);
|
||||||
println!(" Admin: {}", p.is_admin);
|
println!(" Admin: {}", p.is_admin);
|
||||||
println!(" Inventory: {}", p.inventory_json);
|
println!(" Inventory: {}", p.inventory_json);
|
||||||
if let Some(ref w) = p.equipped_weapon_json { println!(" Weapon: {w}"); }
|
println!(" Equipped: {}", p.equipped_json);
|
||||||
if let Some(ref a) = p.equipped_armor_json { println!(" Armor: {a}"); }
|
|
||||||
let attitudes = db.load_attitudes(name);
|
let attitudes = db.load_attitudes(name);
|
||||||
if !attitudes.is_empty() {
|
if !attitudes.is_empty() {
|
||||||
println!(" Attitudes:");
|
println!(" Attitudes:");
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ impl ChargenState {
|
|||||||
"\r\n{}\r\n\r\n",
|
"\r\n{}\r\n\r\n",
|
||||||
ansi::bold("=== Choose Your Race ===")
|
ansi::bold("=== Choose Your Race ===")
|
||||||
));
|
));
|
||||||
for (i, race) in world.races.iter().enumerate() {
|
let visible_races: Vec<_> = world.races.iter().filter(|r| !r.hidden).collect();
|
||||||
|
for (i, race) in visible_races.iter().enumerate() {
|
||||||
let mods = format_stat_mods(&race.stats);
|
let mods = format_stat_mods(&race.stats);
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
" {}{}.{} {} {}\r\n {}\r\n",
|
" {}{}.{} {} {}\r\n {}\r\n",
|
||||||
@@ -44,6 +45,31 @@ impl ChargenState {
|
|||||||
},
|
},
|
||||||
ansi::color(ansi::DIM, &race.description),
|
ansi::color(ansi::DIM, &race.description),
|
||||||
));
|
));
|
||||||
|
let mut extras = Vec::new();
|
||||||
|
if race.size != "medium" {
|
||||||
|
extras.push(format!("Size: {}", race.size));
|
||||||
|
}
|
||||||
|
if !race.traits.is_empty() {
|
||||||
|
extras.push(format!("Traits: {}", race.traits.join(", ")));
|
||||||
|
}
|
||||||
|
if race.natural_armor > 0 {
|
||||||
|
extras.push(format!("Natural armor: {}", race.natural_armor));
|
||||||
|
}
|
||||||
|
if !race.natural_attacks.is_empty() {
|
||||||
|
let atks: Vec<String> = race.natural_attacks.iter()
|
||||||
|
.map(|a| format!("{} ({}dmg {})", a.name, a.damage, a.damage_type))
|
||||||
|
.collect();
|
||||||
|
extras.push(format!("Natural attacks: {}", atks.join(", ")));
|
||||||
|
}
|
||||||
|
if !race.vision.is_empty() {
|
||||||
|
extras.push(format!("Vision: {}", race.vision.join(", ")));
|
||||||
|
}
|
||||||
|
if !extras.is_empty() {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" {}\r\n",
|
||||||
|
ansi::color(ansi::DIM, &extras.join(" | "))
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"\r\n{}",
|
"\r\n{}",
|
||||||
@@ -57,9 +83,14 @@ impl ChargenState {
|
|||||||
"\r\n{}\r\n\r\n",
|
"\r\n{}\r\n\r\n",
|
||||||
ansi::bold("=== Choose Your Class ===")
|
ansi::bold("=== Choose Your Class ===")
|
||||||
));
|
));
|
||||||
for (i, class) in world.classes.iter().enumerate() {
|
let visible_classes: Vec<_> = world.classes.iter().filter(|c| !c.hidden).collect();
|
||||||
|
for (i, class) in visible_classes.iter().enumerate() {
|
||||||
|
let guild_info = class.guild.as_ref()
|
||||||
|
.and_then(|gid| world.guilds.get(gid))
|
||||||
|
.map(|g| format!(" → joins {}", ansi::color(ansi::YELLOW, &g.name)))
|
||||||
|
.unwrap_or_default();
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
" {}{}.{} {} {}\r\n {}\r\n {}HP:{} {}ATK:{} {}DEF:{}{}\r\n",
|
" {}{}.{} {} {}{}\r\n {}\r\n {}HP:{} {}ATK:{} {}DEF:{}{}\r\n",
|
||||||
ansi::BOLD,
|
ansi::BOLD,
|
||||||
i + 1,
|
i + 1,
|
||||||
ansi::RESET,
|
ansi::RESET,
|
||||||
@@ -70,6 +101,7 @@ impl ChargenState {
|
|||||||
class.growth.attack_per_level,
|
class.growth.attack_per_level,
|
||||||
class.growth.defense_per_level,
|
class.growth.defense_per_level,
|
||||||
)),
|
)),
|
||||||
|
guild_info,
|
||||||
ansi::color(ansi::DIM, &class.description),
|
ansi::color(ansi::DIM, &class.description),
|
||||||
ansi::GREEN,
|
ansi::GREEN,
|
||||||
class.base_stats.max_hp,
|
class.base_stats.max_hp,
|
||||||
@@ -96,7 +128,7 @@ impl ChargenState {
|
|||||||
ChargenStep::AwaitingRace => {
|
ChargenStep::AwaitingRace => {
|
||||||
let race = find_by_input(
|
let race = find_by_input(
|
||||||
input,
|
input,
|
||||||
&world.races.iter().map(|r| (r.id.clone(), r.name.clone())).collect::<Vec<_>>(),
|
&world.races.iter().filter(|r| !r.hidden).map(|r| (r.id.clone(), r.name.clone())).collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
match race {
|
match race {
|
||||||
Some((id, name)) => {
|
Some((id, name)) => {
|
||||||
@@ -119,6 +151,7 @@ impl ChargenState {
|
|||||||
&world
|
&world
|
||||||
.classes
|
.classes
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|c| !c.hidden)
|
||||||
.map(|c| (c.id.clone(), c.name.clone()))
|
.map(|c| (c.id.clone(), c.name.clone()))
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
@@ -179,6 +212,8 @@ fn format_stat_mods(stats: &crate::world::StatModifiers) -> String {
|
|||||||
("CON", stats.constitution),
|
("CON", stats.constitution),
|
||||||
("INT", stats.intelligence),
|
("INT", stats.intelligence),
|
||||||
("WIS", stats.wisdom),
|
("WIS", stats.wisdom),
|
||||||
|
("PER", stats.perception),
|
||||||
|
("CHA", stats.charisma),
|
||||||
];
|
];
|
||||||
for (label, val) in fields {
|
for (label, val) in fields {
|
||||||
if val != 0 {
|
if val != 0 {
|
||||||
|
|||||||
108
src/combat.rs
108
src/combat.rs
@@ -45,8 +45,8 @@ pub fn resolve_combat_tick(
|
|||||||
|
|
||||||
let npc_hp_before = instance.hp;
|
let npc_hp_before = instance.hp;
|
||||||
let conn = state.players.get(&player_id)?;
|
let conn = state.players.get(&player_id)?;
|
||||||
let p_atk = conn.player.effective_attack();
|
let p_atk = conn.player.effective_attack(&state.world);
|
||||||
let p_def = conn.player.effective_defense();
|
let p_def = conn.player.effective_defense(&state.world);
|
||||||
let _ = conn;
|
let _ = conn;
|
||||||
|
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
@@ -69,6 +69,8 @@ pub fn resolve_combat_tick(
|
|||||||
));
|
));
|
||||||
|
|
||||||
if new_npc_hp <= 0 {
|
if new_npc_hp <= 0 {
|
||||||
|
let player_name = state.players.get(&player_id).map(|c| c.player.name.clone()).unwrap_or_else(|| "Unknown".into());
|
||||||
|
log::info!(target: "{combat}", "Combat: Player '{}' (ID {}) killed NPC '{}' ({})", player_name, player_id, npc_template.name, npc_id);
|
||||||
if let Some(inst) = state.npc_instances.get_mut(&npc_id) {
|
if let Some(inst) = state.npc_instances.get_mut(&npc_id) {
|
||||||
inst.alive = false;
|
inst.alive = false;
|
||||||
inst.hp = 0;
|
inst.hp = 0;
|
||||||
@@ -76,17 +78,24 @@ pub fn resolve_combat_tick(
|
|||||||
}
|
}
|
||||||
npc_died = true;
|
npc_died = true;
|
||||||
xp_gained = npc_combat.xp_reward;
|
xp_gained = npc_combat.xp_reward;
|
||||||
|
let gold_gained = npc_template.gold;
|
||||||
|
let silver_gained = npc_template.silver;
|
||||||
|
let copper_gained = npc_template.copper;
|
||||||
|
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
" {} {} collapses! You gain {} XP.\r\n",
|
" {} {} collapses! You gain {} XP and {}g {}s {}c.\r\n",
|
||||||
ansi::color(ansi::GREEN, "**"),
|
ansi::color(ansi::GREEN, "**"),
|
||||||
ansi::color(ansi::RED, &npc_template.name),
|
ansi::color(ansi::RED, &npc_template.name),
|
||||||
ansi::bold(&xp_gained.to_string()),
|
ansi::bold(&xp_gained.to_string()),
|
||||||
|
gold_gained, silver_gained, copper_gained
|
||||||
));
|
));
|
||||||
|
|
||||||
if let Some(conn) = state.players.get_mut(&player_id) {
|
if let Some(conn) = state.players.get_mut(&player_id) {
|
||||||
conn.combat = None;
|
conn.combat = None;
|
||||||
conn.player.stats.xp += xp_gained;
|
conn.player.stats.xp += xp_gained;
|
||||||
|
conn.player.gold += gold_gained;
|
||||||
|
conn.player.silver += silver_gained;
|
||||||
|
conn.player.copper += copper_gained;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if let Some(inst) = state.npc_instances.get_mut(&npc_id) {
|
if let Some(inst) = state.npc_instances.get_mut(&npc_id) {
|
||||||
@@ -132,6 +141,95 @@ pub fn resolve_combat_tick(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
CombatAction::Cast(ref spell_id) => {
|
||||||
|
let spell = state.world.get_spell(spell_id).cloned();
|
||||||
|
if let Some(spell) = spell {
|
||||||
|
if let Some(conn) = state.players.get_mut(&player_id) {
|
||||||
|
if spell.cost_mana > conn.player.stats.mana {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" {} Not enough mana for {}!\r\n",
|
||||||
|
ansi::color(ansi::RED, "!!"), spell.name,
|
||||||
|
));
|
||||||
|
} else if spell.cost_endurance > conn.player.stats.endurance {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" {} Not enough endurance for {}!\r\n",
|
||||||
|
ansi::color(ansi::RED, "!!"), spell.name,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
conn.player.stats.mana -= spell.cost_mana;
|
||||||
|
conn.player.stats.endurance -= spell.cost_endurance;
|
||||||
|
if spell.cooldown_ticks > 0 {
|
||||||
|
conn.player.cooldowns.insert(spell_id.clone(), spell.cooldown_ticks);
|
||||||
|
}
|
||||||
|
|
||||||
|
if spell.spell_type == "heal" {
|
||||||
|
let old_hp = conn.player.stats.hp;
|
||||||
|
conn.player.stats.hp = (conn.player.stats.hp + spell.heal).min(conn.player.stats.max_hp);
|
||||||
|
let healed = conn.player.stats.hp - old_hp;
|
||||||
|
out.push_str(&format!(
|
||||||
|
" {} You cast {}! Restored {} HP.\r\n",
|
||||||
|
ansi::color(ansi::GREEN, "++"),
|
||||||
|
ansi::color(ansi::CYAN, &spell.name),
|
||||||
|
ansi::bold(&healed.to_string()),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
// Offensive spell
|
||||||
|
let spell_dmg = spell.damage + state.rng.next_range(1, 4);
|
||||||
|
let new_npc_hp = (npc_hp_before - spell_dmg).max(0);
|
||||||
|
out.push_str(&format!(
|
||||||
|
" {} You cast {} on {} for {} {} damage!\r\n",
|
||||||
|
ansi::color(ansi::MAGENTA, "**"),
|
||||||
|
ansi::color(ansi::CYAN, &spell.name),
|
||||||
|
ansi::color(ansi::RED, &npc_template.name),
|
||||||
|
ansi::bold(&spell_dmg.to_string()),
|
||||||
|
spell.damage_type,
|
||||||
|
));
|
||||||
|
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()),
|
||||||
|
));
|
||||||
|
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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply status effect from spell
|
||||||
|
if let Some(ref eff) = spell.effect {
|
||||||
|
if spell.spell_type == "offensive" {
|
||||||
|
// Could apply effect to NPC in future, for now just note it
|
||||||
|
} else {
|
||||||
|
let pname = conn.player.name.clone();
|
||||||
|
state.db.save_effect(&pname, eff, spell.effect_duration, spell.effect_magnitude);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" {} Spell not found.\r\n",
|
||||||
|
ansi::color(ansi::RED, "!!"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
CombatAction::UseItem(idx) => {
|
CombatAction::UseItem(idx) => {
|
||||||
if let Some(conn) = state.players.get_mut(&player_id) {
|
if let Some(conn) = state.players.get_mut(&player_id) {
|
||||||
if idx < conn.player.inventory.len() {
|
if idx < conn.player.inventory.len() {
|
||||||
@@ -255,7 +353,9 @@ pub fn player_death_respawn(player_id: usize, state: &mut GameState) -> String {
|
|||||||
.players
|
.players
|
||||||
.get(&player_id)
|
.get(&player_id)
|
||||||
.map(|c| c.player.name.clone())
|
.map(|c| c.player.name.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_else(|| "Unknown".into());
|
||||||
|
|
||||||
|
log::info!(target: "{combat}", "Combat: Player '{}' (ID {}) died and respawned at {}", player_name, player_id, spawn_room);
|
||||||
|
|
||||||
if let Some(conn) = state.players.get_mut(&player_id) {
|
if let Some(conn) = state.players.get_mut(&player_id) {
|
||||||
conn.player.stats.hp = conn.player.stats.max_hp;
|
conn.player.stats.hp = conn.player.stats.max_hp;
|
||||||
|
|||||||
1119
src/commands.rs
1119
src/commands.rs
File diff suppressed because it is too large
Load Diff
187
src/db.rs
187
src/db.rs
@@ -14,9 +14,15 @@ pub struct SavedPlayer {
|
|||||||
pub attack: i32,
|
pub attack: i32,
|
||||||
pub defense: i32,
|
pub defense: i32,
|
||||||
pub inventory_json: String,
|
pub inventory_json: String,
|
||||||
pub equipped_weapon_json: Option<String>,
|
pub equipped_json: String,
|
||||||
pub equipped_armor_json: Option<String>,
|
pub mana: i32,
|
||||||
|
pub max_mana: i32,
|
||||||
|
pub endurance: i32,
|
||||||
|
pub max_endurance: i32,
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
|
pub gold: i32,
|
||||||
|
pub silver: i32,
|
||||||
|
pub copper: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct NpcAttitudeRow {
|
pub struct NpcAttitudeRow {
|
||||||
@@ -51,6 +57,10 @@ pub trait GameDb: Send + Sync {
|
|||||||
fn load_all_effects(&self) -> Vec<StatusEffectRow>;
|
fn load_all_effects(&self) -> Vec<StatusEffectRow>;
|
||||||
fn tick_all_effects(&self) -> Vec<StatusEffectRow>;
|
fn tick_all_effects(&self) -> Vec<StatusEffectRow>;
|
||||||
fn clear_effects(&self, player_name: &str);
|
fn clear_effects(&self, player_name: &str);
|
||||||
|
|
||||||
|
fn load_guild_memberships(&self, player_name: &str) -> Vec<(String, i32)>;
|
||||||
|
fn save_guild_membership(&self, player_name: &str, guild_id: &str, level: i32);
|
||||||
|
fn remove_guild_membership(&self, player_name: &str, guild_id: &str);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SQLite implementation ---
|
// --- SQLite implementation ---
|
||||||
@@ -68,7 +78,7 @@ impl SqliteDb {
|
|||||||
.map_err(|e| format!("Failed to set pragmas: {e}"))?;
|
.map_err(|e| format!("Failed to set pragmas: {e}"))?;
|
||||||
|
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"CREATE TABLE IF NOT EXISTS players (
|
r#"CREATE TABLE IF NOT EXISTS players (
|
||||||
name TEXT PRIMARY KEY,
|
name TEXT PRIMARY KEY,
|
||||||
race_id TEXT NOT NULL,
|
race_id TEXT NOT NULL,
|
||||||
class_id TEXT NOT NULL,
|
class_id TEXT NOT NULL,
|
||||||
@@ -79,12 +89,19 @@ impl SqliteDb {
|
|||||||
max_hp INTEGER NOT NULL,
|
max_hp INTEGER NOT NULL,
|
||||||
attack INTEGER NOT NULL,
|
attack INTEGER NOT NULL,
|
||||||
defense INTEGER NOT NULL,
|
defense INTEGER NOT NULL,
|
||||||
inventory_json TEXT NOT NULL DEFAULT '[]',
|
inventory_json TEXT NOT NULL DEFAULT "[]",
|
||||||
equipped_weapon_json TEXT,
|
equipped_json TEXT NOT NULL DEFAULT "{}",
|
||||||
equipped_armor_json TEXT,
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
is_admin INTEGER NOT NULL DEFAULT 0
|
mana INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_mana INTEGER NOT NULL DEFAULT 0,
|
||||||
|
endurance INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_endurance INTEGER NOT NULL DEFAULT 0,
|
||||||
|
gold INTEGER NOT NULL DEFAULT 0,
|
||||||
|
silver INTEGER NOT NULL DEFAULT 0,
|
||||||
|
copper INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS npc_attitudes (
|
CREATE TABLE IF NOT EXISTS npc_attitudes (
|
||||||
player_name TEXT NOT NULL,
|
player_name TEXT NOT NULL,
|
||||||
npc_id TEXT NOT NULL,
|
npc_id TEXT NOT NULL,
|
||||||
@@ -103,13 +120,20 @@ impl SqliteDb {
|
|||||||
remaining_ticks INTEGER NOT NULL,
|
remaining_ticks INTEGER NOT NULL,
|
||||||
magnitude INTEGER NOT NULL DEFAULT 0,
|
magnitude INTEGER NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (player_name, kind)
|
PRIMARY KEY (player_name, kind)
|
||||||
);",
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS player_guilds (
|
||||||
|
player_name TEXT NOT NULL,
|
||||||
|
guild_id TEXT NOT NULL,
|
||||||
|
level INTEGER NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (player_name, guild_id)
|
||||||
|
);"#,
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("Failed to create tables: {e}"))?;
|
.map_err(|e| format!("Failed to create tables: {e}"))?;
|
||||||
|
|
||||||
// Migration: add is_admin column if missing
|
// Migration: add is_admin column if missing
|
||||||
let has_admin: bool = conn
|
let has_admin: bool = conn
|
||||||
.prepare("SELECT COUNT(*) FROM pragma_table_info('players') WHERE name='is_admin'")
|
.prepare(r#"SELECT COUNT(*) FROM pragma_table_info("players") WHERE name="is_admin""#)
|
||||||
.and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0)))
|
.and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0)))
|
||||||
.map(|c| c > 0)
|
.map(|c| c > 0)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
@@ -120,6 +144,58 @@ impl SqliteDb {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: equipped_weapon_json/equipped_armor_json -> equipped_json
|
||||||
|
let has_old_weapon: bool = conn
|
||||||
|
.prepare(r#"SELECT COUNT(*) FROM pragma_table_info("players") WHERE name="equipped_weapon_json""#)
|
||||||
|
.and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0)))
|
||||||
|
.map(|c| c > 0)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let has_equipped: bool = conn
|
||||||
|
.prepare(r#"SELECT COUNT(*) FROM pragma_table_info("players") WHERE name="equipped_json""#)
|
||||||
|
.and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0)))
|
||||||
|
.map(|c| c > 0)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if has_old_weapon && !has_equipped {
|
||||||
|
let _ = conn.execute(
|
||||||
|
r#"ALTER TABLE players ADD COLUMN equipped_json TEXT NOT NULL DEFAULT "{}"#,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
log::info!("Migrating equipped_weapon_json/equipped_armor_json to equipped_json...");
|
||||||
|
let _ = conn.execute_batch(
|
||||||
|
r#"UPDATE players SET equipped_json = "{}" WHERE equipped_weapon_json IS NULL AND equipped_armor_json IS NULL;"#
|
||||||
|
);
|
||||||
|
} else if !has_equipped {
|
||||||
|
let _ = conn.execute(
|
||||||
|
r#"ALTER TABLE players ADD COLUMN equipped_json TEXT NOT NULL DEFAULT "{}"#,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: add mana/endurance columns
|
||||||
|
let has_mana: bool = conn
|
||||||
|
.prepare(r#"SELECT COUNT(*) FROM pragma_table_info("players") WHERE name="mana""#)
|
||||||
|
.and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0)))
|
||||||
|
.map(|c| c > 0)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !has_mana {
|
||||||
|
let _ = conn.execute("ALTER TABLE players ADD COLUMN mana INTEGER NOT NULL DEFAULT 0", []);
|
||||||
|
let _ = conn.execute("ALTER TABLE players ADD COLUMN max_mana INTEGER NOT NULL DEFAULT 0", []);
|
||||||
|
let _ = conn.execute("ALTER TABLE players ADD COLUMN endurance INTEGER NOT NULL DEFAULT 0", []);
|
||||||
|
let _ = conn.execute("ALTER TABLE players ADD COLUMN max_endurance INTEGER NOT NULL DEFAULT 0", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: add currency columns
|
||||||
|
let has_gold: bool = conn
|
||||||
|
.prepare(r#"SELECT COUNT(*) FROM pragma_table_info("players") WHERE name="gold""#)
|
||||||
|
.and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0)))
|
||||||
|
.map(|c| c > 0)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !has_gold {
|
||||||
|
let _ = conn.execute("ALTER TABLE players ADD COLUMN gold INTEGER NOT NULL DEFAULT 0", []);
|
||||||
|
let _ = conn.execute("ALTER TABLE players ADD COLUMN silver INTEGER NOT NULL DEFAULT 0", []);
|
||||||
|
let _ = conn.execute("ALTER TABLE players ADD COLUMN copper INTEGER NOT NULL DEFAULT 0", []);
|
||||||
|
}
|
||||||
|
|
||||||
log::info!("Database opened: {}", path.display());
|
log::info!("Database opened: {}", path.display());
|
||||||
Ok(SqliteDb {
|
Ok(SqliteDb {
|
||||||
conn: std::sync::Mutex::new(conn),
|
conn: std::sync::Mutex::new(conn),
|
||||||
@@ -132,8 +208,8 @@ impl GameDb for SqliteDb {
|
|||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
||||||
attack, defense, inventory_json, equipped_weapon_json,
|
attack, defense, inventory_json, equipped_json, is_admin,
|
||||||
equipped_armor_json, is_admin
|
mana, max_mana, endurance, max_endurance, gold, silver, copper
|
||||||
FROM players WHERE name = ?1",
|
FROM players WHERE name = ?1",
|
||||||
[name],
|
[name],
|
||||||
|row| {
|
|row| {
|
||||||
@@ -149,9 +225,15 @@ impl GameDb for SqliteDb {
|
|||||||
attack: row.get(8)?,
|
attack: row.get(8)?,
|
||||||
defense: row.get(9)?,
|
defense: row.get(9)?,
|
||||||
inventory_json: row.get(10)?,
|
inventory_json: row.get(10)?,
|
||||||
equipped_weapon_json: row.get(11)?,
|
equipped_json: row.get::<_, String>(11).unwrap_or_else(|_| "{}".into()),
|
||||||
equipped_armor_json: row.get(12)?,
|
is_admin: row.get::<_, i32>(12)? != 0,
|
||||||
is_admin: row.get::<_, i32>(13)? != 0,
|
mana: row.get::<_, i32>(13).unwrap_or(0),
|
||||||
|
max_mana: row.get::<_, i32>(14).unwrap_or(0),
|
||||||
|
endurance: row.get::<_, i32>(15).unwrap_or(0),
|
||||||
|
max_endurance: row.get::<_, i32>(16).unwrap_or(0),
|
||||||
|
gold: row.get::<_, i32>(17).unwrap_or(0),
|
||||||
|
silver: row.get::<_, i32>(18).unwrap_or(0),
|
||||||
|
copper: row.get::<_, i32>(19).unwrap_or(0),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -162,31 +244,23 @@ impl GameDb for SqliteDb {
|
|||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let _ = conn.execute(
|
let _ = conn.execute(
|
||||||
"INSERT INTO players (name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
"INSERT INTO players (name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
||||||
attack, defense, inventory_json, equipped_weapon_json,
|
attack, defense, inventory_json, equipped_json, is_admin,
|
||||||
equipped_armor_json, is_admin)
|
mana, max_mana, endurance, max_endurance, gold, silver, copper)
|
||||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)
|
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20)
|
||||||
ON CONFLICT(name) DO UPDATE SET
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
room_id=excluded.room_id, level=excluded.level, xp=excluded.xp,
|
room_id=excluded.room_id, level=excluded.level, xp=excluded.xp,
|
||||||
hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack,
|
hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack,
|
||||||
defense=excluded.defense, inventory_json=excluded.inventory_json,
|
defense=excluded.defense, inventory_json=excluded.inventory_json,
|
||||||
equipped_weapon_json=excluded.equipped_weapon_json,
|
equipped_json=excluded.equipped_json, is_admin=excluded.is_admin,
|
||||||
equipped_armor_json=excluded.equipped_armor_json,
|
mana=excluded.mana, max_mana=excluded.max_mana,
|
||||||
is_admin=excluded.is_admin",
|
endurance=excluded.endurance, max_endurance=excluded.max_endurance,
|
||||||
|
gold=excluded.gold, silver=excluded.silver, copper=excluded.copper",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
p.name,
|
p.name, p.race_id, p.class_id, p.room_id, p.level, p.xp,
|
||||||
p.race_id,
|
p.hp, p.max_hp, p.attack, p.defense,
|
||||||
p.class_id,
|
p.inventory_json, p.equipped_json, p.is_admin as i32,
|
||||||
p.room_id,
|
p.mana, p.max_mana, p.endurance, p.max_endurance,
|
||||||
p.level,
|
p.gold, p.silver, p.copper,
|
||||||
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,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -196,6 +270,7 @@ impl GameDb for SqliteDb {
|
|||||||
let _ = conn.execute("DELETE FROM players WHERE name = ?1", [name]);
|
let _ = conn.execute("DELETE FROM players WHERE name = ?1", [name]);
|
||||||
let _ = conn.execute("DELETE FROM npc_attitudes WHERE player_name = ?1", [name]);
|
let _ = conn.execute("DELETE FROM npc_attitudes WHERE player_name = ?1", [name]);
|
||||||
let _ = conn.execute("DELETE FROM status_effects WHERE player_name = ?1", [name]);
|
let _ = conn.execute("DELETE FROM status_effects WHERE player_name = ?1", [name]);
|
||||||
|
let _ = conn.execute("DELETE FROM player_guilds WHERE player_name = ?1", [name]);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_admin(&self, name: &str, is_admin: bool) -> bool {
|
fn set_admin(&self, name: &str, is_admin: bool) -> bool {
|
||||||
@@ -214,8 +289,8 @@ impl GameDb for SqliteDb {
|
|||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
||||||
attack, defense, inventory_json, equipped_weapon_json,
|
attack, defense, inventory_json, equipped_json, is_admin,
|
||||||
equipped_armor_json, is_admin
|
mana, max_mana, endurance, max_endurance, gold, silver, copper
|
||||||
FROM players ORDER BY name",
|
FROM players ORDER BY name",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -232,9 +307,15 @@ impl GameDb for SqliteDb {
|
|||||||
attack: row.get(8)?,
|
attack: row.get(8)?,
|
||||||
defense: row.get(9)?,
|
defense: row.get(9)?,
|
||||||
inventory_json: row.get(10)?,
|
inventory_json: row.get(10)?,
|
||||||
equipped_weapon_json: row.get(11)?,
|
equipped_json: row.get::<_, String>(11).unwrap_or_else(|_| "{}".into()),
|
||||||
equipped_armor_json: row.get(12)?,
|
is_admin: row.get::<_, i32>(12)? != 0,
|
||||||
is_admin: row.get::<_, i32>(13)? != 0,
|
mana: row.get::<_, i32>(13).unwrap_or(0),
|
||||||
|
max_mana: row.get::<_, i32>(14).unwrap_or(0),
|
||||||
|
endurance: row.get::<_, i32>(15).unwrap_or(0),
|
||||||
|
max_endurance: row.get::<_, i32>(16).unwrap_or(0),
|
||||||
|
gold: row.get::<_, i32>(17).unwrap_or(0),
|
||||||
|
silver: row.get::<_, i32>(18).unwrap_or(0),
|
||||||
|
copper: row.get::<_, i32>(19).unwrap_or(0),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -379,4 +460,32 @@ impl GameDb for SqliteDb {
|
|||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let _ = conn.execute("DELETE FROM status_effects WHERE player_name = ?1", [player_name]);
|
let _ = conn.execute("DELETE FROM status_effects WHERE player_name = ?1", [player_name]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn load_guild_memberships(&self, player_name: &str) -> Vec<(String, i32)> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT guild_id, level FROM player_guilds WHERE player_name = ?1")
|
||||||
|
.unwrap();
|
||||||
|
stmt.query_map([player_name], |row| Ok((row.get(0)?, row.get(1)?)))
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_guild_membership(&self, player_name: &str, guild_id: &str, level: i32) {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let _ = conn.execute(
|
||||||
|
"INSERT INTO player_guilds (player_name, guild_id, level) VALUES (?1, ?2, ?3)
|
||||||
|
ON CONFLICT(player_name, guild_id) DO UPDATE SET level=excluded.level",
|
||||||
|
rusqlite::params![player_name, guild_id, level],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_guild_membership(&self, player_name: &str, guild_id: &str) {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let _ = conn.execute(
|
||||||
|
"DELETE FROM player_guilds WHERE player_name = ?1 AND guild_id = ?2",
|
||||||
|
rusqlite::params![player_name, guild_id],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
299
src/game.rs
299
src/game.rs
@@ -7,7 +7,36 @@ use russh::server::Handle;
|
|||||||
use russh::ChannelId;
|
use russh::ChannelId;
|
||||||
|
|
||||||
use crate::db::{GameDb, SavedPlayer};
|
use crate::db::{GameDb, SavedPlayer};
|
||||||
use crate::world::{Attitude, Object, World};
|
use crate::world::{Attitude, Class, Object, Race, World};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum WeatherKind {
|
||||||
|
Clear,
|
||||||
|
Cloudy,
|
||||||
|
Rain,
|
||||||
|
Storm,
|
||||||
|
Snow,
|
||||||
|
Fog,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WeatherKind {
|
||||||
|
pub fn description(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
WeatherKind::Clear => "The sky is clear.",
|
||||||
|
WeatherKind::Cloudy => "The sky is overcast with clouds.",
|
||||||
|
WeatherKind::Rain => "It is raining.",
|
||||||
|
WeatherKind::Storm => "A powerful storm is raging.",
|
||||||
|
WeatherKind::Snow => "Soft snowflakes are falling from the sky.",
|
||||||
|
WeatherKind::Fog => "A thick fog blankets the area.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WeatherState {
|
||||||
|
pub kind: WeatherKind,
|
||||||
|
pub remaining_ticks: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct PlayerStats {
|
pub struct PlayerStats {
|
||||||
@@ -18,6 +47,10 @@ pub struct PlayerStats {
|
|||||||
pub level: i32,
|
pub level: i32,
|
||||||
pub xp: i32,
|
pub xp: i32,
|
||||||
pub xp_to_next: i32,
|
pub xp_to_next: i32,
|
||||||
|
pub max_mana: i32,
|
||||||
|
pub mana: i32,
|
||||||
|
pub max_endurance: i32,
|
||||||
|
pub endurance: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Player {
|
pub struct Player {
|
||||||
@@ -27,28 +60,48 @@ pub struct Player {
|
|||||||
pub room_id: String,
|
pub room_id: String,
|
||||||
pub stats: PlayerStats,
|
pub stats: PlayerStats,
|
||||||
pub inventory: Vec<Object>,
|
pub inventory: Vec<Object>,
|
||||||
pub equipped_weapon: Option<Object>,
|
pub equipped: HashMap<String, Object>,
|
||||||
pub equipped_armor: Option<Object>,
|
pub guilds: HashMap<String, i32>,
|
||||||
|
pub cooldowns: HashMap<String, i32>,
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
|
pub gold: i32,
|
||||||
|
pub silver: i32,
|
||||||
|
pub copper: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Player {
|
impl Player {
|
||||||
pub fn effective_attack(&self) -> i32 {
|
pub fn equipped_in_slot(&self, slot: &str) -> Option<&Object> {
|
||||||
let bonus = self
|
self.equipped.get(slot)
|
||||||
.equipped_weapon
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|w| w.stats.damage)
|
|
||||||
.unwrap_or(0);
|
|
||||||
self.stats.attack + bonus
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn effective_defense(&self) -> i32 {
|
pub fn total_equipped_damage(&self) -> i32 {
|
||||||
let bonus = self
|
self.equipped.values()
|
||||||
.equipped_armor
|
.filter_map(|o| o.stats.damage)
|
||||||
.as_ref()
|
.sum()
|
||||||
.and_then(|a| a.stats.armor)
|
}
|
||||||
|
|
||||||
|
pub fn total_equipped_armor(&self) -> i32 {
|
||||||
|
self.equipped.values()
|
||||||
|
.filter_map(|o| o.stats.armor)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn effective_attack(&self, world: &World) -> i32 {
|
||||||
|
let race_natural = world.races.iter()
|
||||||
|
.find(|r| r.id == self.race_id)
|
||||||
|
.map(|r| r.natural_attacks.iter().map(|a| a.damage).max().unwrap_or(0))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
self.stats.defense + bonus
|
let weapon_bonus = self.total_equipped_damage();
|
||||||
|
let unarmed_or_weapon = weapon_bonus.max(race_natural);
|
||||||
|
self.stats.attack + unarmed_or_weapon
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn effective_defense(&self, world: &World) -> i32 {
|
||||||
|
let race_natural = world.races.iter()
|
||||||
|
.find(|r| r.id == self.race_id)
|
||||||
|
.map(|r| r.natural_armor)
|
||||||
|
.unwrap_or(0);
|
||||||
|
self.stats.defense + self.total_equipped_armor() + race_natural
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +111,7 @@ pub enum CombatAction {
|
|||||||
Defend,
|
Defend,
|
||||||
Flee,
|
Flee,
|
||||||
UseItem(usize),
|
UseItem(usize),
|
||||||
|
Cast(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct CombatState {
|
pub struct CombatState {
|
||||||
@@ -70,12 +124,14 @@ pub struct NpcInstance {
|
|||||||
pub hp: i32,
|
pub hp: i32,
|
||||||
pub alive: bool,
|
pub alive: bool,
|
||||||
pub death_time: Option<Instant>,
|
pub death_time: Option<Instant>,
|
||||||
|
pub race_id: String,
|
||||||
|
pub class_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PlayerConnection {
|
pub struct PlayerConnection {
|
||||||
pub player: Player,
|
pub player: Player,
|
||||||
pub channel: ChannelId,
|
pub channel: Option<ChannelId>,
|
||||||
pub handle: Handle,
|
pub handle: Option<Handle>,
|
||||||
pub combat: Option<CombatState>,
|
pub combat: Option<CombatState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,36 +171,95 @@ pub struct GameState {
|
|||||||
pub npc_instances: HashMap<String, NpcInstance>,
|
pub npc_instances: HashMap<String, NpcInstance>,
|
||||||
pub rng: XorShift64,
|
pub rng: XorShift64,
|
||||||
pub tick_count: u64,
|
pub tick_count: u64,
|
||||||
|
pub weather: WeatherState,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type SharedState = Arc<Mutex<GameState>>;
|
pub type SharedState = Arc<Mutex<GameState>>;
|
||||||
|
|
||||||
|
pub fn resolve_npc_race_class(
|
||||||
|
fixed_race: &Option<String>,
|
||||||
|
fixed_class: &Option<String>,
|
||||||
|
world: &World,
|
||||||
|
rng: &mut XorShift64,
|
||||||
|
) -> (String, String) {
|
||||||
|
let race_id = match fixed_race {
|
||||||
|
Some(rid) if world.races.iter().any(|r| r.id == *rid) => rid.clone(),
|
||||||
|
_ => {
|
||||||
|
// Pick a random non-hidden race
|
||||||
|
let candidates: Vec<&Race> = world.races.iter().filter(|r| !r.hidden).collect();
|
||||||
|
if candidates.is_empty() {
|
||||||
|
world.races.first().map(|r| r.id.clone()).unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
let len = candidates.len() as i32;
|
||||||
|
let idx = rng.next_range(0, len.saturating_sub(1)) as usize;
|
||||||
|
candidates[idx].id.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let class_id = match fixed_class {
|
||||||
|
Some(cid) if world.classes.iter().any(|c| c.id == *cid) => cid.clone(),
|
||||||
|
_ => {
|
||||||
|
let race = world.races.iter().find(|r| r.id == race_id);
|
||||||
|
// Try race default_class first
|
||||||
|
if let Some(ref dc) = race.and_then(|r| r.default_class.clone()) {
|
||||||
|
if world.classes.iter().any(|c| c.id == *dc) {
|
||||||
|
return (race_id, dc.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No default → pick random non-hidden class compatible with race
|
||||||
|
let restricted = race
|
||||||
|
.map(|r| &r.guild_compatibility.restricted)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let candidates: Vec<&Class> = world.classes.iter()
|
||||||
|
.filter(|c| !c.hidden)
|
||||||
|
.filter(|c| {
|
||||||
|
c.guild.as_ref().map(|gid| !restricted.contains(gid)).unwrap_or(true)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if candidates.is_empty() {
|
||||||
|
world.classes.first().map(|c| c.id.clone()).unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
let len = candidates.len() as i32;
|
||||||
|
let idx = rng.next_range(0, len.saturating_sub(1)) as usize;
|
||||||
|
candidates[idx].id.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(race_id, class_id)
|
||||||
|
}
|
||||||
|
|
||||||
impl GameState {
|
impl GameState {
|
||||||
pub fn new(world: World, db: Arc<dyn GameDb>) -> Self {
|
pub fn new(world: World, db: Arc<dyn GameDb>) -> Self {
|
||||||
let mut npc_instances = HashMap::new();
|
|
||||||
for npc in world.npcs.values() {
|
|
||||||
if let Some(ref combat) = npc.combat {
|
|
||||||
npc_instances.insert(
|
|
||||||
npc.id.clone(),
|
|
||||||
NpcInstance {
|
|
||||||
hp: combat.max_hp,
|
|
||||||
alive: true,
|
|
||||||
death_time: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let seed = std::time::SystemTime::now()
|
let seed = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_nanos() as u64;
|
.as_nanos() as u64;
|
||||||
|
let mut rng = XorShift64::new(seed);
|
||||||
|
let mut npc_instances = HashMap::new();
|
||||||
|
for npc in world.npcs.values() {
|
||||||
|
let (race_id, class_id) = resolve_npc_race_class(
|
||||||
|
&npc.fixed_race, &npc.fixed_class, &world, &mut rng,
|
||||||
|
);
|
||||||
|
let hp = npc.combat.as_ref().map(|c| c.max_hp).unwrap_or(20);
|
||||||
|
npc_instances.insert(
|
||||||
|
npc.id.clone(),
|
||||||
|
NpcInstance { hp, alive: true, death_time: None, race_id, class_id },
|
||||||
|
);
|
||||||
|
}
|
||||||
GameState {
|
GameState {
|
||||||
world,
|
world,
|
||||||
db,
|
db,
|
||||||
players: HashMap::new(),
|
players: HashMap::new(),
|
||||||
npc_instances,
|
npc_instances,
|
||||||
rng: XorShift64::new(seed),
|
rng,
|
||||||
tick_count: 0,
|
tick_count: 0,
|
||||||
|
weather: WeatherState {
|
||||||
|
kind: WeatherKind::Clear,
|
||||||
|
remaining_ticks: 100,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,8 +314,8 @@ impl GameState {
|
|||||||
name: String,
|
name: String,
|
||||||
race_id: String,
|
race_id: String,
|
||||||
class_id: String,
|
class_id: String,
|
||||||
channel: ChannelId,
|
channel: Option<ChannelId>,
|
||||||
handle: Handle,
|
handle: Option<Handle>,
|
||||||
) {
|
) {
|
||||||
let room_id = self.world.spawn_room.clone();
|
let room_id = self.world.spawn_room.clone();
|
||||||
let race = self.world.races.iter().find(|r| r.id == race_id);
|
let race = self.world.races.iter().find(|r| r.id == race_id);
|
||||||
@@ -219,6 +334,25 @@ impl GameState {
|
|||||||
let attack = base_atk + str_mod + dex_mod / 2;
|
let attack = base_atk + str_mod + dex_mod / 2;
|
||||||
let defense = base_def + con_mod / 2;
|
let defense = base_def + con_mod / 2;
|
||||||
|
|
||||||
|
// Compute starting mana/endurance from initial guild
|
||||||
|
let initial_guild_id = class.and_then(|c| c.guild.clone());
|
||||||
|
let (base_mana, base_endurance) = initial_guild_id.as_ref()
|
||||||
|
.and_then(|gid| self.world.guilds.get(gid))
|
||||||
|
.map(|g| (g.base_mana, g.base_endurance))
|
||||||
|
.unwrap_or((0, 0));
|
||||||
|
let int_mod = race.map(|r| r.stats.intelligence).unwrap_or(0);
|
||||||
|
let wis_mod = race.map(|r| r.stats.wisdom).unwrap_or(0);
|
||||||
|
let max_mana = base_mana + int_mod * 5 + wis_mod * 3;
|
||||||
|
let max_endurance = base_endurance + con_mod * 3 + str_mod * 2;
|
||||||
|
|
||||||
|
let mut guilds = HashMap::new();
|
||||||
|
if let Some(ref gid) = initial_guild_id {
|
||||||
|
if self.world.guilds.contains_key(gid) {
|
||||||
|
guilds.insert(gid.clone(), 1);
|
||||||
|
self.db.save_guild_membership(&name, gid, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let stats = PlayerStats {
|
let stats = PlayerStats {
|
||||||
max_hp,
|
max_hp,
|
||||||
hp: max_hp,
|
hp: max_hp,
|
||||||
@@ -227,6 +361,10 @@ impl GameState {
|
|||||||
level: 1,
|
level: 1,
|
||||||
xp: 0,
|
xp: 0,
|
||||||
xp_to_next: 100,
|
xp_to_next: 100,
|
||||||
|
max_mana,
|
||||||
|
mana: max_mana,
|
||||||
|
max_endurance,
|
||||||
|
endurance: max_endurance,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.players.insert(
|
self.players.insert(
|
||||||
@@ -239,9 +377,13 @@ impl GameState {
|
|||||||
room_id,
|
room_id,
|
||||||
stats,
|
stats,
|
||||||
inventory: Vec::new(),
|
inventory: Vec::new(),
|
||||||
equipped_weapon: None,
|
equipped: HashMap::new(),
|
||||||
equipped_armor: None,
|
guilds,
|
||||||
|
cooldowns: HashMap::new(),
|
||||||
is_admin: false,
|
is_admin: false,
|
||||||
|
gold: 0,
|
||||||
|
silver: 0,
|
||||||
|
copper: 10, // Start with some copper
|
||||||
},
|
},
|
||||||
channel,
|
channel,
|
||||||
handle,
|
handle,
|
||||||
@@ -254,19 +396,13 @@ impl GameState {
|
|||||||
&mut self,
|
&mut self,
|
||||||
id: usize,
|
id: usize,
|
||||||
saved: SavedPlayer,
|
saved: SavedPlayer,
|
||||||
channel: ChannelId,
|
channel: Option<ChannelId>,
|
||||||
handle: Handle,
|
handle: Option<Handle>,
|
||||||
) {
|
) {
|
||||||
let inventory: Vec<Object> =
|
let inventory: Vec<Object> =
|
||||||
serde_json::from_str(&saved.inventory_json).unwrap_or_default();
|
serde_json::from_str(&saved.inventory_json).unwrap_or_default();
|
||||||
let equipped_weapon: Option<Object> = saved
|
let equipped: HashMap<String, Object> =
|
||||||
.equipped_weapon_json
|
serde_json::from_str(&saved.equipped_json).unwrap_or_default();
|
||||||
.as_deref()
|
|
||||||
.and_then(|j| serde_json::from_str(j).ok());
|
|
||||||
let equipped_armor: Option<Object> = saved
|
|
||||||
.equipped_armor_json
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|j| serde_json::from_str(j).ok());
|
|
||||||
|
|
||||||
let room_id = if self.world.rooms.contains_key(&saved.room_id) {
|
let room_id = if self.world.rooms.contains_key(&saved.room_id) {
|
||||||
saved.room_id
|
saved.room_id
|
||||||
@@ -274,6 +410,9 @@ impl GameState {
|
|||||||
self.world.spawn_room.clone()
|
self.world.spawn_room.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let guilds_vec = self.db.load_guild_memberships(&saved.name);
|
||||||
|
let guilds: HashMap<String, i32> = guilds_vec.into_iter().collect();
|
||||||
|
|
||||||
let stats = PlayerStats {
|
let stats = PlayerStats {
|
||||||
max_hp: saved.max_hp,
|
max_hp: saved.max_hp,
|
||||||
hp: saved.hp,
|
hp: saved.hp,
|
||||||
@@ -282,6 +421,10 @@ impl GameState {
|
|||||||
level: saved.level,
|
level: saved.level,
|
||||||
xp: saved.xp,
|
xp: saved.xp,
|
||||||
xp_to_next: saved.level * 100,
|
xp_to_next: saved.level * 100,
|
||||||
|
max_mana: saved.max_mana,
|
||||||
|
mana: saved.mana,
|
||||||
|
max_endurance: saved.max_endurance,
|
||||||
|
endurance: saved.endurance,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.players.insert(
|
self.players.insert(
|
||||||
@@ -294,9 +437,13 @@ impl GameState {
|
|||||||
room_id,
|
room_id,
|
||||||
stats,
|
stats,
|
||||||
inventory,
|
inventory,
|
||||||
equipped_weapon,
|
equipped,
|
||||||
equipped_armor,
|
guilds,
|
||||||
|
cooldowns: HashMap::new(),
|
||||||
is_admin: saved.is_admin,
|
is_admin: saved.is_admin,
|
||||||
|
gold: saved.gold,
|
||||||
|
silver: saved.silver,
|
||||||
|
copper: saved.copper,
|
||||||
},
|
},
|
||||||
channel,
|
channel,
|
||||||
handle,
|
handle,
|
||||||
@@ -310,14 +457,8 @@ impl GameState {
|
|||||||
let p = &conn.player;
|
let p = &conn.player;
|
||||||
let inv_json =
|
let inv_json =
|
||||||
serde_json::to_string(&p.inventory).unwrap_or_else(|_| "[]".into());
|
serde_json::to_string(&p.inventory).unwrap_or_else(|_| "[]".into());
|
||||||
let weapon_json = p
|
let equipped_json =
|
||||||
.equipped_weapon
|
serde_json::to_string(&p.equipped).unwrap_or_else(|_| "{}".into());
|
||||||
.as_ref()
|
|
||||||
.map(|w| serde_json::to_string(w).unwrap_or_else(|_| "null".into()));
|
|
||||||
let armor_json = p
|
|
||||||
.equipped_armor
|
|
||||||
.as_ref()
|
|
||||||
.map(|a| serde_json::to_string(a).unwrap_or_else(|_| "null".into()));
|
|
||||||
|
|
||||||
self.db.save_player(&SavedPlayer {
|
self.db.save_player(&SavedPlayer {
|
||||||
name: p.name.clone(),
|
name: p.name.clone(),
|
||||||
@@ -331,9 +472,15 @@ impl GameState {
|
|||||||
attack: p.stats.attack,
|
attack: p.stats.attack,
|
||||||
defense: p.stats.defense,
|
defense: p.stats.defense,
|
||||||
inventory_json: inv_json,
|
inventory_json: inv_json,
|
||||||
equipped_weapon_json: weapon_json,
|
equipped_json,
|
||||||
equipped_armor_json: armor_json,
|
mana: p.stats.mana,
|
||||||
|
max_mana: p.stats.max_mana,
|
||||||
|
endurance: p.stats.endurance,
|
||||||
|
max_endurance: p.stats.max_endurance,
|
||||||
is_admin: p.is_admin,
|
is_admin: p.is_admin,
|
||||||
|
gold: p.gold,
|
||||||
|
silver: p.silver,
|
||||||
|
copper: p.copper,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,11 +500,14 @@ impl GameState {
|
|||||||
|
|
||||||
pub fn check_respawns(&mut self) {
|
pub fn check_respawns(&mut self) {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
for (npc_id, instance) in self.npc_instances.iter_mut() {
|
let npc_ids: Vec<String> = self.npc_instances.keys().cloned().collect();
|
||||||
if instance.alive {
|
for npc_id in npc_ids {
|
||||||
continue;
|
let instance = match self.npc_instances.get(&npc_id) {
|
||||||
}
|
Some(i) => i,
|
||||||
let npc = match self.world.npcs.get(npc_id) {
|
None => continue,
|
||||||
|
};
|
||||||
|
if instance.alive { continue; }
|
||||||
|
let npc = match self.world.npcs.get(&npc_id) {
|
||||||
Some(n) => n,
|
Some(n) => n,
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
@@ -365,13 +515,22 @@ impl GameState {
|
|||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
if let Some(death_time) = instance.death_time {
|
let should_respawn = instance.death_time
|
||||||
if now.duration_since(death_time).as_secs() >= respawn_secs {
|
.map(|dt| now.duration_since(dt).as_secs() >= respawn_secs)
|
||||||
if let Some(ref combat) = npc.combat {
|
.unwrap_or(false);
|
||||||
instance.hp = combat.max_hp;
|
if should_respawn {
|
||||||
instance.alive = true;
|
let hp = npc.combat.as_ref().map(|c| c.max_hp).unwrap_or(20);
|
||||||
instance.death_time = None;
|
let fixed_race = npc.fixed_race.clone();
|
||||||
}
|
let fixed_class = npc.fixed_class.clone();
|
||||||
|
let (race_id, class_id) = resolve_npc_race_class(
|
||||||
|
&fixed_race, &fixed_class, &self.world, &mut self.rng,
|
||||||
|
);
|
||||||
|
if let Some(inst) = self.npc_instances.get_mut(&npc_id) {
|
||||||
|
inst.hp = hp;
|
||||||
|
inst.alive = true;
|
||||||
|
inst.death_time = None;
|
||||||
|
inst.race_id = race_id;
|
||||||
|
inst.class_id = class_id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
167
src/jsonrpc.rs
Normal file
167
src/jsonrpc.rs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::game::SharedState;
|
||||||
|
use crate::commands;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct JsonRpcRequest {
|
||||||
|
_jsonrpc: String,
|
||||||
|
method: String,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
id: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct JsonRpcResponse {
|
||||||
|
jsonrpc: String,
|
||||||
|
result: Option<serde_json::Value>,
|
||||||
|
error: Option<serde_json::Value>,
|
||||||
|
id: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_jsonrpc_server(state: SharedState, port: u16) {
|
||||||
|
let listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap();
|
||||||
|
log::info!("JSON-RPC server listening on 0.0.0.0:{port}");
|
||||||
|
|
||||||
|
let sessions = Arc::new(Mutex::new(HashMap::<usize, String>::new()));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (stream, addr) = match listener.accept().await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to accept connection: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
log::info!("New JSON-RPC connection from {addr:?}");
|
||||||
|
|
||||||
|
let state = state.clone();
|
||||||
|
let sessions = sessions.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
handle_connection(stream, state, sessions).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_connection(
|
||||||
|
mut stream: TcpStream,
|
||||||
|
state: SharedState,
|
||||||
|
sessions: Arc<Mutex<HashMap<usize, String>>>,
|
||||||
|
) {
|
||||||
|
let (reader, mut writer) = stream.split();
|
||||||
|
let mut reader = BufReader::new(reader);
|
||||||
|
let mut line = String::new();
|
||||||
|
|
||||||
|
// Map RPC session ID to player ID
|
||||||
|
let mut current_player_id: Option<usize> = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
line.clear();
|
||||||
|
match reader.read_line(&mut line).await {
|
||||||
|
Ok(0) => break, // Connection closed
|
||||||
|
Ok(_) => {
|
||||||
|
let req: JsonRpcRequest = match serde_json::from_str(&line) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
let resp = JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
result: None,
|
||||||
|
error: Some(json!({"code": -32700, "message": format!("Parse error: {e}")})),
|
||||||
|
id: None,
|
||||||
|
};
|
||||||
|
let _ = writer.write_all(format!("{}\n", serde_json::to_string(&resp).unwrap()).as_bytes()).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = handle_request(req, &state, &sessions, &mut current_player_id).await;
|
||||||
|
let _ = writer.write_all(format!("{}\n", serde_json::to_string(&resp).unwrap()).as_bytes()).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Error reading from JSON-RPC stream: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup session if needed
|
||||||
|
if let Some(pid) = current_player_id {
|
||||||
|
let mut st = state.lock().await;
|
||||||
|
st.remove_player(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_request(
|
||||||
|
req: JsonRpcRequest,
|
||||||
|
state: &SharedState,
|
||||||
|
_sessions: &Arc<Mutex<HashMap<usize, String>>>,
|
||||||
|
current_player_id: &mut Option<usize>,
|
||||||
|
) -> JsonRpcResponse {
|
||||||
|
let method = req.method.as_str();
|
||||||
|
let id = req.id.clone();
|
||||||
|
|
||||||
|
let result = match method {
|
||||||
|
"login" => {
|
||||||
|
let username = req.params.as_ref()
|
||||||
|
.and_then(|p| p.get("username"))
|
||||||
|
.and_then(|u| u.as_str())
|
||||||
|
.unwrap_or("anonymous");
|
||||||
|
|
||||||
|
let mut st = state.lock().await;
|
||||||
|
let player_id = rand::random::<usize>();
|
||||||
|
|
||||||
|
let saved = st.db.load_player(username);
|
||||||
|
if let Some(saved) = saved {
|
||||||
|
st.load_existing_player(player_id, saved, None, None);
|
||||||
|
*current_player_id = Some(player_id);
|
||||||
|
json!({"status": "success", "session_id": player_id})
|
||||||
|
} else {
|
||||||
|
json!({"status": "error", "message": "Player not found. Create character via SSH first."})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"list_commands" => {
|
||||||
|
json!(commands::get_command_list())
|
||||||
|
},
|
||||||
|
"execute" => {
|
||||||
|
if let Some(pid) = *current_player_id {
|
||||||
|
let command = req.params.as_ref()
|
||||||
|
.and_then(|p| p.get("command"))
|
||||||
|
.and_then(|c| c.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let args = req.params.as_ref()
|
||||||
|
.and_then(|p| p.get("args"))
|
||||||
|
.and_then(|a| a.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let input = if args.is_empty() { command.to_string() } else { format!("{command} {args}") };
|
||||||
|
let result = commands::execute(&input, pid, state).await;
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"output": strip_ansi(&result.output),
|
||||||
|
"quit": result.quit
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
json!({"error": "Not logged in"})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => json!({"error": "Method not found"}),
|
||||||
|
};
|
||||||
|
|
||||||
|
JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
result: Some(result),
|
||||||
|
error: None,
|
||||||
|
id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_ansi(text: &str) -> String {
|
||||||
|
let re = regex::Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").unwrap();
|
||||||
|
re.replace_all(text, "").to_string()
|
||||||
|
}
|
||||||
@@ -8,3 +8,4 @@ pub mod game;
|
|||||||
pub mod ssh;
|
pub mod ssh;
|
||||||
pub mod tick;
|
pub mod tick;
|
||||||
pub mod world;
|
pub mod world;
|
||||||
|
pub mod jsonrpc;
|
||||||
|
|||||||
69
src/main.rs
69
src/main.rs
@@ -2,6 +2,8 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use flexi_logger::writers::FileLogWriter;
|
||||||
|
use flexi_logger::{Cleanup, Criterion, Duplicate, FileSpec, Logger, Naming, WriteMode};
|
||||||
use russh::keys::ssh_key::rand_core::OsRng;
|
use russh::keys::ssh_key::rand_core::OsRng;
|
||||||
use russh::server::Server as _;
|
use russh::server::Server as _;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
@@ -18,11 +20,12 @@ const DEFAULT_DB_PATH: &str = "./mudserver.db";
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
|
||||||
|
|
||||||
let mut port = DEFAULT_PORT;
|
let mut port = DEFAULT_PORT;
|
||||||
|
let mut jsonrpc_port = 2223;
|
||||||
let mut world_dir = PathBuf::from(DEFAULT_WORLD_DIR);
|
let mut world_dir = PathBuf::from(DEFAULT_WORLD_DIR);
|
||||||
let mut db_path = PathBuf::from(DEFAULT_DB_PATH);
|
let mut db_path = PathBuf::from(DEFAULT_DB_PATH);
|
||||||
|
let mut log_dir = "logs".to_string();
|
||||||
|
let mut log_level = "info".to_string();
|
||||||
|
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
let mut i = 1;
|
let mut i = 1;
|
||||||
@@ -35,6 +38,13 @@ async fn main() {
|
|||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.expect("--port requires a number");
|
.expect("--port requires a number");
|
||||||
}
|
}
|
||||||
|
"--rpc-port" => {
|
||||||
|
i += 1;
|
||||||
|
jsonrpc_port = args
|
||||||
|
.get(i)
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.expect("--rpc-port requires a number");
|
||||||
|
}
|
||||||
"--world" | "-w" => {
|
"--world" | "-w" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
world_dir = PathBuf::from(args.get(i).expect("--world requires a path"));
|
world_dir = PathBuf::from(args.get(i).expect("--world requires a path"));
|
||||||
@@ -43,11 +53,22 @@ async fn main() {
|
|||||||
i += 1;
|
i += 1;
|
||||||
db_path = PathBuf::from(args.get(i).expect("--db requires a path"));
|
db_path = PathBuf::from(args.get(i).expect("--db requires a path"));
|
||||||
}
|
}
|
||||||
|
"--log-dir" => {
|
||||||
|
i += 1;
|
||||||
|
log_dir = args.get(i).expect("--log-dir requires a path").to_string();
|
||||||
|
}
|
||||||
|
"--log-level" => {
|
||||||
|
i += 1;
|
||||||
|
log_level = args.get(i).expect("--log-level requires a level").to_string();
|
||||||
|
}
|
||||||
"--help" => {
|
"--help" => {
|
||||||
eprintln!("Usage: mudserver [OPTIONS]");
|
eprintln!("Usage: mudserver [OPTIONS]");
|
||||||
eprintln!(" --port, -p Listen port (default: {DEFAULT_PORT})");
|
eprintln!(" --port, -p SSH listen port (default: {DEFAULT_PORT})");
|
||||||
|
eprintln!(" --rpc-port JSON-RPC listen port (default: 2223)");
|
||||||
eprintln!(" --world, -w World directory (default: {DEFAULT_WORLD_DIR})");
|
eprintln!(" --world, -w World directory (default: {DEFAULT_WORLD_DIR})");
|
||||||
eprintln!(" --db, -d Database path (default: {DEFAULT_DB_PATH})");
|
eprintln!(" --db, -d Database path (default: {DEFAULT_DB_PATH})");
|
||||||
|
eprintln!(" --log-dir Directory for log files (default: logs)");
|
||||||
|
eprintln!(" --log-level Logging level (default: info)");
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
@@ -58,6 +79,42 @@ async fn main() {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure log directory exists
|
||||||
|
std::fs::create_dir_all(&log_dir).unwrap_or_else(|e| {
|
||||||
|
eprintln!("Failed to create log directory: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize logger
|
||||||
|
let combat_writer = FileLogWriter::builder(FileSpec::default().directory(&log_dir).basename("combat"))
|
||||||
|
.rotate(
|
||||||
|
Criterion::Size(10_000_000), // 10 MB
|
||||||
|
Naming::Numbers,
|
||||||
|
Cleanup::KeepLogFiles(7),
|
||||||
|
)
|
||||||
|
.append()
|
||||||
|
.write_mode(WriteMode::Direct)
|
||||||
|
.try_build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
Logger::try_with_str(&log_level)
|
||||||
|
.unwrap()
|
||||||
|
.log_to_file(FileSpec::default().directory(&log_dir).basename("mudserver"))
|
||||||
|
.append()
|
||||||
|
.duplicate_to_stderr(Duplicate::All)
|
||||||
|
.rotate(
|
||||||
|
Criterion::Size(10_000_000), // 10 MB
|
||||||
|
Naming::Numbers,
|
||||||
|
Cleanup::KeepLogFiles(7),
|
||||||
|
)
|
||||||
|
.write_mode(WriteMode::Direct)
|
||||||
|
.add_writer("combat", Box::new(combat_writer))
|
||||||
|
.start()
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
eprintln!("Failed to initialize logger: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
log::info!("Loading world from: {}", world_dir.display());
|
log::info!("Loading world from: {}", world_dir.display());
|
||||||
let loaded_world = world::World::load(&world_dir).unwrap_or_else(|e| {
|
let loaded_world = world::World::load(&world_dir).unwrap_or_else(|e| {
|
||||||
eprintln!("Failed to load world: {e}");
|
eprintln!("Failed to load world: {e}");
|
||||||
@@ -90,6 +147,12 @@ async fn main() {
|
|||||||
tick::run_tick_engine(tick_state).await;
|
tick::run_tick_engine(tick_state).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Spawn JSON-RPC server
|
||||||
|
let rpc_state = state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
mudserver::jsonrpc::run_jsonrpc_server(rpc_state, jsonrpc_port).await;
|
||||||
|
});
|
||||||
|
|
||||||
let mut server = ssh::MudServer::new(state);
|
let mut server = ssh::MudServer::new(state);
|
||||||
|
|
||||||
let listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap();
|
let listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap();
|
||||||
|
|||||||
33
src/ssh.rs
33
src/ssh.rs
@@ -80,9 +80,11 @@ impl MudHandler {
|
|||||||
if let Some(saved) = saved {
|
if let Some(saved) = saved {
|
||||||
let handle = session.handle();
|
let handle = session.handle();
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock().await;
|
||||||
state.load_existing_player(self.id, saved, channel, handle);
|
state.load_existing_player(self.id, saved, Some(channel), Some(handle));
|
||||||
drop(state);
|
drop(state);
|
||||||
|
|
||||||
|
log::info!("Player '{}' (id={}) logged in", self.username, self.id);
|
||||||
|
|
||||||
let msg = format!(
|
let msg = format!(
|
||||||
"{}\r\n",
|
"{}\r\n",
|
||||||
ansi::system_msg("Welcome back! Your character has been restored.")
|
ansi::system_msg("Welcome back! Your character has been restored.")
|
||||||
@@ -127,7 +129,13 @@ impl MudHandler {
|
|||||||
let others: Vec<_> = state
|
let others: Vec<_> = state
|
||||||
.players_in_room(&room_id, self.id)
|
.players_in_room(&room_id, self.id)
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| (c.channel, c.handle.clone()))
|
.filter_map(|c| {
|
||||||
|
if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
|
||||||
|
Some((ch, h.clone()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let room_view = render_entry_room(&state, &room_id, &player_name, self.id);
|
let room_view = render_entry_room(&state, &room_id, &player_name, self.id);
|
||||||
@@ -165,13 +173,20 @@ impl MudHandler {
|
|||||||
.map(|c| c.name.clone())
|
.map(|c| c.name.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"New character created: {} (Race: {}, Class: {})",
|
||||||
|
self.username,
|
||||||
|
race_name,
|
||||||
|
class_name
|
||||||
|
);
|
||||||
|
|
||||||
state.create_new_player(
|
state.create_new_player(
|
||||||
self.id,
|
self.id,
|
||||||
self.username.clone(),
|
self.username.clone(),
|
||||||
race_id,
|
race_id,
|
||||||
class_id,
|
class_id,
|
||||||
channel,
|
Some(channel),
|
||||||
handle,
|
Some(handle),
|
||||||
);
|
);
|
||||||
state.save_player_to_db(self.id);
|
state.save_player_to_db(self.id);
|
||||||
drop(state);
|
drop(state);
|
||||||
@@ -203,7 +218,13 @@ impl MudHandler {
|
|||||||
let others: Vec<_> = state
|
let others: Vec<_> = state
|
||||||
.players_in_room(&conn.player.room_id, self.id)
|
.players_in_room(&conn.player.room_id, self.id)
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| (c.channel, c.handle.clone()))
|
.filter_map(|c| {
|
||||||
|
if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
|
||||||
|
Some((ch, h.clone()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
drop(state);
|
drop(state);
|
||||||
for (ch, h) in others {
|
for (ch, h) in others {
|
||||||
@@ -415,7 +436,7 @@ impl russh::server::Handler for MudHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let keep_going =
|
let keep_going =
|
||||||
commands::execute(&line, self.id, &self.state, session, channel)
|
commands::execute_for_ssh(&line, self.id, &self.state, session, channel)
|
||||||
.await?;
|
.await?;
|
||||||
if !keep_going {
|
if !keep_going {
|
||||||
self.handle_disconnect().await;
|
self.handle_disconnect().await;
|
||||||
|
|||||||
137
src/tick.rs
137
src/tick.rs
@@ -26,6 +26,48 @@ pub async fn run_tick_engine(state: SharedState) {
|
|||||||
st.tick_count += 1;
|
st.tick_count += 1;
|
||||||
let tick = st.tick_count;
|
let tick = st.tick_count;
|
||||||
|
|
||||||
|
let mut weather_msg = None;
|
||||||
|
st.weather.remaining_ticks = st.weather.remaining_ticks.saturating_sub(1);
|
||||||
|
if st.weather.remaining_ticks == 0 {
|
||||||
|
let old_kind = st.weather.kind;
|
||||||
|
st.weather.kind = match st.rng.next_range(0, 5) {
|
||||||
|
0 => crate::game::WeatherKind::Clear,
|
||||||
|
1 => crate::game::WeatherKind::Cloudy,
|
||||||
|
2 => crate::game::WeatherKind::Rain,
|
||||||
|
3 => crate::game::WeatherKind::Storm,
|
||||||
|
4 => crate::game::WeatherKind::Snow,
|
||||||
|
5 => crate::game::WeatherKind::Fog,
|
||||||
|
_ => crate::game::WeatherKind::Clear,
|
||||||
|
};
|
||||||
|
st.weather.remaining_ticks = st.rng.next_range(100, 400) as u32;
|
||||||
|
|
||||||
|
if old_kind != st.weather.kind {
|
||||||
|
weather_msg = Some(format!(
|
||||||
|
"\r\n {}\r\n",
|
||||||
|
ansi::color(ansi::CYAN, st.weather.kind.description())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply "wet" effect if raining/storming
|
||||||
|
if st.weather.kind == crate::game::WeatherKind::Rain
|
||||||
|
|| st.weather.kind == crate::game::WeatherKind::Storm
|
||||||
|
{
|
||||||
|
let wet_players: Vec<String> = st
|
||||||
|
.players
|
||||||
|
.values()
|
||||||
|
.filter_map(|c| {
|
||||||
|
st.world
|
||||||
|
.get_room(&c.player.room_id)
|
||||||
|
.filter(|r| r.outdoors)
|
||||||
|
.map(|_| c.player.name.clone())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for name in wet_players {
|
||||||
|
st.db.save_effect(&name, "wet", 10, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
st.check_respawns();
|
st.check_respawns();
|
||||||
|
|
||||||
// --- NPC auto-aggro: hostile NPCs initiate combat with players in their room ---
|
// --- NPC auto-aggro: hostile NPCs initiate combat with players in their room ---
|
||||||
@@ -51,7 +93,7 @@ pub async fn run_tick_engine(state: SharedState) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let att = st.npc_attitude_toward(npc_id, &conn.player.name);
|
let att = st.npc_attitude_toward(npc_id, &conn.player.name);
|
||||||
if att.will_attack() {
|
if att.is_hostile() {
|
||||||
new_combats.push((*pid, npc_id.clone()));
|
new_combats.push((*pid, npc_id.clone()));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -60,6 +102,16 @@ pub async fn run_tick_engine(state: SharedState) {
|
|||||||
|
|
||||||
let mut messages: HashMap<usize, String> = HashMap::new();
|
let mut messages: HashMap<usize, String> = HashMap::new();
|
||||||
|
|
||||||
|
if let Some(msg) = weather_msg {
|
||||||
|
for (&pid, conn) in st.players.iter() {
|
||||||
|
if let Some(room) = st.world.get_room(&conn.player.room_id) {
|
||||||
|
if room.outdoors {
|
||||||
|
messages.entry(pid).or_default().push_str(&msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (pid, npc_id) in &new_combats {
|
for (pid, npc_id) in &new_combats {
|
||||||
let npc_name = st
|
let npc_name = st
|
||||||
.world
|
.world
|
||||||
@@ -183,6 +235,24 @@ pub async fn run_tick_engine(state: SharedState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"wet" => {
|
||||||
|
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) {
|
||||||
|
if eff.remaining_ticks <= 0 {
|
||||||
|
messages.entry(pid).or_default().push_str(&format!(
|
||||||
|
"\r\n {} You dry off.\r\n",
|
||||||
|
ansi::color(ansi::CYAN, "~~"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
"regen" => {
|
"regen" => {
|
||||||
let heal = eff.magnitude;
|
let heal = eff.magnitude;
|
||||||
let online_pid = st
|
let online_pid = st
|
||||||
@@ -223,35 +293,62 @@ pub async fn run_tick_engine(state: SharedState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Tick cooldowns for all online players ---
|
||||||
|
let cd_pids: Vec<usize> = st.players.keys().copied().collect();
|
||||||
|
for pid in cd_pids {
|
||||||
|
if let Some(conn) = st.players.get_mut(&pid) {
|
||||||
|
conn.player.cooldowns.retain(|_, cd| {
|
||||||
|
*cd -= 1;
|
||||||
|
*cd > 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Passive regen for online players not in combat ---
|
// --- Passive regen for online players not in combat ---
|
||||||
if tick % REGEN_EVERY_N_TICKS == 0 {
|
if tick % REGEN_EVERY_N_TICKS == 0 {
|
||||||
let regen_pids: Vec<usize> = st
|
let regen_pids: Vec<usize> = st
|
||||||
.players
|
.players
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, c)| {
|
.filter(|(_, c)| c.combat.is_none())
|
||||||
c.combat.is_none() && c.player.stats.hp < c.player.stats.max_hp
|
|
||||||
})
|
|
||||||
.map(|(&id, _)| id)
|
.map(|(&id, _)| id)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
for pid in regen_pids {
|
for pid in regen_pids {
|
||||||
if let Some(conn) = st.players.get_mut(&pid) {
|
if let Some(conn) = st.players.get_mut(&pid) {
|
||||||
let heal =
|
let s = &mut conn.player.stats;
|
||||||
(conn.player.stats.max_hp * REGEN_PERCENT / 100).max(1);
|
let mut regen_msg = String::new();
|
||||||
let old = conn.player.stats.hp;
|
|
||||||
conn.player.stats.hp =
|
// HP regen
|
||||||
(conn.player.stats.hp + heal).min(conn.player.stats.max_hp);
|
if s.hp < s.max_hp {
|
||||||
let healed = conn.player.stats.hp - old;
|
let heal = (s.max_hp * REGEN_PERCENT / 100).max(1);
|
||||||
|
let old = s.hp;
|
||||||
|
s.hp = (s.hp + heal).min(s.max_hp);
|
||||||
|
let healed = s.hp - old;
|
||||||
if healed > 0 {
|
if healed > 0 {
|
||||||
messages.entry(pid).or_default().push_str(&format!(
|
regen_msg.push_str(&format!(
|
||||||
"\r\n {} You recover {} HP. ({}/{})\r\n",
|
"\r\n {} You recover {} HP. ({}/{})",
|
||||||
ansi::color(ansi::DIM, "~~"),
|
ansi::color(ansi::DIM, "~~"), healed, s.hp, s.max_hp,
|
||||||
healed,
|
|
||||||
conn.player.stats.hp,
|
|
||||||
conn.player.stats.max_hp,
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mana regen
|
||||||
|
if s.mana < s.max_mana {
|
||||||
|
let regen = (s.max_mana * REGEN_PERCENT / 100).max(1);
|
||||||
|
s.mana = (s.mana + regen).min(s.max_mana);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endurance regen
|
||||||
|
if s.endurance < s.max_endurance {
|
||||||
|
let regen = (s.max_endurance * REGEN_PERCENT / 100).max(1);
|
||||||
|
s.endurance = (s.endurance + regen).min(s.max_endurance);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !regen_msg.is_empty() {
|
||||||
|
regen_msg.push_str("\r\n");
|
||||||
|
messages.entry(pid).or_default().push_str(®en_msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
st.save_player_to_db(pid);
|
st.save_player_to_db(pid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,11 +361,15 @@ pub async fn run_tick_engine(state: SharedState) {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let conn = st.players.get(&pid)?;
|
let conn = st.players.get(&pid)?;
|
||||||
|
if let (Some(ch), Some(h)) = (conn.channel, &conn.handle) {
|
||||||
Some((
|
Some((
|
||||||
conn.channel,
|
ch,
|
||||||
conn.handle.clone(),
|
h.clone(),
|
||||||
format!("{}{}", msg, ansi::prompt()),
|
format!("{}{}", msg, ansi::prompt()),
|
||||||
))
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
411
src/world.rs
411
src/world.rs
@@ -77,12 +77,26 @@ pub struct RoomFile {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub exits: HashMap<String, String>,
|
pub exits: HashMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub outdoors: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct ShopFile {
|
||||||
|
pub buys: Vec<String>, // List of item kinds or IDs the shop buys
|
||||||
|
pub sells: Vec<String>, // List of item IDs the shop sells
|
||||||
|
#[serde(default)]
|
||||||
|
pub markup: f32, // Multiplier for sell price (default 1.0)
|
||||||
|
#[serde(default)]
|
||||||
|
pub markdown: f32, // Multiplier for buy price (default 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
pub struct NpcDialogue {
|
pub struct NpcDialogue {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub greeting: Option<String>,
|
pub greeting: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub keywords: HashMap<String, String>, // keyword -> response
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -104,11 +118,23 @@ pub struct NpcFile {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub faction: Option<String>,
|
pub faction: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub race: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub class: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub respawn_secs: Option<u64>,
|
pub respawn_secs: Option<u64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dialogue: Option<NpcDialogue>,
|
pub dialogue: Option<NpcDialogue>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub combat: Option<NpcCombatFile>,
|
pub combat: Option<NpcCombatFile>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub shop: Option<ShopFile>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gold: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub silver: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub copper: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_attitude() -> Attitude {
|
fn default_attitude() -> Attitude {
|
||||||
@@ -134,11 +160,21 @@ pub struct ObjectFile {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub kind: Option<String>,
|
pub kind: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub slot: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub takeable: bool,
|
pub takeable: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stats: Option<ObjectStatsFile>,
|
pub stats: Option<ObjectStatsFile>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub value_gold: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub value_silver: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub value_copper: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Race TOML schema ---
|
||||||
|
|
||||||
#[derive(Deserialize, Default, Clone)]
|
#[derive(Deserialize, Default, Clone)]
|
||||||
pub struct StatModifiers {
|
pub struct StatModifiers {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -151,6 +187,86 @@ pub struct StatModifiers {
|
|||||||
pub intelligence: i32,
|
pub intelligence: i32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub wisdom: i32,
|
pub wisdom: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub perception: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub charisma: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default, Clone)]
|
||||||
|
pub struct BodyFile {
|
||||||
|
#[serde(default = "default_size")]
|
||||||
|
pub size: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub weight: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub slots: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_size() -> String {
|
||||||
|
"medium".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default, Clone)]
|
||||||
|
pub struct NaturalAttack {
|
||||||
|
#[serde(default)]
|
||||||
|
pub damage: i32,
|
||||||
|
#[serde(default = "default_damage_type")]
|
||||||
|
pub r#type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cooldown_ticks: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_damage_type() -> String {
|
||||||
|
"physical".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default, Clone)]
|
||||||
|
pub struct NaturalFile {
|
||||||
|
#[serde(default)]
|
||||||
|
pub armor: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub attacks: HashMap<String, NaturalAttack>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default, Clone)]
|
||||||
|
pub struct RegenFile {
|
||||||
|
#[serde(default = "default_one")]
|
||||||
|
pub hp: f32,
|
||||||
|
#[serde(default = "default_one")]
|
||||||
|
pub mana: f32,
|
||||||
|
#[serde(default = "default_one")]
|
||||||
|
pub endurance: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_one() -> f32 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default, Clone)]
|
||||||
|
pub struct GuildCompatibilityFile {
|
||||||
|
#[serde(default)]
|
||||||
|
pub good: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub average: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub poor: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub restricted: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default, Clone)]
|
||||||
|
pub struct RaceMiscFile {
|
||||||
|
#[serde(default)]
|
||||||
|
pub lifespan: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub diet: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub xp_rate: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub natural_terrain: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub vision: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -158,9 +274,33 @@ pub struct RaceFile {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub metarace: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hidden: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_class: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub stats: StatModifiers,
|
pub stats: StatModifiers,
|
||||||
|
#[serde(default)]
|
||||||
|
pub body: BodyFile,
|
||||||
|
#[serde(default)]
|
||||||
|
pub natural: NaturalFile,
|
||||||
|
#[serde(default)]
|
||||||
|
pub resistances: HashMap<String, f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub traits: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub disadvantages: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub regen: RegenFile,
|
||||||
|
#[serde(default)]
|
||||||
|
pub guild_compatibility: GuildCompatibilityFile,
|
||||||
|
#[serde(default)]
|
||||||
|
pub misc: RaceMiscFile,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Class TOML schema (starter class — seeds initial guild on character creation) ---
|
||||||
|
|
||||||
#[derive(Deserialize, Default, Clone)]
|
#[derive(Deserialize, Default, Clone)]
|
||||||
pub struct ClassBaseStats {
|
pub struct ClassBaseStats {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -186,9 +326,83 @@ pub struct ClassFile {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub hidden: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub base_stats: ClassBaseStats,
|
pub base_stats: ClassBaseStats,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub growth: ClassGrowth,
|
pub growth: ClassGrowth,
|
||||||
|
#[serde(default)]
|
||||||
|
pub guild: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Guild TOML schema ---
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default, Clone)]
|
||||||
|
pub struct GuildGrowth {
|
||||||
|
#[serde(default)]
|
||||||
|
pub hp_per_level: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mana_per_level: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub endurance_per_level: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub attack_per_level: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub defense_per_level: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct GuildFile {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_level: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub resource: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub base_mana: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub base_endurance: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub growth: GuildGrowth,
|
||||||
|
#[serde(default)]
|
||||||
|
pub spells: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub min_player_level: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub race_restricted: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Spell TOML schema ---
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SpellFile {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub spell_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub damage: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub heal: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub damage_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cost_mana: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cost_endurance: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cooldown_ticks: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub casting_ticks: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub min_guild_level: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub effect: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub effect_duration: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub effect_magnitude: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Runtime types ---
|
// --- Runtime types ---
|
||||||
@@ -201,6 +415,7 @@ pub struct Room {
|
|||||||
pub exits: HashMap<String, String>,
|
pub exits: HashMap<String, String>,
|
||||||
pub npcs: Vec<String>,
|
pub npcs: Vec<String>,
|
||||||
pub objects: Vec<String>,
|
pub objects: Vec<String>,
|
||||||
|
pub outdoors: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -219,9 +434,16 @@ pub struct Npc {
|
|||||||
pub room: String,
|
pub room: String,
|
||||||
pub base_attitude: Attitude,
|
pub base_attitude: Attitude,
|
||||||
pub faction: Option<String>,
|
pub faction: Option<String>,
|
||||||
|
pub fixed_race: Option<String>,
|
||||||
|
pub fixed_class: Option<String>,
|
||||||
pub respawn_secs: Option<u64>,
|
pub respawn_secs: Option<u64>,
|
||||||
pub greeting: Option<String>,
|
pub greeting: Option<String>,
|
||||||
|
pub keywords: HashMap<String, String>,
|
||||||
pub combat: Option<NpcCombatStats>,
|
pub combat: Option<NpcCombatStats>,
|
||||||
|
pub shop: Option<ShopFile>,
|
||||||
|
pub gold: i32,
|
||||||
|
pub silver: i32,
|
||||||
|
pub copper: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
@@ -238,8 +460,24 @@ pub struct Object {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
pub room: Option<String>,
|
pub room: Option<String>,
|
||||||
pub kind: Option<String>,
|
pub kind: Option<String>,
|
||||||
|
pub slot: Option<String>,
|
||||||
pub takeable: bool,
|
pub takeable: bool,
|
||||||
pub stats: ObjectStats,
|
pub stats: ObjectStats,
|
||||||
|
pub value_gold: i32,
|
||||||
|
pub value_silver: i32,
|
||||||
|
pub value_copper: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const DEFAULT_HUMANOID_SLOTS: &[&str] = &[
|
||||||
|
"head", "neck", "torso", "legs", "feet", "main_hand", "off_hand", "finger", "finger",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct NaturalAttackDef {
|
||||||
|
pub name: String,
|
||||||
|
pub damage: i32,
|
||||||
|
pub damage_type: String,
|
||||||
|
pub cooldown_ticks: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -247,7 +485,27 @@ pub struct Race {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
pub metarace: Option<String>,
|
||||||
|
pub hidden: bool,
|
||||||
|
pub default_class: Option<String>,
|
||||||
pub stats: StatModifiers,
|
pub stats: StatModifiers,
|
||||||
|
pub size: String,
|
||||||
|
pub weight: i32,
|
||||||
|
pub slots: Vec<String>,
|
||||||
|
pub natural_armor: i32,
|
||||||
|
pub natural_attacks: Vec<NaturalAttackDef>,
|
||||||
|
pub resistances: HashMap<String, f32>,
|
||||||
|
pub traits: Vec<String>,
|
||||||
|
pub disadvantages: Vec<String>,
|
||||||
|
pub regen_hp: f32,
|
||||||
|
pub regen_mana: f32,
|
||||||
|
pub regen_endurance: f32,
|
||||||
|
pub guild_compatibility: GuildCompatibilityFile,
|
||||||
|
pub lifespan: Option<i32>,
|
||||||
|
pub diet: Option<String>,
|
||||||
|
pub xp_rate: f32,
|
||||||
|
pub natural_terrain: Vec<String>,
|
||||||
|
pub vision: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -255,8 +513,44 @@ pub struct Class {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
pub hidden: bool,
|
||||||
pub base_stats: ClassBaseStats,
|
pub base_stats: ClassBaseStats,
|
||||||
pub growth: ClassGrowth,
|
pub growth: ClassGrowth,
|
||||||
|
pub guild: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Guild {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub max_level: i32,
|
||||||
|
pub resource: String,
|
||||||
|
pub base_mana: i32,
|
||||||
|
pub base_endurance: i32,
|
||||||
|
pub growth: GuildGrowth,
|
||||||
|
pub spells: Vec<String>,
|
||||||
|
pub min_player_level: i32,
|
||||||
|
pub race_restricted: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Spell {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub spell_type: String,
|
||||||
|
pub damage: i32,
|
||||||
|
pub heal: i32,
|
||||||
|
pub damage_type: String,
|
||||||
|
pub cost_mana: i32,
|
||||||
|
pub cost_endurance: i32,
|
||||||
|
pub cooldown_ticks: i32,
|
||||||
|
pub casting_ticks: i32,
|
||||||
|
pub min_guild_level: i32,
|
||||||
|
pub effect: Option<String>,
|
||||||
|
pub effect_duration: i32,
|
||||||
|
pub effect_magnitude: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct World {
|
pub struct World {
|
||||||
@@ -267,6 +561,8 @@ pub struct World {
|
|||||||
pub objects: HashMap<String, Object>,
|
pub objects: HashMap<String, Object>,
|
||||||
pub races: Vec<Race>,
|
pub races: Vec<Race>,
|
||||||
pub classes: Vec<Class>,
|
pub classes: Vec<Class>,
|
||||||
|
pub guilds: HashMap<String, Guild>,
|
||||||
|
pub spells: HashMap<String, Spell>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl World {
|
impl World {
|
||||||
@@ -280,14 +576,77 @@ impl World {
|
|||||||
let mut races = Vec::new();
|
let mut races = Vec::new();
|
||||||
load_entities_from_dir(&world_dir.join("races"), "race", &mut |id, content| {
|
load_entities_from_dir(&world_dir.join("races"), "race", &mut |id, content| {
|
||||||
let rf: RaceFile = toml::from_str(content).map_err(|e| format!("Bad race {id}: {e}"))?;
|
let rf: RaceFile = toml::from_str(content).map_err(|e| format!("Bad race {id}: {e}"))?;
|
||||||
races.push(Race { id, name: rf.name, description: rf.description, stats: rf.stats });
|
let slots = if rf.body.slots.is_empty() {
|
||||||
|
DEFAULT_HUMANOID_SLOTS.iter().map(|s| s.to_string()).collect()
|
||||||
|
} else {
|
||||||
|
rf.body.slots
|
||||||
|
};
|
||||||
|
let natural_attacks = rf.natural.attacks.into_iter().map(|(name, a)| {
|
||||||
|
NaturalAttackDef { name, damage: a.damage, damage_type: a.r#type, cooldown_ticks: a.cooldown_ticks }
|
||||||
|
}).collect();
|
||||||
|
races.push(Race {
|
||||||
|
id, name: rf.name, description: rf.description,
|
||||||
|
metarace: rf.metarace,
|
||||||
|
hidden: rf.hidden,
|
||||||
|
default_class: rf.default_class,
|
||||||
|
stats: rf.stats,
|
||||||
|
size: rf.body.size,
|
||||||
|
weight: rf.body.weight,
|
||||||
|
slots,
|
||||||
|
natural_armor: rf.natural.armor,
|
||||||
|
natural_attacks,
|
||||||
|
resistances: rf.resistances,
|
||||||
|
traits: rf.traits,
|
||||||
|
disadvantages: rf.disadvantages,
|
||||||
|
regen_hp: rf.regen.hp,
|
||||||
|
regen_mana: rf.regen.mana,
|
||||||
|
regen_endurance: rf.regen.endurance,
|
||||||
|
guild_compatibility: rf.guild_compatibility,
|
||||||
|
lifespan: rf.misc.lifespan,
|
||||||
|
diet: rf.misc.diet,
|
||||||
|
xp_rate: rf.misc.xp_rate.unwrap_or(1.0),
|
||||||
|
natural_terrain: rf.misc.natural_terrain,
|
||||||
|
vision: rf.misc.vision,
|
||||||
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut classes = Vec::new();
|
let mut classes = Vec::new();
|
||||||
load_entities_from_dir(&world_dir.join("classes"), "class", &mut |id, content| {
|
load_entities_from_dir(&world_dir.join("classes"), "class", &mut |id, content| {
|
||||||
let cf: ClassFile = toml::from_str(content).map_err(|e| format!("Bad class {id}: {e}"))?;
|
let cf: ClassFile = toml::from_str(content).map_err(|e| format!("Bad class {id}: {e}"))?;
|
||||||
classes.push(Class { id, name: cf.name, description: cf.description, base_stats: cf.base_stats, growth: cf.growth });
|
classes.push(Class { id, name: cf.name, description: cf.description, hidden: cf.hidden, base_stats: cf.base_stats, growth: cf.growth, guild: cf.guild });
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut guilds = HashMap::new();
|
||||||
|
load_entities_from_dir(&world_dir.join("guilds"), "guild", &mut |id, content| {
|
||||||
|
let gf: GuildFile = toml::from_str(content).map_err(|e| format!("Bad guild {id}: {e}"))?;
|
||||||
|
guilds.insert(id.clone(), Guild {
|
||||||
|
id, name: gf.name, description: gf.description,
|
||||||
|
max_level: if gf.max_level == 0 { 50 } else { gf.max_level },
|
||||||
|
resource: gf.resource.unwrap_or_else(|| "mana".into()),
|
||||||
|
base_mana: gf.base_mana, base_endurance: gf.base_endurance,
|
||||||
|
growth: gf.growth, spells: gf.spells,
|
||||||
|
min_player_level: gf.min_player_level,
|
||||||
|
race_restricted: gf.race_restricted,
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut spells = HashMap::new();
|
||||||
|
load_entities_from_dir(&world_dir.join("spells"), "spell", &mut |id, content| {
|
||||||
|
let sf: SpellFile = toml::from_str(content).map_err(|e| format!("Bad spell {id}: {e}"))?;
|
||||||
|
spells.insert(id.clone(), Spell {
|
||||||
|
id, name: sf.name, description: sf.description,
|
||||||
|
spell_type: sf.spell_type.unwrap_or_else(|| "offensive".into()),
|
||||||
|
damage: sf.damage, heal: sf.heal,
|
||||||
|
damage_type: sf.damage_type.unwrap_or_else(|| "magical".into()),
|
||||||
|
cost_mana: sf.cost_mana, cost_endurance: sf.cost_endurance,
|
||||||
|
cooldown_ticks: sf.cooldown_ticks, casting_ticks: sf.casting_ticks,
|
||||||
|
min_guild_level: sf.min_guild_level,
|
||||||
|
effect: sf.effect, effect_duration: sf.effect_duration,
|
||||||
|
effect_magnitude: sf.effect_magnitude,
|
||||||
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -296,7 +655,7 @@ impl World {
|
|||||||
let mut region_dirs: Vec<_> = entries
|
let mut region_dirs: Vec<_> = entries
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
|
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
|
||||||
.filter(|e| { let n = e.file_name().to_string_lossy().to_string(); n != "races" && n != "classes" })
|
.filter(|e| { let n = e.file_name().to_string_lossy().to_string(); n != "races" && n != "classes" && n != "guilds" && n != "spells" })
|
||||||
.collect();
|
.collect();
|
||||||
region_dirs.sort_by_key(|e| e.file_name());
|
region_dirs.sort_by_key(|e| e.file_name());
|
||||||
|
|
||||||
@@ -309,23 +668,37 @@ impl World {
|
|||||||
|
|
||||||
load_entities_from_dir(®ion_path.join("rooms"), ®ion_name, &mut |id, content| {
|
load_entities_from_dir(®ion_path.join("rooms"), ®ion_name, &mut |id, content| {
|
||||||
let rf: RoomFile = toml::from_str(content).map_err(|e| format!("Bad room {id}: {e}"))?;
|
let rf: RoomFile = toml::from_str(content).map_err(|e| format!("Bad room {id}: {e}"))?;
|
||||||
rooms.insert(id.clone(), Room { id: id.clone(), region: region_name.clone(), name: rf.name, description: rf.description, exits: rf.exits, npcs: Vec::new(), objects: Vec::new() });
|
rooms.insert(id.clone(), Room { id: id.clone(), region: region_name.clone(), name: rf.name, description: rf.description, exits: rf.exits, npcs: Vec::new(), objects: Vec::new(), outdoors: rf.outdoors });
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
load_entities_from_dir(®ion_path.join("npcs"), ®ion_name, &mut |id, content| {
|
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 nf: NpcFile = toml::from_str(content).map_err(|e| format!("Bad npc {id}: {e}"))?;
|
||||||
|
let (greeting, keywords) = match nf.dialogue {
|
||||||
|
Some(d) => (d.greeting, d.keywords),
|
||||||
|
None => (None, HashMap::new()),
|
||||||
|
};
|
||||||
let combat = Some(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 }));
|
.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 {
|
||||||
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 });
|
id: id.clone(), name: nf.name, description: nf.description, room: nf.room,
|
||||||
|
base_attitude: nf.base_attitude, faction: nf.faction,
|
||||||
|
fixed_race: nf.race, fixed_class: nf.class,
|
||||||
|
respawn_secs: nf.respawn_secs, greeting, keywords, combat,
|
||||||
|
shop: nf.shop, gold: nf.gold, silver: nf.silver, copper: nf.copper,
|
||||||
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
load_entities_from_dir(®ion_path.join("objects"), ®ion_name, &mut |id, content| {
|
load_entities_from_dir(®ion_path.join("objects"), ®ion_name, &mut |id, content| {
|
||||||
let of: ObjectFile = toml::from_str(content).map_err(|e| format!("Bad object {id}: {e}"))?;
|
let of: ObjectFile = toml::from_str(content).map_err(|e| format!("Bad object {id}: {e}"))?;
|
||||||
let stats = of.stats.unwrap_or_default();
|
let stats = of.stats.unwrap_or_default();
|
||||||
objects.insert(id.clone(), Object { id: id.clone(), name: of.name, description: of.description, room: of.room, kind: of.kind, takeable: of.takeable, stats: ObjectStats { damage: stats.damage, armor: stats.armor, heal_amount: stats.heal_amount } });
|
objects.insert(id.clone(), Object {
|
||||||
|
id: id.clone(), name: of.name, description: of.description, room: of.room,
|
||||||
|
kind: of.kind, slot: of.slot, takeable: of.takeable,
|
||||||
|
stats: ObjectStats { damage: stats.damage, armor: stats.armor, heal_amount: stats.heal_amount },
|
||||||
|
value_gold: of.value_gold, value_silver: of.value_silver, value_copper: of.value_copper,
|
||||||
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
@@ -335,16 +708,30 @@ impl World {
|
|||||||
|
|
||||||
if !rooms.contains_key(&manifest.spawn_room) { return Err(format!("Spawn room '{}' not found", manifest.spawn_room)); }
|
if !rooms.contains_key(&manifest.spawn_room) { return Err(format!("Spawn room '{}' not found", manifest.spawn_room)); }
|
||||||
for room in rooms.values() { for (dir, target) in &room.exits { if !rooms.contains_key(target) { return Err(format!("Room '{}' exit '{dir}' -> unknown '{target}'", room.id)); } } }
|
for room in rooms.values() { for (dir, target) in &room.exits { if !rooms.contains_key(target) { return Err(format!("Room '{}' exit '{dir}' -> unknown '{target}'", room.id)); } } }
|
||||||
if races.is_empty() { return Err("No races defined".into()); }
|
if races.iter().filter(|r| !r.hidden).count() == 0 { return Err("No playable (non-hidden) races defined".into()); }
|
||||||
if classes.is_empty() { return Err("No classes defined".into()); }
|
if classes.iter().filter(|c| !c.hidden).count() == 0 { return Err("No playable (non-hidden) classes defined".into()); }
|
||||||
|
|
||||||
log::info!("World '{}': {} rooms, {} npcs, {} objects, {} races, {} classes", manifest.name, rooms.len(), npcs.len(), objects.len(), races.len(), classes.len());
|
log::info!("World '{}': {} rooms, {} npcs, {} objects, {} races, {} classes, {} guilds, {} spells",
|
||||||
Ok(World { name: manifest.name, spawn_room: manifest.spawn_room, rooms, npcs, objects, races, classes })
|
manifest.name, rooms.len(), npcs.len(), objects.len(), races.len(), classes.len(), guilds.len(), spells.len());
|
||||||
|
Ok(World { name: manifest.name, spawn_room: manifest.spawn_room, rooms, npcs, objects, races, classes, guilds, spells })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_room(&self, id: &str) -> Option<&Room> { self.rooms.get(id) }
|
pub fn get_room(&self, id: &str) -> Option<&Room> { self.rooms.get(id) }
|
||||||
pub fn get_npc(&self, id: &str) -> Option<&Npc> { self.npcs.get(id) }
|
pub fn get_npc(&self, id: &str) -> Option<&Npc> { self.npcs.get(id) }
|
||||||
pub fn get_object(&self, id: &str) -> Option<&Object> { self.objects.get(id) }
|
pub fn get_object(&self, id: &str) -> Option<&Object> { self.objects.get(id) }
|
||||||
|
pub fn get_guild(&self, id: &str) -> Option<&Guild> { self.guilds.get(id) }
|
||||||
|
pub fn get_spell(&self, id: &str) -> Option<&Spell> { self.spells.get(id) }
|
||||||
|
|
||||||
|
pub fn spells_for_guild(&self, guild_id: &str, guild_level: i32) -> Vec<&Spell> {
|
||||||
|
let guild = match self.guilds.get(guild_id) {
|
||||||
|
Some(g) => g,
|
||||||
|
None => return Vec::new(),
|
||||||
|
};
|
||||||
|
guild.spells.iter()
|
||||||
|
.filter_map(|sid| self.spells.get(sid))
|
||||||
|
.filter(|s| s.min_guild_level <= guild_level)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_toml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, String> {
|
fn load_toml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, String> {
|
||||||
|
|||||||
34
world/MANIFEST.md
Normal file
34
world/MANIFEST.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# World Manifest Reference
|
||||||
|
|
||||||
|
The file `world/manifest.toml` defines the world identity and spawn location. There is exactly one manifest per world.
|
||||||
|
|
||||||
|
## Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `name` | string | Yes | World name (e.g. shown at login). |
|
||||||
|
| `spawn_room` | string | Yes | Full room ID where new characters and respawned dead characters appear (e.g. `"town:town_square"`). Must reference an existing room. |
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```toml
|
||||||
|
name = "The Shattered Realm"
|
||||||
|
spawn_room = "town:town_square"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory layout
|
||||||
|
|
||||||
|
The loader expects (under `world/`):
|
||||||
|
|
||||||
|
- `manifest.toml` — this file
|
||||||
|
- `races/*.toml` — race definitions (see `races/RACES.md`)
|
||||||
|
- `classes/*.toml` — class definitions (see `classes/CLASSES.md`)
|
||||||
|
- `guilds/*.toml` — guild definitions (see `guilds/GUILDS.md`)
|
||||||
|
- `spells/*.toml` — spell definitions (see `spells/SPELLS.md`)
|
||||||
|
- `<region>/` — one folder per region (e.g. `town/`), each containing:
|
||||||
|
- `region.toml` — region metadata (see `town/REGION.md`)
|
||||||
|
- `rooms/*.toml` — rooms (see `town/rooms/ROOMS.md`)
|
||||||
|
- `npcs/*.toml` — NPCs (see `town/npcs/NPCS.md`)
|
||||||
|
- `objects/*.toml` — objects (see `town/objects/OBJECTS.md`)
|
||||||
|
|
||||||
|
Folder names `races`, `classes`, `guilds`, and `spells` are reserved; other top-level directories are treated as regions.
|
||||||
64
world/classes/CLASSES.md
Normal file
64
world/classes/CLASSES.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Class TOML Reference
|
||||||
|
|
||||||
|
Each file in `world/classes/` defines one class. The filename (without `.toml`) becomes the class ID with prefix `class:` (e.g. `warrior.toml` → `class:warrior`).
|
||||||
|
|
||||||
|
## Top-level fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Default | Description |
|
||||||
|
|-------|------|----------|---------|-------------|
|
||||||
|
| `name` | string | Yes | — | Display name of the class. |
|
||||||
|
| `description` | string | Yes | — | Short description shown in chargen. |
|
||||||
|
| `hidden` | boolean | No | `false` | If `true`, the class does not appear in character creation. Use for NPC-only classes (e.g. Peasant, Creature). |
|
||||||
|
| `guild` | string | No | — | Guild ID (e.g. `"guild:warriors_guild"`). If set, new characters who choose this class automatically join this guild at level 1 and receive that guild’s base mana/endurance. |
|
||||||
|
|
||||||
|
## `[base_stats]` — Starting stats at level 1
|
||||||
|
|
||||||
|
| Field | Type | Required | Default | Description |
|
||||||
|
|-------|------|----------|---------|-------------|
|
||||||
|
| `max_hp` | integer | No | `0` | Base maximum HP before race/stat modifiers. |
|
||||||
|
| `attack` | integer | No | `0` | Base attack before modifiers. |
|
||||||
|
| `defense` | integer | No | `0` | Base defense before modifiers. |
|
||||||
|
|
||||||
|
## `[growth]` — Per-level gains
|
||||||
|
|
||||||
|
| Field | Type | Required | Default | Description |
|
||||||
|
|-------|------|----------|---------|-------------|
|
||||||
|
| `hp_per_level` | integer | No | `0` | HP added each level. |
|
||||||
|
| `attack_per_level` | integer | No | `0` | Attack added each level. |
|
||||||
|
| `defense_per_level` | integer | No | `0` | Defense added each level. |
|
||||||
|
|
||||||
|
## Minimal example (hidden NPC class)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
name = "Peasant"
|
||||||
|
description = "A common folk with no particular training."
|
||||||
|
hidden = true
|
||||||
|
|
||||||
|
[base_stats]
|
||||||
|
max_hp = 50
|
||||||
|
attack = 4
|
||||||
|
defense = 4
|
||||||
|
|
||||||
|
[growth]
|
||||||
|
hp_per_level = 5
|
||||||
|
attack_per_level = 1
|
||||||
|
defense_per_level = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example with guild (playable class)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
name = "Warrior"
|
||||||
|
description = "Masters of arms and armor."
|
||||||
|
guild = "guild:warriors_guild"
|
||||||
|
|
||||||
|
[base_stats]
|
||||||
|
max_hp = 120
|
||||||
|
attack = 14
|
||||||
|
defense = 12
|
||||||
|
|
||||||
|
[growth]
|
||||||
|
hp_per_level = 15
|
||||||
|
attack_per_level = 3
|
||||||
|
defense_per_level = 2
|
||||||
|
```
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
name = "Cleric"
|
name = "Cleric"
|
||||||
description = "Devout healers and protectors, clerics channel divine power to mend and shield."
|
description = "Devout healers and protectors, clerics channel divine power to mend and shield."
|
||||||
|
guild = "guild:clerics_guild"
|
||||||
|
|
||||||
[base_stats]
|
[base_stats]
|
||||||
max_hp = 100
|
max_hp = 100
|
||||||
|
|||||||
13
world/classes/creature.toml
Normal file
13
world/classes/creature.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
name = "Creature"
|
||||||
|
description = "A wild thing that fights on instinct alone."
|
||||||
|
hidden = true
|
||||||
|
|
||||||
|
[base_stats]
|
||||||
|
max_hp = 30
|
||||||
|
attack = 6
|
||||||
|
defense = 2
|
||||||
|
|
||||||
|
[growth]
|
||||||
|
hp_per_level = 4
|
||||||
|
attack_per_level = 2
|
||||||
|
defense_per_level = 0
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
name = "Mage"
|
name = "Mage"
|
||||||
description = "Wielders of arcane power, mages trade resilience for devastating force."
|
description = "Wielders of arcane power, mages trade resilience for devastating force."
|
||||||
|
guild = "guild:mages_guild"
|
||||||
|
|
||||||
[base_stats]
|
[base_stats]
|
||||||
max_hp = 70
|
max_hp = 70
|
||||||
|
|||||||
13
world/classes/peasant.toml
Normal file
13
world/classes/peasant.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
name = "Peasant"
|
||||||
|
description = "A common folk with no particular training or aptitude for adventure."
|
||||||
|
hidden = true
|
||||||
|
|
||||||
|
[base_stats]
|
||||||
|
max_hp = 50
|
||||||
|
attack = 4
|
||||||
|
defense = 4
|
||||||
|
|
||||||
|
[growth]
|
||||||
|
hp_per_level = 5
|
||||||
|
attack_per_level = 1
|
||||||
|
defense_per_level = 1
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
name = "Rogue"
|
name = "Rogue"
|
||||||
description = "Quick and cunning, rogues strike from the shadows with lethal precision."
|
description = "Quick and cunning, rogues strike from the shadows with lethal precision."
|
||||||
|
guild = "guild:rogues_guild"
|
||||||
|
|
||||||
[base_stats]
|
[base_stats]
|
||||||
max_hp = 85
|
max_hp = 85
|
||||||
|
|||||||
13
world/classes/warden.toml
Normal file
13
world/classes/warden.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
name = "Warden"
|
||||||
|
description = "Vigilant defenders of the forest, wardens balance physical prowess with nature's magic."
|
||||||
|
guild = "guild:druids_guild"
|
||||||
|
|
||||||
|
[base_stats]
|
||||||
|
max_hp = 110
|
||||||
|
attack = 10
|
||||||
|
defense = 12
|
||||||
|
|
||||||
|
[growth]
|
||||||
|
hp_per_level = 12
|
||||||
|
attack_per_level = 2
|
||||||
|
defense_per_level = 3
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
name = "Warrior"
|
name = "Warrior"
|
||||||
description = "Masters of arms and armor, warriors lead the charge and hold the line."
|
description = "Masters of arms and armor, warriors lead the charge and hold the line."
|
||||||
|
guild = "guild:warriors_guild"
|
||||||
|
|
||||||
[base_stats]
|
[base_stats]
|
||||||
max_hp = 120
|
max_hp = 120
|
||||||
|
|||||||
58
world/guilds/GUILDS.md
Normal file
58
world/guilds/GUILDS.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Guild TOML Reference
|
||||||
|
|
||||||
|
Each file in `world/guilds/` defines one guild. The filename (without `.toml`) becomes the guild ID with prefix `guild:` (e.g. `warriors_guild.toml` → `guild:warriors_guild`).
|
||||||
|
|
||||||
|
## Top-level fields
|
||||||
|
|
||||||
|
Put these **before** any `[section]` headers so they are not parsed as part of a table.
|
||||||
|
|
||||||
|
| Field | Type | Required | Default | Description |
|
||||||
|
|-------|------|----------|---------|-------------|
|
||||||
|
| `name` | string | Yes | — | Display name of the guild. |
|
||||||
|
| `description` | string | Yes | — | Short description. |
|
||||||
|
| `max_level` | integer | No | `50` | Maximum guild level. |
|
||||||
|
| `resource` | string | No | `"mana"` | Primary resource: `"mana"` or `"endurance"`. |
|
||||||
|
| `base_mana` | integer | No | `0` | Mana granted when joining (and per new member level if used). |
|
||||||
|
| `base_endurance` | integer | No | `0` | Endurance granted when joining. |
|
||||||
|
| `spells` | array of strings | No | `[]` | Spell IDs (e.g. `"spell:power_strike"`) this guild grants. Order can matter for display. |
|
||||||
|
| `min_player_level` | integer | No | `0` | Minimum character level required to join. |
|
||||||
|
| `race_restricted` | array of strings | No | `[]` | Race IDs (e.g. `"race:dragon"`) that cannot join this guild. |
|
||||||
|
|
||||||
|
## `[growth]` — Per guild-level gains
|
||||||
|
|
||||||
|
| Field | Type | Required | Default | Description |
|
||||||
|
|-------|------|----------|---------|-------------|
|
||||||
|
| `hp_per_level` | integer | No | `0` | HP per guild level. |
|
||||||
|
| `mana_per_level` | integer | No | `0` | Mana per guild level. |
|
||||||
|
| `endurance_per_level` | integer | No | `0` | Endurance per guild level. |
|
||||||
|
| `attack_per_level` | integer | No | `0` | Attack per guild level. |
|
||||||
|
| `defense_per_level` | integer | No | `0` | Defense per guild level. |
|
||||||
|
|
||||||
|
## Important: TOML layout
|
||||||
|
|
||||||
|
Top-level keys such as `spells`, `min_player_level`, and `race_restricted` must appear **before** the `[growth]` section. Otherwise they are parsed as part of `[growth]` and ignored.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```toml
|
||||||
|
name = "Warriors Guild"
|
||||||
|
description = "Masters of martial combat."
|
||||||
|
max_level = 50
|
||||||
|
resource = "endurance"
|
||||||
|
base_mana = 0
|
||||||
|
base_endurance = 50
|
||||||
|
spells = ["spell:power_strike", "spell:battle_cry", "spell:shield_wall", "spell:whirlwind"]
|
||||||
|
min_player_level = 0
|
||||||
|
race_restricted = []
|
||||||
|
|
||||||
|
[growth]
|
||||||
|
hp_per_level = 8
|
||||||
|
mana_per_level = 0
|
||||||
|
endurance_per_level = 5
|
||||||
|
attack_per_level = 2
|
||||||
|
defense_per_level = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Spell IDs
|
||||||
|
|
||||||
|
Spell IDs are the spell’s file stem with prefix `spell:` (e.g. `world/spells/power_strike.toml` → `spell:power_strike`). Spells are defined in `world/spells/*.toml`; see `world/spells/SPELLS.md`.
|
||||||
16
world/guilds/clerics_guild.toml
Normal file
16
world/guilds/clerics_guild.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
name = "Clerics Guild"
|
||||||
|
description = "Channels of divine power, the Clerics Guild teaches the sacred arts of healing, protection, and righteous combat. A balance of support and resilience."
|
||||||
|
max_level = 50
|
||||||
|
resource = "mana"
|
||||||
|
base_mana = 40
|
||||||
|
base_endurance = 20
|
||||||
|
spells = ["spell:heal", "spell:smite", "spell:divine_shield", "spell:purify"]
|
||||||
|
min_player_level = 0
|
||||||
|
race_restricted = []
|
||||||
|
|
||||||
|
[growth]
|
||||||
|
hp_per_level = 6
|
||||||
|
mana_per_level = 5
|
||||||
|
endurance_per_level = 2
|
||||||
|
attack_per_level = 1
|
||||||
|
defense_per_level = 2
|
||||||
16
world/guilds/druids_guild.toml
Normal file
16
world/guilds/druids_guild.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
name = "Druids Guild"
|
||||||
|
description = "Guardians of the forest and masters of nature's magic. Members focus on defense, healing, and manipulating the natural world."
|
||||||
|
max_level = 50
|
||||||
|
resource = "mana"
|
||||||
|
base_mana = 50
|
||||||
|
base_endurance = 20
|
||||||
|
spells = ["spell:entangle", "spell:barkskin", "spell:thorns", "spell:heal"]
|
||||||
|
min_player_level = 0
|
||||||
|
race_restricted = []
|
||||||
|
|
||||||
|
[growth]
|
||||||
|
hp_per_level = 5
|
||||||
|
mana_per_level = 5
|
||||||
|
endurance_per_level = 2
|
||||||
|
attack_per_level = 2
|
||||||
|
defense_per_level = 3
|
||||||
16
world/guilds/mages_guild.toml
Normal file
16
world/guilds/mages_guild.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
name = "Mages Guild"
|
||||||
|
description = "Wielders of arcane forces, the Mages Guild unlocks the mysteries of magical power. Members sacrifice physical resilience for devastating magical attacks."
|
||||||
|
max_level = 50
|
||||||
|
resource = "mana"
|
||||||
|
base_mana = 60
|
||||||
|
base_endurance = 0
|
||||||
|
spells = ["spell:magic_missile", "spell:fireball", "spell:frost_shield", "spell:arcane_blast"]
|
||||||
|
min_player_level = 0
|
||||||
|
race_restricted = []
|
||||||
|
|
||||||
|
[growth]
|
||||||
|
hp_per_level = 3
|
||||||
|
mana_per_level = 8
|
||||||
|
endurance_per_level = 0
|
||||||
|
attack_per_level = 1
|
||||||
|
defense_per_level = 0
|
||||||
16
world/guilds/rogues_guild.toml
Normal file
16
world/guilds/rogues_guild.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
name = "Rogues Guild"
|
||||||
|
description = "Silent and deadly, the Rogues Guild teaches the arts of stealth, subterfuge, and precision strikes. Members trade raw power for cunning and speed."
|
||||||
|
max_level = 50
|
||||||
|
resource = "endurance"
|
||||||
|
base_mana = 0
|
||||||
|
base_endurance = 40
|
||||||
|
spells = ["spell:backstab", "spell:poison_blade", "spell:evasion", "spell:shadow_step"]
|
||||||
|
min_player_level = 0
|
||||||
|
race_restricted = []
|
||||||
|
|
||||||
|
[growth]
|
||||||
|
hp_per_level = 5
|
||||||
|
mana_per_level = 0
|
||||||
|
endurance_per_level = 6
|
||||||
|
attack_per_level = 3
|
||||||
|
defense_per_level = 0
|
||||||
16
world/guilds/warriors_guild.toml
Normal file
16
world/guilds/warriors_guild.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
name = "Warriors Guild"
|
||||||
|
description = "Masters of martial combat, the Warriors Guild trains its members in the art of physical warfare. Emphasizes strength, endurance, and weapon mastery."
|
||||||
|
max_level = 50
|
||||||
|
resource = "endurance"
|
||||||
|
base_mana = 0
|
||||||
|
base_endurance = 50
|
||||||
|
spells = ["spell:power_strike", "spell:battle_cry", "spell:shield_wall", "spell:whirlwind"]
|
||||||
|
min_player_level = 0
|
||||||
|
race_restricted = []
|
||||||
|
|
||||||
|
[growth]
|
||||||
|
hp_per_level = 8
|
||||||
|
mana_per_level = 0
|
||||||
|
endurance_per_level = 5
|
||||||
|
attack_per_level = 2
|
||||||
|
defense_per_level = 1
|
||||||
5
world/lawold/npcs/adwyn.toml
Normal file
5
world/lawold/npcs/adwyn.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name = "Adwyn"
|
||||||
|
description = "Adwyn has a round face, with curly grey hair and soft grey eyes. He wears modest garments and a pewter amulet. Adwyn is haunted by the memories of a past life."
|
||||||
|
room = "lawold:nobles_district_gilded_avenue"
|
||||||
|
race = "race:human"
|
||||||
|
base_attitude = "aggressive"
|
||||||
6
world/lawold/npcs/aenhild.toml
Normal file
6
world/lawold/npcs/aenhild.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Aenhild"
|
||||||
|
description = "Aenhild is short, with messy blonde hair and narrow amber eyes. She wears leather armor and wields a hammer and dagger. Aenhild is disorganized and mischievous."
|
||||||
|
room = "lawold:hell_hounds_farthing_streets"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:cleric"
|
||||||
|
base_attitude = "neutral"
|
||||||
5
world/lawold/npcs/altes.toml
Normal file
5
world/lawold/npcs/altes.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name = "Altes"
|
||||||
|
description = "Altes is short, with grey hair and narrow brown eyes. He wears fine raiment and jewelry. Altes has an animal companion, a tawny rat named Othert."
|
||||||
|
room = "lawold:nobles_district_gilded_avenue"
|
||||||
|
race = "race:human"
|
||||||
|
base_attitude = "aggressive"
|
||||||
6
world/lawold/npcs/arior.toml
Normal file
6
world/lawold/npcs/arior.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Arior"
|
||||||
|
description = "Arior is overweight, with long golden hair and brown eyes. She wears dark robes and wields a dagger. Arior holds a grudge against druids."
|
||||||
|
room = "lawold:servants_ward_sawmill"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:mage"
|
||||||
|
base_attitude = "aggressive"
|
||||||
6
world/lawold/npcs/arran.toml
Normal file
6
world/lawold/npcs/arran.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Arran"
|
||||||
|
description = "Arran has a square face, with silver hair and narrow blue eyes. She wears splint mail and wields a short sword and shield. Arran is stoic and adaptable."
|
||||||
|
room = "lawold:palace_village_devils_gallows"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:warrior"
|
||||||
|
base_attitude = "aggressive"
|
||||||
6
world/lawold/npcs/bagge.toml
Normal file
6
world/lawold/npcs/bagge.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Bagge"
|
||||||
|
description = "Bagge is tall, with thick brown hair and hazel eyes. He wears splint mail and wields a battle axe and shield. Bagge is charming but unforgiving."
|
||||||
|
room = "lawold:nobles_district_noble_gate"
|
||||||
|
race = "race:dwarf"
|
||||||
|
class = "class:warrior"
|
||||||
|
base_attitude = "friendly"
|
||||||
5
world/lawold/npcs/balli.toml
Normal file
5
world/lawold/npcs/balli.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name = "Balli"
|
||||||
|
description = "Balli has a round face, with copper hair and soft blue eyes. He wears tailored clothing and a sable fur cape. Balli seeks to discover what destroyed his homeland."
|
||||||
|
room = "lawold:demons_borough_ewip_tower_ruins"
|
||||||
|
race = "race:dwarf"
|
||||||
|
base_attitude = "aggressive"
|
||||||
5
world/lawold/npcs/bethel.toml
Normal file
5
world/lawold/npcs/bethel.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name = "Bethel"
|
||||||
|
description = "Bethel is tall, with auburn hair and green eyes. She wears simple clothing and a green cloak. Its hard to picture Bethel as an adventurer, but she owns more weapons than most knights of the royal guard."
|
||||||
|
room = "lawold:beggars_market_stalls"
|
||||||
|
race = "race:human"
|
||||||
|
base_attitude = "neutral"
|
||||||
15
world/lawold/npcs/breda.toml
Normal file
15
world/lawold/npcs/breda.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
name = "Breda"
|
||||||
|
description = "Breda is haughty in bearing, with thin auburn hair and narrow blue eyes. She wears simple clothing and several small tools hang from her belt. Breda will purchase monster teeth for a silver coin each."
|
||||||
|
room = "lawold:well_market_trade_stalls"
|
||||||
|
race = "race:human"
|
||||||
|
base_attitude = "neutral"
|
||||||
|
|
||||||
|
[dialogue]
|
||||||
|
greeting = "What do you want? I'm busy. Unless you have some teeth to sell?"
|
||||||
|
keywords = { teeth = "I buy monster teeth. One silver each. No questions asked.", tools = "My tools aren't for sale, but I have some spares if you have the coin." }
|
||||||
|
|
||||||
|
[shop]
|
||||||
|
buys = ["junk", "teeth", "tool"]
|
||||||
|
sells = ["town:small_hammer", "town:chisel"]
|
||||||
|
markup = 1.2
|
||||||
|
markdown = 0.5
|
||||||
6
world/lawold/npcs/brida.toml
Normal file
6
world/lawold/npcs/brida.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Brida"
|
||||||
|
description = "Brida is overweight, with blonde hair and dark green eyes. She wears modest garments and a copper amulet. Brida refers to herself in the third person."
|
||||||
|
room = "lawold:black_docks_quay"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:mage"
|
||||||
|
base_attitude = "aggressive"
|
||||||
5
world/lawold/npcs/brothge.toml
Normal file
5
world/lawold/npcs/brothge.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name = "Brothge"
|
||||||
|
description = "Brothge has short grey hair and large amber eyes, and numerous cruel scars. He wears simple clothing and carries a cedar staff. Brothge is rumored to be haunted by the ghost of a dragon."
|
||||||
|
room = "lawold:well_market_trade_stalls"
|
||||||
|
race = "race:human"
|
||||||
|
base_attitude = "aggressive"
|
||||||
6
world/lawold/npcs/burga.toml
Normal file
6
world/lawold/npcs/burga.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Burga"
|
||||||
|
description = "Burga has copper hair and light blue eyes, and a wide mouth. She wears fine clothing and a dragonscale cloak. Burga seeks only fame and glory."
|
||||||
|
room = "lawold:hell_hounds_farthing_burgas_pewter"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:mage"
|
||||||
|
base_attitude = "neutral"
|
||||||
6
world/lawold/npcs/cenga.toml
Normal file
6
world/lawold/npcs/cenga.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Cenga"
|
||||||
|
description = "Cenga is rough in appearance, with brown hair and green eyes. She wears leather armor and wields a club and dagger. Cenga is sarcastic and adaptable."
|
||||||
|
room = "lawold:arcanists_farthing_river_road"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:cleric"
|
||||||
|
base_attitude = "neutral"
|
||||||
6
world/lawold/npcs/cilia.toml
Normal file
6
world/lawold/npcs/cilia.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Cilia"
|
||||||
|
description = "Cilia has straight silver hair and brown eyes, and numerous jagged scars. She wears modest garments and several pouches hang from her belt. Cilia is heroic but lazy."
|
||||||
|
room = "lawold:servants_ward_quarter"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:warden"
|
||||||
|
base_attitude = "aggressive"
|
||||||
5
world/lawold/npcs/cuthburg.toml
Normal file
5
world/lawold/npcs/cuthburg.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name = "Cuthburg"
|
||||||
|
description = "Cuthburg has thick golden hair and brown eyes, and wears glasses with pewter rims. She wears well-made clothing and a dragonscale cloak. Cuthburg is impatient and disorganized."
|
||||||
|
room = "lawold:hell_hounds_ward_blue_staff_inn"
|
||||||
|
race = "race:human"
|
||||||
|
base_attitude = "neutral"
|
||||||
6
world/lawold/npcs/cynre.toml
Normal file
6
world/lawold/npcs/cynre.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Cynre"
|
||||||
|
description = "Cynre has red hair and dark grey eyes. He wears tailored clothing and an amulet of luminous crystal. Cynre is scheming but fair."
|
||||||
|
room = "lawold:servants_ward_masonry"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:cleric"
|
||||||
|
base_attitude = "friendly"
|
||||||
6
world/lawold/npcs/dainarv.toml
Normal file
6
world/lawold/npcs/dainarv.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Dainarv"
|
||||||
|
description = "Dainarv has blonde hair and dark grey eyes. He wears modest garments and a copper amulet. Dainarv is hunting the warlord who murdered his family."
|
||||||
|
room = "lawold:dale_village_gate"
|
||||||
|
race = "race:dwarf"
|
||||||
|
class = "class:mage"
|
||||||
|
base_attitude = "aggressive"
|
||||||
6
world/lawold/npcs/demund.toml
Normal file
6
world/lawold/npcs/demund.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Demund"
|
||||||
|
description = "Demund is common in appearance, with messy red hair and large hazel eyes. He wears studded leather and wields a mace and light crossbow. Demund has an animal companion, a hawk named Lafa."
|
||||||
|
room = "lawold:artists_district_lane"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:warrior"
|
||||||
|
base_attitude = "friendly"
|
||||||
5
world/lawold/npcs/dwoinarv.toml
Normal file
5
world/lawold/npcs/dwoinarv.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name = "Dwoinarv"
|
||||||
|
description = "Dwoinarv has golden hair and light blue eyes, and a thin nose. She wears simple clothing and several pouches hang from her belt. Dwoinarv is a practiced gambler."
|
||||||
|
room = "lawold:hell_hounds_ward_streets"
|
||||||
|
race = "race:dwarf"
|
||||||
|
base_attitude = "friendly"
|
||||||
6
world/lawold/npcs/edrarmir.toml
Normal file
6
world/lawold/npcs/edrarmir.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Edrarmir"
|
||||||
|
description = "Edrarmir is short and stout, with copper hair and light hazel eyes. He wears leather armor and wields a poisoned club and darts. Edrarmir is absent-minded and covetous."
|
||||||
|
room = "lawold:arcanists_farthing_moon_gate"
|
||||||
|
race = "race:elf"
|
||||||
|
class = "class:rogue"
|
||||||
|
base_attitude = "friendly"
|
||||||
6
world/lawold/npcs/efril.toml
Normal file
6
world/lawold/npcs/efril.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Efril"
|
||||||
|
description = "Efril has black hair and grey eyes, and wears glasses with brass rims. She wears leather armor and wields a short sword and dagger. Efril has a black snake named Sarry."
|
||||||
|
room = "lawold:arcanists_farthing_river_road"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:rogue"
|
||||||
|
base_attitude = "friendly"
|
||||||
5
world/lawold/npcs/elet.toml
Normal file
5
world/lawold/npcs/elet.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name = "Elet"
|
||||||
|
description = "Elet is stout, with copper hair and bright amber eyes. She wears modest garments and carries a long knife. Elet claims that her breads are baked in the golden flames of a faerie hearth."
|
||||||
|
room = "lawold:beggars_market_stalls"
|
||||||
|
race = "race:human"
|
||||||
|
base_attitude = "aggressive"
|
||||||
6
world/lawold/npcs/ered.toml
Normal file
6
world/lawold/npcs/ered.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Ered"
|
||||||
|
description = "Ered has black hair and light brown eyes, and a pattern of unusual marks on her face. She wears leather armor and wields a dagger. Ered has a raven named Rancis."
|
||||||
|
room = "lawold:hell_hounds_farthing_streets"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:rogue"
|
||||||
|
base_attitude = "friendly"
|
||||||
5
world/lawold/npcs/finta.toml
Normal file
5
world/lawold/npcs/finta.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name = "Finta"
|
||||||
|
description = "Finta has black hair and soft grey eyes. He wears modest garments and riding boots. Finta has a deadly allergy to horses."
|
||||||
|
room = "lawold:dale_village_gate"
|
||||||
|
race = "race:elf"
|
||||||
|
base_attitude = "friendly"
|
||||||
12
world/lawold/npcs/francis.toml
Normal file
12
world/lawold/npcs/francis.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name = "Francis"
|
||||||
|
description = "Francis has a long face, with messy copper hair and bright grey eyes. He wears tailored clothing and an amulet of luminous crystal. Francis seeks revenge against the sister who betrayed him."
|
||||||
|
room = "lawold:senate_hall"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:mage"
|
||||||
|
base_attitude = "neutral"
|
||||||
|
gold = 1
|
||||||
|
silver = 5
|
||||||
|
|
||||||
|
[dialogue]
|
||||||
|
greeting = "Welcome to the senate hall. Just stay out of my way."
|
||||||
|
keywords = { sister = "She was my kin. My flesh and blood. And she left me for dead.", revenge = "One day, she will understand the depth of her betrayal." }
|
||||||
6
world/lawold/npcs/fricio.toml
Normal file
6
world/lawold/npcs/fricio.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Fricio"
|
||||||
|
description = "Fricio is slender, with uneven white hair and brown eyes. He wears leather armor and wields a club and sling. Fricio is depressed and amoral."
|
||||||
|
room = "lawold:demons_borough_river_walk"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:rogue"
|
||||||
|
base_attitude = "neutral"
|
||||||
5
world/lawold/npcs/frobern.toml
Normal file
5
world/lawold/npcs/frobern.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name = "Frobern"
|
||||||
|
description = "Frobern has an angular face, with brown hair and sharp amber eyes. He wears fine raiment and jewelry. Frobern believes that he's being hunted by supernatural creatures."
|
||||||
|
room = "lawold:titans_wharf_docks"
|
||||||
|
race = "race:human"
|
||||||
|
base_attitude = "friendly"
|
||||||
11
world/lawold/npcs/gauwis.toml
Normal file
11
world/lawold/npcs/gauwis.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
name = "Gauwis"
|
||||||
|
description = "Gauwis is rugged in appearance, with black hair and brown eyes. He wears chain mail and wields a flail and short bow. Gauwis seeks to destroy the race of ogres."
|
||||||
|
room = "lawold:central_bridge"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:warrior"
|
||||||
|
base_attitude = "neutral"
|
||||||
|
gold = 1
|
||||||
|
|
||||||
|
[dialogue]
|
||||||
|
greeting = "Stay sharp. These lands are dangerous."
|
||||||
|
keywords = { ogres = "They are a blight. A plague on this world.", destroy = "Every one of them must be wiped from existence." }
|
||||||
6
world/lawold/npcs/gerey.toml
Normal file
6
world/lawold/npcs/gerey.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Gerey"
|
||||||
|
description = "Gerey has silver hair and bright blue eyes. He wears leather armor and wields a spear. Gerey is known for his jeweled sword, a gift from the Lord Mayor."
|
||||||
|
room = "lawold:beggars_market_square"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:warrior"
|
||||||
|
base_attitude = "friendly"
|
||||||
5
world/lawold/npcs/gifu.toml
Normal file
5
world/lawold/npcs/gifu.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name = "Gifu"
|
||||||
|
description = "Gifu is short and overweight, with copper hair and green eyes. She wears plain clothing and riding boots. Gifu also deals in forged documents and seals."
|
||||||
|
room = "lawold:saints_market_plaza"
|
||||||
|
race = "race:human"
|
||||||
|
base_attitude = "aggressive"
|
||||||
6
world/lawold/npcs/grarder.toml
Normal file
6
world/lawold/npcs/grarder.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Grarder"
|
||||||
|
description = "Grarder is common in appearance, with blonde hair and narrow brown eyes. He wears leather armor and wields a long sword and dagger. Grarder has a black cat named Evet."
|
||||||
|
room = "lawold:titans_wharf_docks"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:rogue"
|
||||||
|
base_attitude = "aggressive"
|
||||||
6
world/lawold/npcs/hone.toml
Normal file
6
world/lawold/npcs/hone.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Hone"
|
||||||
|
description = "Hone is exceptionally beautiful, with silver hair and green eyes. She wears leather armor and wields a dagger. Hone is fighting a war with the Thieves' Guild."
|
||||||
|
room = "lawold:beggars_market_alley"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:rogue"
|
||||||
|
base_attitude = "aggressive"
|
||||||
5
world/lawold/npcs/isan.toml
Normal file
5
world/lawold/npcs/isan.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name = "Isan"
|
||||||
|
description = "Isan is short, with thin silver hair and green eyes. She wears well-made clothing and carries an oak staff. Isan is known for her generosity to beggars and waifs."
|
||||||
|
room = "lawold:saints_market_cathedral"
|
||||||
|
race = "race:human"
|
||||||
|
base_attitude = "aggressive"
|
||||||
6
world/lawold/npcs/james.toml
Normal file
6
world/lawold/npcs/james.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "James"
|
||||||
|
description = "James is rough in appearance, with golden hair and brown eyes. He wears studded leather and wields a spear and shield. James is known for his jeweled sword, a gift from the Lord Mayor."
|
||||||
|
room = "lawold:beggars_market_square"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:warrior"
|
||||||
|
base_attitude = "neutral"
|
||||||
6
world/lawold/npcs/kakli.toml
Normal file
6
world/lawold/npcs/kakli.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Kakli"
|
||||||
|
description = "Kakli has black hair and green eyes. He wears splint mail and wields a quarterstaff. Kakli refers to himself in the third person."
|
||||||
|
room = "lawold:artists_district_diviners_hall"
|
||||||
|
race = "race:dwarf"
|
||||||
|
class = "class:cleric"
|
||||||
|
base_attitude = "aggressive"
|
||||||
5
world/lawold/npcs/kathon.toml
Normal file
5
world/lawold/npcs/kathon.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name = "Kathon"
|
||||||
|
description = "Kathon has red hair and dark grey eyes, and a malevolent smile of sharpened teeth. She wears fine raiment and jewelry. Kathon has an animal companion, a green firedrake named Robert."
|
||||||
|
room = "lawold:palace_village_avenue"
|
||||||
|
race = "race:human"
|
||||||
|
base_attitude = "aggressive"
|
||||||
5
world/lawold/npcs/kila.toml
Normal file
5
world/lawold/npcs/kila.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name = "Kila"
|
||||||
|
description = "Kila is tall and stout, with golden hair and dark grey eyes. He wears plain clothing and a wide-brimmed hat. Kila refers to himself in the third person."
|
||||||
|
room = "lawold:dale_village_gate"
|
||||||
|
race = "race:dwarf"
|
||||||
|
base_attitude = "aggressive"
|
||||||
6
world/lawold/npcs/lasym.toml
Normal file
6
world/lawold/npcs/lasym.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Lasym"
|
||||||
|
description = "Lasym is fey in appearance, with thick black hair and blue eyes. He wears leather armor and wields a bastard sword. Lasym seeks to become a vampire."
|
||||||
|
room = "lawold:titans_wharf_docks"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:warrior"
|
||||||
|
base_attitude = "aggressive"
|
||||||
5
world/lawold/npcs/lina.toml
Normal file
5
world/lawold/npcs/lina.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name = "Lina"
|
||||||
|
description = "Lina is stout, with brown hair and blue eyes. She wears fine raiment and an ermine fur cape. Lina has an animal companion, a copper firedrake named Guilodher."
|
||||||
|
room = "lawold:demons_borough_river_walk"
|
||||||
|
race = "race:elf"
|
||||||
|
base_attitude = "friendly"
|
||||||
6
world/lawold/npcs/maly.toml
Normal file
6
world/lawold/npcs/maly.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Maly"
|
||||||
|
description = "Maly has thick copper hair and green eyes, and a beaked nose. She wears modest garments and a dragonscale cloak. Maly is competitive and inquisitive."
|
||||||
|
room = "lawold:servants_ward_quarter"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:mage"
|
||||||
|
base_attitude = "neutral"
|
||||||
6
world/lawold/npcs/merey.toml
Normal file
6
world/lawold/npcs/merey.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Merey"
|
||||||
|
description = "Merey is short and overweight, with straight white hair and blue eyes. She wears chain mail and wields a bastard sword and shield. Merey has an animal companion, a black rat named Ware."
|
||||||
|
room = "lawold:dale_village_gate"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:warrior"
|
||||||
|
base_attitude = "aggressive"
|
||||||
6
world/lawold/npcs/mesym.toml
Normal file
6
world/lawold/npcs/mesym.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Mesym"
|
||||||
|
description = "Mesym is fair in appearance, with straight white hair and dark blue eyes. He wears leather armor and wields a dagger. Mesym has a black snake named Oelbeoth."
|
||||||
|
room = "lawold:hell_hounds_farthing_streets"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:rogue"
|
||||||
|
base_attitude = "neutral"
|
||||||
6
world/lawold/npcs/narder.toml
Normal file
6
world/lawold/npcs/narder.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Narder"
|
||||||
|
description = "Narder is beastly in appearance, with golden hair and sharp hazel eyes. He wears banded mail and wields a military fork. Narder seeks to discover who murdered his family."
|
||||||
|
room = "lawold:hell_hounds_ward_streets"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:warrior"
|
||||||
|
base_attitude = "aggressive"
|
||||||
6
world/lawold/npcs/rione.toml
Normal file
6
world/lawold/npcs/rione.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Rione"
|
||||||
|
description = "Rione is exceptionally beautiful, with brown hair and grey eyes. She wears chain mail and wields a battle axe and shield. Rione seeks to continue the noble legacy of her father."
|
||||||
|
room = "lawold:demons_borough_river_walk"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:warrior"
|
||||||
|
base_attitude = "friendly"
|
||||||
5
world/lawold/npcs/ryellia.toml
Normal file
5
world/lawold/npcs/ryellia.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name = "Ryellia"
|
||||||
|
description = "Ryellia has thin golden hair and green eyes, and an unusual scar on her leg. She wears worn clothing and several pouches hang from her belt. Ryellia dislikes everyone except other humans."
|
||||||
|
room = "lawold:lighthouse_promenade"
|
||||||
|
race = "race:human"
|
||||||
|
base_attitude = "neutral"
|
||||||
10
world/lawold/npcs/saege.toml
Normal file
10
world/lawold/npcs/saege.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
name = "Saege"
|
||||||
|
description = "Saege has auburn hair and blue eyes. He wears modest garments and an iron amulet. Saege is rumored to lead a cult of a draconic god."
|
||||||
|
room = "lawold:well_market_square"
|
||||||
|
race = "race:human"
|
||||||
|
base_attitude = "friendly"
|
||||||
|
silver = 10
|
||||||
|
|
||||||
|
[dialogue]
|
||||||
|
greeting = "Greetings, traveler. May the iron amulet protect you."
|
||||||
|
keywords = { cult = "Cult? You must be misinformed. We are but humble followers.", god = "The dragon god of old is powerful beyond your reckoning." }
|
||||||
6
world/lawold/npcs/sane.toml
Normal file
6
world/lawold/npcs/sane.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Sane"
|
||||||
|
description = "Sane has braided auburn hair and amber eyes, and numerous animal tattoos. She wears banded mail and wields a hammer. Sane is intuitive and reflective."
|
||||||
|
room = "lawold:arcanists_farthing_laboratories"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:cleric"
|
||||||
|
base_attitude = "friendly"
|
||||||
6
world/lawold/npcs/sanzir.toml
Normal file
6
world/lawold/npcs/sanzir.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Sanzir"
|
||||||
|
description = "Sanzir is short, with straight blonde hair and soft amber eyes. He wears chain mail and wields a battle axe. Sanzir is perverted but charming."
|
||||||
|
room = "lawold:black_docks_quay"
|
||||||
|
race = "race:dwarf"
|
||||||
|
class = "class:warden"
|
||||||
|
base_attitude = "neutral"
|
||||||
12
world/lawold/npcs/sunna.toml
Normal file
12
world/lawold/npcs/sunna.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name = "Sunna"
|
||||||
|
description = "Sunna has a long face, with silver hair and soft brown eyes. She wears studded leather and wields a mace. Sunna seeks to prove herself to her peers."
|
||||||
|
room = "lawold:palace_village_palace_gate"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:warden"
|
||||||
|
base_attitude = "friendly"
|
||||||
|
silver = 2
|
||||||
|
copper = 5
|
||||||
|
|
||||||
|
[dialogue]
|
||||||
|
greeting = "Greetings. I am Sunna, a warden of the palace gate."
|
||||||
|
keywords = { prove = "I must prove that I am worthy of this post.", peers = "Many of my peers think I am too soft for this work." }
|
||||||
6
world/lawold/npcs/suse.toml
Normal file
6
world/lawold/npcs/suse.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Suse"
|
||||||
|
description = "Suse has a long face, with curly black hair and large brown eyes. She wears fine clothing and numerous rings. Suse has an animal companion, a hunting dog named Eoswulf."
|
||||||
|
room = "lawold:senate_plaza"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:cleric"
|
||||||
|
base_attitude = "aggressive"
|
||||||
6
world/lawold/npcs/swulfa.toml
Normal file
6
world/lawold/npcs/swulfa.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Swulfa"
|
||||||
|
description = "Swulfa has messy white hair and hazel eyes, and a thin mouth. He wears scale mail and wields a battle axe and javelins. Swulfa is haunted by the ghost of someone he killed."
|
||||||
|
room = "lawold:titans_wharf_warehouses"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:warrior"
|
||||||
|
base_attitude = "neutral"
|
||||||
11
world/lawold/npcs/thosve.toml
Normal file
11
world/lawold/npcs/thosve.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
name = "Thosve"
|
||||||
|
description = "Thosve is exceptionally beautiful, with red hair and soft green eyes. She wears plate mail and wields a two-handed sword. Thosve seeks to make amends for a life accidentally taken."
|
||||||
|
room = "lawold:artists_district_lane"
|
||||||
|
race = "race:dwarf"
|
||||||
|
class = "class:warrior"
|
||||||
|
base_attitude = "friendly"
|
||||||
|
silver = 3
|
||||||
|
|
||||||
|
[dialogue]
|
||||||
|
greeting = "Greetings. I am Thosve."
|
||||||
|
keywords = { amends = "We all carry burdens. Some heavier than others.", life = "It was a mistake. But it cost a life. A life I cannot give back." }
|
||||||
6
world/lawold/npcs/walde.toml
Normal file
6
world/lawold/npcs/walde.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Walde"
|
||||||
|
description = "Walde has an angular face, with cropped brown hair and grey eyes. He wears chain mail and wields a ranseur. Walde is impulsive and shrewd."
|
||||||
|
room = "lawold:demons_borough_river_walk"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:warrior"
|
||||||
|
base_attitude = "aggressive"
|
||||||
6
world/lawold/npcs/wine.toml
Normal file
6
world/lawold/npcs/wine.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Wine"
|
||||||
|
description = "Wine is youthful in appearance, with black hair and blue eyes. He wears simple clothing and wields a quarterstaff and sling. Wine is haunted by the memories of a past life."
|
||||||
|
room = "lawold:lighthouse_celestial_pavilion"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:mage"
|
||||||
|
base_attitude = "friendly"
|
||||||
16
world/lawold/npcs/wisym.toml
Normal file
16
world/lawold/npcs/wisym.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
name = "Wisym"
|
||||||
|
description = "Wisym is fair in appearance, with silver hair and sharp green eyes. He wears sturdy clothing and several small tools hang from his belt. Wisym is known for his stout breads, favored by the town guards."
|
||||||
|
room = "lawold:saints_market_plaza"
|
||||||
|
race = "race:human"
|
||||||
|
base_attitude = "neutral"
|
||||||
|
silver = 20
|
||||||
|
|
||||||
|
[dialogue]
|
||||||
|
greeting = "Welcome to my shop. I have the freshest bread in Lawold."
|
||||||
|
keywords = { bread = "My bread is hearty and stays fresh for days. The guards love it." }
|
||||||
|
|
||||||
|
[shop]
|
||||||
|
buys = ["food"]
|
||||||
|
sells = ["town:healing_potion"]
|
||||||
|
markup = 1.5
|
||||||
|
markdown = 0.5
|
||||||
5
world/lawold/npcs/witha.toml
Normal file
5
world/lawold/npcs/witha.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name = "Witha"
|
||||||
|
description = "Witha has brown hair and narrow brown eyes. She wears fine clothing and a feathered hat. Witha is lustful and timid."
|
||||||
|
room = "lawold:demons_borough_pottery"
|
||||||
|
race = "race:human"
|
||||||
|
base_attitude = "neutral"
|
||||||
6
world/lawold/npcs/wulfgiua.toml
Normal file
6
world/lawold/npcs/wulfgiua.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = "Wulfgiua"
|
||||||
|
description = "Wulfgiua is stout, with red hair and brown eyes. She wears modest garments and a copper amulet. Wulfgiua has an animal companion, a hawk named Ennet."
|
||||||
|
room = "lawold:dale_village_gate"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:cleric"
|
||||||
|
base_attitude = "friendly"
|
||||||
11
world/lawold/npcs/wyna.toml
Normal file
11
world/lawold/npcs/wyna.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
name = "Wyna"
|
||||||
|
description = "Wyna is pleasant in appearance, with curly white hair and grey eyes. She wears leather armor and wields a poisoned short sword and dagger. Wyna was magically imprisoned for a hundred years."
|
||||||
|
room = "lawold:senate_plaza"
|
||||||
|
race = "race:human"
|
||||||
|
class = "class:rogue"
|
||||||
|
base_attitude = "friendly"
|
||||||
|
silver = 5
|
||||||
|
|
||||||
|
[dialogue]
|
||||||
|
greeting = "It is good to see the sun again."
|
||||||
|
keywords = { imprisoned = "It felt like a long, dark dream.", century = "A hundred years have passed since I last saw this world." }
|
||||||
2
world/lawold/region.toml
Normal file
2
world/lawold/region.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
name = "Lawold"
|
||||||
|
description = "A coastal capital city situated along a river, governed by the Golden Senate."
|
||||||
10
world/lawold/rooms/arcanists_farthing_laboratories.toml
Normal file
10
world/lawold/rooms/arcanists_farthing_laboratories.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
name = "Arcanist's Laboratories"
|
||||||
|
description = """\
|
||||||
|
A series of connected stone buildings with bluish slate roofs house \
|
||||||
|
the city's many alchemists and mages. The brown dirt roads between the \
|
||||||
|
buildings are stained with colorful residues, and the air hums with \
|
||||||
|
latent magical energy. The massive black city wall is visible to the \
|
||||||
|
north, protecting the district."""
|
||||||
|
|
||||||
|
[exits]
|
||||||
|
west = "lawold:arcanists_farthing_river_road"
|
||||||
10
world/lawold/rooms/arcanists_farthing_moon_gate.toml
Normal file
10
world/lawold/rooms/arcanists_farthing_moon_gate.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
name = "The Moon Gate"
|
||||||
|
description = """\
|
||||||
|
A large hemisphere of pearlescent white stone stands at the end of the \
|
||||||
|
road. The stone sometimes becomes ghostly and translucent under the \
|
||||||
|
light of a full moon. Dense clusters of stone buildings with bluish \
|
||||||
|
slate roofs surround the gate, their windows often glowing with \
|
||||||
|
strange, magical lights."""
|
||||||
|
|
||||||
|
[exits]
|
||||||
|
south = "lawold:arcanists_farthing_river_road"
|
||||||
12
world/lawold/rooms/arcanists_farthing_river_road.toml
Normal file
12
world/lawold/rooms/arcanists_farthing_river_road.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name = "Arcanist's Farthing River Road"
|
||||||
|
description = """\
|
||||||
|
A road of well-trodden brown cobblestones runs alongside the river, \
|
||||||
|
lined with tall, narrow buildings with bluish slate roofs. This is \
|
||||||
|
the district of the learned and the magical. To the north, the river \
|
||||||
|
flows towards the sea, while to the south, the road leads to the \
|
||||||
|
Central Bridge."""
|
||||||
|
|
||||||
|
[exits]
|
||||||
|
north = "lawold:arcanists_farthing_moon_gate"
|
||||||
|
south = "lawold:central_bridge"
|
||||||
|
east = "lawold:arcanists_farthing_laboratories"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user