Compare commits

...

33 Commits

Author SHA1 Message Date
394a9b8355 Merge pull request 'Implement robust logging and enhanced event coverage' (#6) from feature/robust-logging into main
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m27s
Reviewed-on: #6
2026-03-19 21:16:40 -06:00
7bab50b431 Merge branch 'main' into feature/robust-logging
All checks were successful
Smoke tests / Build and smoke test (pull_request) Successful in 1m28s
Smoke tests / Build and smoke test (push) Successful in 1m28s
2026-03-19 21:11:44 -06:00
AI Agent
b81362d4d1 Implement robust logging with flexi_logger and update CI to verify logs
Some checks failed
Smoke tests / Build and smoke test (pull_request) Successful in 1m30s
Smoke tests / Build and smoke test (push) Has been cancelled
2026-03-19 21:08:04 -06:00
93b1c1e301 Merge pull request 'Add tests for weather and time of day system' (#5) from feature/weather-time into main
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m23s
Reviewed-on: #5
2026-03-19 17:43:52 -06:00
d9f2929c0c Merge branch 'main' into feature/weather-time
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m24s
Smoke tests / Build and smoke test (pull_request) Successful in 1m25s
2026-03-19 17:38:05 -06:00
AI Agent
2689f9e29e Add tests for weather and time of day system
Some checks failed
Smoke tests / Build and smoke test (push) Has been cancelled
Smoke tests / Build and smoke test (pull_request) Successful in 1m23s
2026-03-19 17:01:31 -06:00
8b49ef2c46 Merge pull request 'Implement weather and time of day system' (#4) from feature/weather-time into main
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m22s
2026-03-19 17:00:04 -06:00
AI Agent
1f4955db82 Implement weather and time of day system
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m20s
Smoke tests / Build and smoke test (pull_request) Successful in 1m24s
2026-03-19 16:58:06 -06:00
03122f2901 Merge pull request 'Feature: Dynamic command discovery and enhanced RPC testing' (#3) from feature/dynamic-commands into main
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m20s
Reviewed-on: #3
2026-03-19 16:08:59 -06:00
AI Agent
678543dd9a Merge origin/main into feature/dynamic-commands
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m20s
Smoke tests / Build and smoke test (pull_request) Successful in 1m23s
Restore multi-step smoke workflow (openssh-client, netcat, wait-for-tcp).
Resolve run-tests.sh and TESTING.md with feature JSON-RPC coverage; add
matching Smoke - JSON-RPC list_commands workflow step.

Made-with: Cursor
2026-03-19 15:56:45 -06:00
AI Agent
0914b5a32b Replace sleep with wait-for-tcp script in run-tests.sh and CI workflow to improve reliability of smoke tests. Update TESTING.md to include prerequisites for the new script.
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m17s
2026-03-19 15:47:21 -06:00
AI Agent
1a545bbae7 Refactor run-tests.sh to source individual test scripts and update TESTING.md for clarity on smoke test execution. CI now sets SKIP_SMOKE_BUILD to optimize the workflow.
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m18s
2026-03-19 15:39:32 -06:00
AI Agent
3a2a606c4a needed tools
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m12s
2026-03-19 15:30:21 -06:00
AI Agent
f183daa16c Feature: dynamic command discovery for JSON-RPC and enhanced testing
Some checks failed
Smoke tests / Build and smoke test (push) Failing after 1m2s
2026-03-19 15:22:39 -06:00
3baa0091f9 Merge pull request 'Feature: Shops, Economy and Enhanced NPC Interactions' (#2) from feature/shops-and-interactions into main
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m2s
Reviewed-on: #2
2026-03-19 09:59:10 -06:00
AI Agent
87baaee46f Finalize shops, interactions and world data
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m53s
2026-03-19 08:12:28 -06:00
AI Agent
52b333fa48 Update world data with dialogue, currency, and shop inventories 2026-03-17 13:34:36 -06:00
AI Agent
0722a2f1d7 Implement currency, shops, and enhanced NPC interaction system 2026-03-17 13:31:33 -06:00
AI Agent
2e1794b799 Fix: Rename unused jsonrpc field to _jsonrpc to silence warning
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m1s
2026-03-17 10:14:31 -06:00
df757ba37d Merge pull request 'Fix smoke tests and resolve CI timeouts' (#1) from mcp-integration into main
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m6s
Reviewed-on: #1
2026-03-17 10:00:19 -06:00
AI Agent
ebdfa16aa5 Fix smoke tests: update movement for new spawn point and resolve Test 6 timeout
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m49s
2026-03-17 09:53:01 -06:00
AI Agent
dd517d8851 Add JSON-RPC interface and refactor for MCP support
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1h0m43s
2026-03-16 19:19:21 -06:00
AI Agent
4e41038555 Implement Lawold capital city: 49 rooms, 59 NPCs, and full world integration
Some checks failed
Smoke tests / Build and smoke test (push) Has been cancelled
2026-03-16 13:51:36 -06:00
def077c645 fix
Some checks failed
Smoke tests / Build and smoke test (push) Has been cancelled
2026-03-14 18:39:06 -06:00
AI Agent
2157b45486 Enhance error handling in ssh_mud function by temporarily disabling exit on error. This allows for graceful handling of MUD connection closures without terminating the script prematurely.
Some checks failed
Smoke tests / Build and smoke test (push) Has been cancelled
2026-03-14 18:32:54 -06:00
AI Agent
f2f1699351 Enable verbose output in run-tests.sh by adding the '-x' flag to the set command for improved debugging.
Some checks failed
Smoke tests / Build and smoke test (push) Failing after 34s
2026-03-14 18:30:58 -06:00
AI Agent
014730e2f7 Refactor SSH command in run-tests.sh to handle MUD connection closure gracefully
Some checks failed
Smoke tests / Build and smoke test (push) Failing after 34s
- Introduced a new function `ssh_mud` to encapsulate SSH command execution and treat exit code 255 as a success.
- Updated all SSH calls in the test script to use the new function for improved error handling.
2026-03-14 18:29:19 -06:00
AI Agent
b5fb56c50c Update smoke-tests.yml to install Rust using rustup instead of apt
Some checks failed
Smoke tests / Build and smoke test (push) Failing after 34s
2026-03-14 18:25:33 -06:00
AI Agent
a2ffee0f94 Update documentation and CI to include world validation checks
Some checks failed
Smoke tests / Build and smoke test (push) Failing after 34s
- Added a note in AGENTS.md about using `mudtool validate -w ./world` for schema validation before committing.
- Updated TESTING.md to include a checklist item for the new validation command.
- Modified smoke-tests.yml to run the world validation command as part of the CI workflow.
2026-03-14 18:22:34 -06:00
AI Agent
93862c3c34 Add world validation command to mudtool
Some checks failed
Smoke tests / Build and smoke test (push) Failing after 16s
- Introduced a new command `validate` to check the integrity of world data, ensuring all referenced entities (NPCs, objects, guilds, races, classes, spells) exist and have valid attributes.
- Updated help message to include usage of the new command and its options.
- Added support for specifying a world directory via command line argument.
2026-03-14 18:18:58 -06:00
AI Agent
7c50bbf01a fix tests
Some checks failed
Smoke tests / Build and smoke test (push) Failing after 52s
2026-03-14 18:15:58 -06:00
AI Agent
9c286823e6 Update TESTING.md to include prerequisites for running tests, specifying the need for the Rust toolchain and SSH client.
Some checks failed
Smoke tests / Build and smoke test (push) Failing after 0s
2026-03-14 18:06:54 -06:00
AI Agent
09ff51c2b0 sometests 2026-03-14 17:54:12 -06:00
136 changed files with 2363 additions and 370 deletions

View 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
View File

@@ -2,3 +2,5 @@
*.db
*.db-shm
*.db-wal
/logs
/manual_logs

View File

@@ -131,6 +131,7 @@ src/
2. NPCs without a `[combat]` section get default stats (20hp/4atk/2def/5xp)
3. Room IDs are `<region_dir>:<filename_stem>`
4. Cross-region exits work — just reference the full ID
5. Run `mudtool validate -w ./world` to check schemas, references, and values before committing
### New DB table
1. Add `CREATE TABLE IF NOT EXISTS` in `SqliteDb::open()`

176
Cargo.lock generated
View File

@@ -61,56 +61,6 @@ dependencies = [
"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]]
name = "anyhow"
version = "1.0.102"
@@ -335,12 +285,6 @@ dependencies = [
"inout",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "compact_str"
version = "0.9.0"
@@ -385,6 +329,30 @@ dependencies = [
"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]]
name = "crossterm"
version = "0.28.1"
@@ -694,29 +662,6 @@ dependencies = [
"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]]
name = "equivalent"
version = "1.0.2"
@@ -809,6 +754,21 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "fnv"
version = "1.0.7"
@@ -1166,12 +1126,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.14.0"
@@ -1187,30 +1141,6 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "js-sys"
version = "0.3.91"
@@ -1387,9 +1317,11 @@ name = "mudserver"
version = "0.2.0"
dependencies = [
"crossterm 0.28.1",
"env_logger",
"flexi_logger",
"log",
"rand",
"ratatui",
"regex",
"rusqlite",
"russh",
"serde",
@@ -1421,6 +1353,15 @@ dependencies = [
"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]]
name = "num-bigint"
version = "0.4.6"
@@ -1510,12 +1451,6 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
@@ -1813,15 +1748,6 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "powerfmt"
version = "0.2.0"

View File

@@ -13,4 +13,6 @@ rusqlite = { version = "0.35", features = ["bundled"] }
ratatui = "0.30"
crossterm = "0.28"
log = "0.4"
env_logger = "0.11"
flexi_logger = { version = "0.29", features = ["async"] }
regex = "1"
rand = "0.8"

View File

@@ -1,6 +1,10 @@
# Planned Features
## Completed
Tracking document for features and content planned for the MUD server. No implementation order implied unless noted. Grouped by difficulty (effort / scope).
- **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
@@ -16,9 +20,7 @@ Content-only or minimal code; add TOML/data and existing systems already support
New state, commands, or mechanics with bounded scope.
- **Weather** — Weather system (e.g., rain, snow, fog) affecting areas or atmosphere; scope TBD.
- **Day/night or time of day** — Time cycle affecting room descriptions, spawns, or NPC behavior; lighter than full weather.
- **Shops / economy** — NPCs that buy and sell; currency and pricing (new fields/tables, trade commands).
- **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.

View File

@@ -1,6 +1,18 @@
# 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
- [ ] `cargo build` succeeds with no errors
@@ -101,6 +113,13 @@ Run through these checks before every commit to ensure consistent feature covera
- [ ] Negative status effects cleared on player death/respawn
- [ ] 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
@@ -206,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
## MUD Tool - CLI
- [ ] `mudtool validate -w ./world` checks world data (schemas, references, values)
- [ ] `mudtool players list` shows all players
- [ ] `mudtool players show <name>` shows details
- [ ] `mudtool players set-admin <name> true` works
@@ -225,79 +245,13 @@ Run through these checks before every commit to ensure consistent feature covera
- [ ] ←→ switches player on Attitudes tab
- [ ] '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
```bash
# 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
```
**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.

123
run-tests.sh Executable file
View 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
View 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

View File

@@ -74,6 +74,7 @@ async fn admin_promote(target: &str, state: &SharedState) -> CommandResult {
if target.is_empty() {
return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin promote <player>")));
}
log::info!("Admin action: promote player '{}'", target);
let st = state.lock().await;
if st.db.set_admin(target, true) {
// Also update in-memory if online
@@ -89,14 +90,15 @@ async fn admin_promote(target: &str, state: &SharedState) -> CommandResult {
)
.as_bytes(),
);
if let (Some(ch), Some(h)) = (conn.channel, &conn.handle) {
return CommandResult {
output: format!(
"{}\r\n",
ansi::system_msg(&format!("{target} has been promoted to admin."))
),
broadcasts: vec![BroadcastMsg {
channel: conn.channel,
handle: conn.handle.clone(),
channel: ch,
handle: h.clone(),
data: msg,
}],
kick_targets: Vec::new(),
@@ -104,6 +106,7 @@ async fn admin_promote(target: &str, state: &SharedState) -> CommandResult {
};
}
}
}
simple(&format!(
"{}\r\n",
ansi::system_msg(&format!(
@@ -122,6 +125,7 @@ async fn admin_demote(target: &str, state: &SharedState) -> CommandResult {
if target.is_empty() {
return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin demote <player>")));
}
log::info!("Admin action: demote player '{}'", target);
let st = state.lock().await;
if st.db.set_admin(target, false) {
simple(&format!(
@@ -140,6 +144,7 @@ async fn admin_kick(target: &str, player_id: usize, state: &SharedState) -> Comm
if target.is_empty() {
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 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
.players_in_room(&room_id, player_id)
.iter()
.map(|p| BroadcastMsg {
channel: p.channel,
handle: p.handle.clone(),
.filter_map(|p| {
if let (Some(ch), Some(h)) = (p.channel, &p.handle) {
Some(BroadcastMsg {
channel: ch,
handle: h.clone(),
data: departure.clone(),
})
} else {
None
}
})
.collect();
// 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 {
channel: c.channel,
handle: c.handle.clone(),
channel: ch,
handle: h.clone(),
data: kick_msg,
});
kick_targets.push(KickTarget {
channel: ch,
handle: h.clone(),
});
}
CommandResult {
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."))
),
broadcasts: bcast,
kick_targets: vec![KickTarget {
channel: c.channel,
handle: c.handle.clone(),
}],
kick_targets,
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>")
));
}
log::info!("Admin action: teleport player ID {} to '{}'", player_id, room_id);
let mut st = state.lock().await;
if st.world.get_room(room_id).is_none() {
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
.players_in_room(&old_rid, player_id)
.iter()
.map(|c| BroadcastMsg {
channel: c.channel,
handle: c.handle.clone(),
.filter_map(|c| {
if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
Some(BroadcastMsg {
channel: ch,
handle: h.clone(),
data: leave.clone(),
})
} else {
None
}
})
.collect();
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(),
);
for c in st.players_in_room(room_id, player_id) {
if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
bcast.push(BroadcastMsg {
channel: c.channel,
handle: c.handle.clone(),
channel: ch,
handle: h.clone(),
data: arrive.clone(),
});
}
}
st.save_player_to_db(player_id);
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 {
log::info!("Admin action: registration setting updated: '{}'", args);
let st = state.lock().await;
match args.to_lowercase().as_str() {
"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>")
));
}
log::info!("Admin action: announcement by player ID {}: '{}'", player_id, msg);
let st = state.lock().await;
let announcement = CryptoVec::from(
format!(
@@ -360,11 +386,17 @@ async fn admin_announce(msg: &str, player_id: usize, state: &SharedState) -> Com
.players
.iter()
.filter(|(&id, _)| id != player_id)
.map(|(_, c)| BroadcastMsg {
channel: c.channel,
handle: c.handle.clone(),
.filter_map(|(_, c)| {
if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
Some(BroadcastMsg {
channel: ch,
handle: h.clone(),
data: announcement.clone(),
})
} else {
None
}
})
.collect();
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 {
log::info!("Admin action: heal player '{}' (empty means self)", args);
let mut st = state.lock().await;
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;
let name = c.player.name.clone();
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(
format!(
"\r\n{}\r\n{}",
@@ -416,11 +451,12 @@ async fn admin_heal(args: &str, player_id: usize, state: &SharedState) -> Comman
)
.as_bytes(),
);
let bcast = vec![BroadcastMsg {
channel: c.channel,
handle: c.handle.clone(),
bcast.push(BroadcastMsg {
channel: ch,
handle: h.clone(),
data: notify,
}];
});
}
let _ = c;
st.save_player_to_db(tid);
return CommandResult {
@@ -535,6 +571,7 @@ async fn admin_info(target: &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();
if parts.len() < 3 {
return simple(&format!(

View File

@@ -9,11 +9,12 @@ use ratatui::prelude::*;
use ratatui::widgets::*;
use mudserver::db::{GameDb, NpcAttitudeRow, SavedPlayer, SqliteDb};
use mudserver::world::Attitude;
use mudserver::world::{Attitude, World};
fn main() {
let args: Vec<String> = std::env::args().collect();
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 i = 1;
@@ -23,6 +24,10 @@ fn main() {
i += 1;
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" => {
print_help();
return;
@@ -32,6 +37,16 @@ fn main() {
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) {
Ok(db) => db,
Err(e) => {
@@ -40,11 +55,6 @@ fn main() {
}
};
if cmd_args.is_empty() {
print_help();
return;
}
match cmd_args[0].as_str() {
"tui" => run_tui(db),
"players" => cmd_players(&db, &cmd_args[1..]),
@@ -60,9 +70,10 @@ fn main() {
fn print_help() {
eprintln!("mudtool - MUD Server Database Manager");
eprintln!();
eprintln!("Usage: mudtool [--db <path>] <command> [args...]");
eprintln!("Usage: mudtool [OPTIONS] <command> [args...]");
eprintln!();
eprintln!("Commands:");
eprintln!(" validate Validate world data (schemas, references, values)");
eprintln!(" tui Interactive TUI editor");
eprintln!(" players list List all players");
eprintln!(" players show <name> Show player details");
@@ -76,6 +87,98 @@ fn print_help() {
eprintln!();
eprintln!("Options:");
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 ============

View File

@@ -69,6 +69,8 @@ pub fn resolve_combat_tick(
));
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) {
inst.alive = false;
inst.hp = 0;
@@ -76,17 +78,24 @@ pub fn resolve_combat_tick(
}
npc_died = true;
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!(
" {} {} collapses! You gain {} XP.\r\n",
" {} {} collapses! You gain {} XP and {}g {}s {}c.\r\n",
ansi::color(ansi::GREEN, "**"),
ansi::color(ansi::RED, &npc_template.name),
ansi::bold(&xp_gained.to_string()),
gold_gained, silver_gained, copper_gained
));
if let Some(conn) = state.players.get_mut(&player_id) {
conn.combat = None;
conn.player.stats.xp += xp_gained;
conn.player.gold += gold_gained;
conn.player.silver += silver_gained;
conn.player.copper += copper_gained;
}
} else {
if let Some(inst) = state.npc_instances.get_mut(&npc_id) {
@@ -344,7 +353,9 @@ pub fn player_death_respawn(player_id: usize, state: &mut GameState) -> String {
.players
.get(&player_id)
.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) {
conn.player.stats.hp = conn.player.stats.max_hp;

View File

@@ -42,17 +42,42 @@ fn resolve_dir(input: &str) -> &str {
input
}
pub async fn execute(
pub async fn execute_for_ssh(
input: &str,
player_id: usize,
state: &SharedState,
session: &mut Session,
channel: ChannelId,
) -> Result<bool, russh::Error> {
let result = execute(input, player_id, state).await;
send(session, channel, &result.output)?;
for msg in result.broadcasts {
let _ = msg.handle.data(msg.channel, msg.data).await;
}
for kick in result.kick_targets {
let _ = kick.handle.close(kick.channel).await;
}
if result.quit {
return Ok(false);
}
send(session, channel, &ansi::prompt())?;
Ok(true)
}
pub async fn execute(
input: &str,
player_id: usize,
state: &SharedState,
) -> CommandResult {
let input = input.trim();
if input.is_empty() {
send(session, channel, &ansi::prompt())?;
return Ok(true);
return CommandResult {
output: ansi::prompt(),
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
};
}
let (cmd, args) = match input.split_once(' ') {
@@ -72,24 +97,23 @@ pub async fn execute(
| "spells" | "skills" | "quit" | "exit"
)
{
drop(st);
send(
session,
channel,
&format!(
return CommandResult {
output: format!(
"{}\r\n{}",
ansi::error_msg(
"You're in combat! Use 'attack', 'defend', 'flee', 'cast', 'use', 'look', 'stats', or 'inventory'."
),
ansi::prompt()
),
)?;
return Ok(true);
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
};
}
}
}
let result = match cmd.as_str() {
match cmd.as_str() {
"look" | "l" => cmd_look(player_id, &args, state).await,
"go" => cmd_go(player_id, &args, state).await,
"north" | "south" | "east" | "west" | "up" | "down" | "n" | "s" | "e" | "w" | "u"
@@ -110,6 +134,7 @@ pub async fn execute(
"spells" | "skills" => cmd_spells(player_id, state).await,
"guild" => cmd_guild(player_id, &args, state).await,
"stats" | "st" => cmd_stats(player_id, state).await,
"shop" => cmd_shop(player_id, &args, state).await,
"admin" => cmd_admin(player_id, &args, state).await,
"help" | "h" | "?" => cmd_help(player_id, state).await,
"quit" | "exit" => CommandResult {
@@ -118,28 +143,30 @@ pub async fn execute(
kick_targets: Vec::new(),
quit: true,
},
_ => simple(&format!(
_ => CommandResult {
output: format!(
"{}\r\n",
ansi::error_msg(&format!(
"Unknown command: '{cmd}'. Type 'help' for commands."
))
)),
};
send(session, channel, &result.output)?;
for msg in result.broadcasts {
let _ = msg.handle.data(msg.channel, msg.data).await;
),
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
},
}
for kick in result.kick_targets {
let _ = kick.handle.close(kick.channel).await;
}
if result.quit {
return Ok(false);
}
send(session, channel, &ansi::prompt())?;
Ok(true)
}
pub fn get_command_list() -> Vec<&'static str> {
vec![
"look", "go", "north", "south", "east", "west", "up", "down",
"say", "who", "take", "drop", "inventory", "equip", "use",
"examine", "talk", "attack", "defend", "flee", "cast",
"spells", "skills", "guild", "stats", "help", "shop",
]
}
fn send(session: &mut Session, channel: ChannelId, text: &str) -> Result<(), russh::Error> {
session.data(channel, CryptoVec::from(text.as_bytes()))?;
Ok(())
@@ -153,6 +180,14 @@ fn attitude_color(att: Attitude) -> &'static str {
}
}
pub fn get_time_of_day(tick: u64) -> &'static str {
let day_tick = tick % 1440;
if day_tick < 360 { "Night" }
else if day_tick < 720 { "Morning" }
else if day_tick < 1080 { "Afternoon" }
else { "Evening" }
}
pub fn render_room_view(
room_id: &str,
player_id: usize,
@@ -168,13 +203,19 @@ pub fn render_room_view(
.map(|c| c.player.name.as_str())
.unwrap_or("");
let time_of_day = get_time_of_day(st.tick_count);
let mut out = format!(
"\r\n{} {}\r\n {}\r\n",
"\r\n{} {} {}\r\n {}\r\n",
ansi::room_name(&room.name),
ansi::system_msg(&format!("[{}]", room.region)),
ansi::color(ansi::YELLOW, &format!("[{}]", time_of_day)),
room.description
);
if room.outdoors {
out.push_str(&format!(" {}\r\n", ansi::color(ansi::CYAN, st.weather.kind.description())));
}
let npc_strs: Vec<String> = room
.npcs
.iter()
@@ -457,12 +498,14 @@ async fn cmd_go(pid: usize, direction: &str, state: &SharedState) -> CommandResu
);
let mut bcast = Vec::new();
for c in st.players_in_room(&old_rid, pid) {
if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
bcast.push(BroadcastMsg {
channel: c.channel,
handle: c.handle.clone(),
channel: ch,
handle: h.clone(),
data: leave.clone(),
});
}
}
if let Some(c) = st.players.get_mut(&pid) {
c.player.room_id = new_rid.clone();
@@ -477,12 +520,14 @@ async fn cmd_go(pid: usize, direction: &str, state: &SharedState) -> CommandResu
.as_bytes(),
);
for c in st.players_in_room(&new_rid, pid) {
if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
bcast.push(BroadcastMsg {
channel: c.channel,
handle: c.handle.clone(),
channel: ch,
handle: h.clone(),
data: arrive.clone(),
});
}
}
st.save_player_to_db(pid);
let output = render_room_view(&new_rid, pid, &st);
@@ -524,11 +569,17 @@ async fn cmd_say(pid: usize, msg: &str, state: &SharedState) -> CommandResult {
let bcast: Vec<_> = st
.players_in_room(&rid, pid)
.iter()
.map(|c| BroadcastMsg {
channel: c.channel,
handle: c.handle.clone(),
.filter_map(|c| {
if let (Some(ch), Some(h)) = (c.channel, &c.handle) {
Some(BroadcastMsg {
channel: ch,
handle: h.clone(),
data: other.clone(),
})
} else {
None
}
})
.collect();
CommandResult {
output: self_msg,
@@ -972,9 +1023,9 @@ async fn cmd_examine(pid: usize, target: &str, state: &SharedState) -> CommandRe
))
}
async fn cmd_talk(pid: usize, target: &str, state: &SharedState) -> CommandResult {
if target.is_empty() {
return simple("Talk to whom?\r\n");
async fn cmd_talk(pid: usize, input: &str, state: &SharedState) -> CommandResult {
if input.is_empty() {
return simple("Talk to whom? (Usage: talk <npc> [keyword])\r\n");
}
let st = state.lock().await;
let conn = match st.players.get(&pid) {
@@ -985,12 +1036,17 @@ async fn cmd_talk(pid: usize, target: &str, state: &SharedState) -> CommandResul
Some(r) => r,
None => return simple("Void\r\n"),
};
let low = target.to_lowercase();
let (target, keyword) = match input.split_once(' ') {
Some((t, k)) => (t.to_lowercase(), k.trim().to_lowercase()),
None => (input.to_lowercase(), String::new()),
};
let pname = &conn.player.name;
for nid in &room.npcs {
if let Some(npc) = st.world.get_npc(nid) {
if npc.name.to_lowercase().contains(&low) {
if npc.name.to_lowercase().contains(&target) {
if !st.npc_instances.get(nid).map(|i| i.alive).unwrap_or(true) {
return simple(&format!(
"{}\r\n",
@@ -1004,17 +1060,58 @@ async fn cmd_talk(pid: usize, target: &str, state: &SharedState) -> CommandResul
ansi::color(ansi::RED, &npc.name)
));
}
let greeting = npc.greeting.as_deref().unwrap_or("...");
if !keyword.is_empty() {
if let Some(response) = npc.keywords.get(&keyword) {
return CommandResult {
output: format!(
"\r\n{} says: \"{}\"\r\n",
ansi::color(ansi::YELLOW, &npc.name),
ansi::color(ansi::WHITE, greeting)
ansi::color(ansi::WHITE, response)
),
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
};
} else {
return simple(&format!(
"{} looks at you blankly, not understanding '{}'.\r\n",
ansi::color(ansi::YELLOW, &npc.name),
keyword
));
}
}
let greeting = npc.greeting.as_deref().unwrap_or("...");
let mut output = format!(
"\r\n{} says: \"{}\"\r\n",
ansi::color(ansi::YELLOW, &npc.name),
ansi::color(ansi::WHITE, greeting)
);
if !npc.keywords.is_empty() {
let mut keys: Vec<_> = npc.keywords.keys().cloned().collect();
keys.sort();
output.push_str(&format!(
" {} {}\r\n",
ansi::color(ansi::DIM, "You can ask about:"),
keys.join(", ")
));
}
if npc.shop.is_some() {
output.push_str(&format!(
" {}\r\n",
ansi::color(ansi::CYAN, "This person appears to be a merchant. Try 'shop list'.")
));
}
return CommandResult {
output,
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
};
}
}
}
@@ -1139,6 +1236,8 @@ async fn cmd_attack(pid: usize, target: &str, state: &SharedState) -> CommandRes
});
}
log::info!(target: "{combat}", "Combat: Player '{}' (ID {}) engaged NPC '{}' ({}) in combat", pname, pid, npc_name, npc_id);
CommandResult {
output: format!(
"{}\r\n{}\r\n{}",
@@ -1400,6 +1499,178 @@ async fn cmd_spells(pid: usize, state: &SharedState) -> CommandResult {
}
}
async fn cmd_shop(pid: usize, args: &str, state: &SharedState) -> CommandResult {
let mut st = state.lock().await;
let rid = match st.players.get(&pid) {
Some(c) => c.player.room_id.clone(),
None => return simple("Error\r\n"),
};
// Find a merchant in the room
let mut merchant_id = None;
if let Some(room) = st.world.get_room(&rid) {
for nid in &room.npcs {
if let Some(npc) = st.world.get_npc(nid) {
if npc.shop.is_some() {
merchant_id = Some(nid.clone());
break;
}
}
}
}
let merchant_id = match merchant_id {
Some(id) => id,
None => return simple("There is no merchant here.\r\n"),
};
let (subcmd, subargs) = match args.split_once(' ') {
Some((c, a)) => (c.to_lowercase(), a.trim()),
None => (args.to_lowercase(), ""),
};
match subcmd.as_str() {
"list" | "ls" | "" => {
let npc = st.world.get_npc(&merchant_id).unwrap();
let shop = npc.shop.as_ref().unwrap();
let mut out = format!(
"\r\n{}'s Shop Inventory (Markup: x{:.1})\r\n",
ansi::bold(&npc.name),
shop.markup
);
if shop.sells.is_empty() {
out.push_str(" (nothing for sale)\r\n");
} else {
for item_id in &shop.sells {
if let Some(obj) = st.world.get_object(item_id) {
let total_copper = (obj.value_gold * 10000 + obj.value_silver * 100 + obj.value_copper) as f32;
let price_copper = (total_copper * shop.markup).ceil() as i32;
let g = price_copper / 10000;
let s = (price_copper % 10000) / 100;
let c = price_copper % 100;
out.push_str(&format!(
" - {} [{}g {}s {}c]\r\n",
ansi::color(ansi::CYAN, &obj.name),
g, s, c
));
}
}
}
simple(&out)
}
"buy" => {
if subargs.is_empty() {
return simple("Buy what?\r\n");
}
let (shop, _npc_name) = {
let npc = st.world.get_npc(&merchant_id).unwrap();
(npc.shop.as_ref().unwrap().clone(), npc.name.clone())
};
let item_id = shop.sells.iter().find(|id| {
if let Some(obj) = st.world.get_object(*id) {
obj.name.to_lowercase().contains(&subargs.to_lowercase())
} else {
false
}
}).cloned();
if let Some(id) = item_id {
let obj = st.world.get_object(&id).unwrap().clone();
let total_copper = (obj.value_gold * 10000 + obj.value_silver * 100 + obj.value_copper) as f32;
let price_copper = (total_copper * shop.markup).ceil() as i32;
if let Some(conn) = st.players.get_mut(&pid) {
let player_total_copper = conn.player.gold * 10000 + conn.player.silver * 100 + conn.player.copper;
if player_total_copper < price_copper {
return simple("You don't have enough money.\r\n");
}
// Deduct money
let mut remaining = player_total_copper - price_copper;
conn.player.gold = remaining / 10000;
remaining %= 10000;
conn.player.silver = remaining / 100;
conn.player.copper = remaining % 100;
// Add to inventory
conn.player.inventory.push(obj.clone());
simple(&format!(
"You buy {} for {} copper equivalents.\r\n",
ansi::color(ansi::CYAN, &obj.name),
price_copper
))
} else {
simple("Error\r\n")
}
} else {
simple("The merchant doesn't sell that.\r\n")
}
}
"sell" => {
if subargs.is_empty() {
return simple("Sell what?\r\n");
}
let shop = st.world.get_npc(&merchant_id).unwrap().shop.as_ref().unwrap().clone();
let item_info = if let Some(conn) = st.players.get(&pid) {
conn.player.inventory.iter().position(|o| o.name.to_lowercase().contains(&subargs.to_lowercase()))
.map(|idx| (idx, conn.player.inventory[idx].clone()))
} else {
None
};
if let Some((idx, obj)) = item_info {
// Check if merchant buys this kind of item
let can_sell = shop.buys.is_empty() || shop.buys.iter().any(|k| {
if let Some(kind) = &obj.kind {
kind.to_lowercase() == k.to_lowercase()
} else {
false
}
});
if !can_sell {
return simple("The merchant isn't interested in that kind of item.\r\n");
}
let total_copper = (obj.value_gold * 10000 + obj.value_silver * 100 + obj.value_copper) as f32;
let price_copper = (total_copper * shop.markdown).floor() as i32;
if let Some(conn) = st.players.get_mut(&pid) {
// Add money to player
let mut player_total_copper = conn.player.gold * 10000 + conn.player.silver * 100 + conn.player.copper;
player_total_copper += price_copper;
conn.player.gold = player_total_copper / 10000;
player_total_copper %= 10000;
conn.player.silver = player_total_copper / 100;
conn.player.copper = player_total_copper % 100;
// Remove from inventory
conn.player.inventory.remove(idx);
simple(&format!(
"You sell {} for {} copper equivalents.\r\n",
ansi::color(ansi::CYAN, &obj.name),
price_copper
))
} else {
simple("Error\r\n")
}
} else {
simple("You don't have that in your inventory.\r\n")
}
}
_ => simple("Usage: shop list | shop buy <item> | shop sell <item>\r\n"),
}
}
async fn cmd_guild(pid: usize, args: &str, state: &SharedState) -> CommandResult {
let (subcmd, subargs) = match args.split_once(' ') {
Some((c, a)) => (c.to_lowercase(), a.trim().to_string()),
@@ -1638,6 +1909,14 @@ async fn cmd_stats(pid: usize, state: &SharedState) -> CommandResult {
s.xp,
s.xp_to_next
));
out.push_str(&format!(
" {} {}{}g {}{}s {}{}c{}\r\n",
ansi::color(ansi::DIM, "Money:"),
ansi::YELLOW, p.gold,
ansi::WHITE, p.silver,
ansi::RED, p.copper,
ansi::RESET,
));
if !p.guilds.is_empty() {
out.push_str(&format!(" {}\r\n", ansi::color(ansi::DIM, "Guilds:")));
let mut guild_list: Vec<_> = p.guilds.iter().collect();

View File

@@ -20,6 +20,9 @@ pub struct SavedPlayer {
pub endurance: i32,
pub max_endurance: i32,
pub is_admin: bool,
pub gold: i32,
pub silver: i32,
pub copper: i32,
}
pub struct NpcAttitudeRow {
@@ -75,7 +78,7 @@ impl SqliteDb {
.map_err(|e| format!("Failed to set pragmas: {e}"))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS players (
r#"CREATE TABLE IF NOT EXISTS players (
name TEXT PRIMARY KEY,
race_id TEXT NOT NULL,
class_id TEXT NOT NULL,
@@ -86,11 +89,19 @@ impl SqliteDb {
max_hp INTEGER NOT NULL,
attack INTEGER NOT NULL,
defense INTEGER NOT NULL,
inventory_json TEXT NOT NULL DEFAULT '[]',
equipped_json TEXT NOT NULL DEFAULT '{}',
is_admin INTEGER NOT NULL DEFAULT 0
inventory_json TEXT NOT NULL DEFAULT "[]",
equipped_json TEXT NOT NULL DEFAULT "{}",
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 (
player_name TEXT NOT NULL,
npc_id TEXT NOT NULL,
@@ -116,13 +127,13 @@ impl SqliteDb {
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}"))?;
// Migration: add is_admin column if missing
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)))
.map(|c| c > 0)
.unwrap_or(false);
@@ -135,34 +146,34 @@ impl SqliteDb {
// Migration: equipped_weapon_json/equipped_armor_json -> equipped_json
let has_old_weapon: bool = conn
.prepare("SELECT COUNT(*) FROM pragma_table_info('players') WHERE name='equipped_weapon_json'")
.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("SELECT COUNT(*) FROM pragma_table_info('players') WHERE name='equipped_json'")
.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(
"ALTER TABLE players ADD COLUMN equipped_json TEXT NOT NULL DEFAULT '{}'",
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(
"UPDATE players SET equipped_json = '{}' WHERE equipped_weapon_json IS NULL AND equipped_armor_json IS NULL;"
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(
"ALTER TABLE players ADD COLUMN equipped_json TEXT NOT NULL DEFAULT '{}'",
r#"ALTER TABLE players ADD COLUMN equipped_json TEXT NOT NULL DEFAULT "{}"#,
[],
);
}
// Migration: add mana/endurance columns
let has_mana: bool = conn
.prepare("SELECT COUNT(*) FROM pragma_table_info('players') WHERE name='mana'")
.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);
@@ -173,6 +184,18 @@ impl SqliteDb {
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());
Ok(SqliteDb {
conn: std::sync::Mutex::new(conn),
@@ -186,7 +209,7 @@ impl GameDb for SqliteDb {
conn.query_row(
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
attack, defense, inventory_json, equipped_json, is_admin,
mana, max_mana, endurance, max_endurance
mana, max_mana, endurance, max_endurance, gold, silver, copper
FROM players WHERE name = ?1",
[name],
|row| {
@@ -208,6 +231,9 @@ impl GameDb for SqliteDb {
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),
})
},
)
@@ -219,20 +245,22 @@ impl GameDb for SqliteDb {
let _ = conn.execute(
"INSERT INTO players (name, race_id, class_id, room_id, level, xp, hp, max_hp,
attack, defense, inventory_json, equipped_json, is_admin,
mana, max_mana, endurance, max_endurance)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17)
mana, max_mana, endurance, max_endurance, gold, silver, copper)
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
room_id=excluded.room_id, level=excluded.level, xp=excluded.xp,
hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack,
defense=excluded.defense, inventory_json=excluded.inventory_json,
equipped_json=excluded.equipped_json, is_admin=excluded.is_admin,
mana=excluded.mana, max_mana=excluded.max_mana,
endurance=excluded.endurance, max_endurance=excluded.max_endurance",
endurance=excluded.endurance, max_endurance=excluded.max_endurance,
gold=excluded.gold, silver=excluded.silver, copper=excluded.copper",
rusqlite::params![
p.name, p.race_id, p.class_id, p.room_id, p.level, p.xp,
p.hp, p.max_hp, p.attack, p.defense,
p.inventory_json, p.equipped_json, p.is_admin as i32,
p.mana, p.max_mana, p.endurance, p.max_endurance,
p.gold, p.silver, p.copper,
],
);
}
@@ -262,7 +290,7 @@ impl GameDb for SqliteDb {
.prepare(
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
attack, defense, inventory_json, equipped_json, is_admin,
mana, max_mana, endurance, max_endurance
mana, max_mana, endurance, max_endurance, gold, silver, copper
FROM players ORDER BY name",
)
.unwrap();
@@ -285,6 +313,9 @@ impl GameDb for SqliteDb {
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()

View File

@@ -9,6 +9,35 @@ use russh::ChannelId;
use crate::db::{GameDb, SavedPlayer};
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)]
pub struct PlayerStats {
pub max_hp: i32,
@@ -35,6 +64,9 @@ pub struct Player {
pub guilds: HashMap<String, i32>,
pub cooldowns: HashMap<String, i32>,
pub is_admin: bool,
pub gold: i32,
pub silver: i32,
pub copper: i32,
}
impl Player {
@@ -98,8 +130,8 @@ pub struct NpcInstance {
pub struct PlayerConnection {
pub player: Player,
pub channel: ChannelId,
pub handle: Handle,
pub channel: Option<ChannelId>,
pub handle: Option<Handle>,
pub combat: Option<CombatState>,
}
@@ -139,6 +171,7 @@ pub struct GameState {
pub npc_instances: HashMap<String, NpcInstance>,
pub rng: XorShift64,
pub tick_count: u64,
pub weather: WeatherState,
}
pub type SharedState = Arc<Mutex<GameState>>;
@@ -157,7 +190,8 @@ pub fn resolve_npc_race_class(
if candidates.is_empty() {
world.races.first().map(|r| r.id.clone()).unwrap_or_default()
} else {
let idx = rng.next_range(0, candidates.len() as i32) as usize;
let len = candidates.len() as i32;
let idx = rng.next_range(0, len.saturating_sub(1)) as usize;
candidates[idx].id.clone()
}
}
@@ -187,7 +221,8 @@ pub fn resolve_npc_race_class(
if candidates.is_empty() {
world.classes.first().map(|c| c.id.clone()).unwrap_or_default()
} else {
let idx = rng.next_range(0, candidates.len() as i32) as usize;
let len = candidates.len() as i32;
let idx = rng.next_range(0, len.saturating_sub(1)) as usize;
candidates[idx].id.clone()
}
}
@@ -221,6 +256,10 @@ impl GameState {
npc_instances,
rng,
tick_count: 0,
weather: WeatherState {
kind: WeatherKind::Clear,
remaining_ticks: 100,
},
}
}
@@ -275,8 +314,8 @@ impl GameState {
name: String,
race_id: String,
class_id: String,
channel: ChannelId,
handle: Handle,
channel: Option<ChannelId>,
handle: Option<Handle>,
) {
let room_id = self.world.spawn_room.clone();
let race = self.world.races.iter().find(|r| r.id == race_id);
@@ -342,6 +381,9 @@ impl GameState {
guilds,
cooldowns: HashMap::new(),
is_admin: false,
gold: 0,
silver: 0,
copper: 10, // Start with some copper
},
channel,
handle,
@@ -354,8 +396,8 @@ impl GameState {
&mut self,
id: usize,
saved: SavedPlayer,
channel: ChannelId,
handle: Handle,
channel: Option<ChannelId>,
handle: Option<Handle>,
) {
let inventory: Vec<Object> =
serde_json::from_str(&saved.inventory_json).unwrap_or_default();
@@ -399,6 +441,9 @@ impl GameState {
guilds,
cooldowns: HashMap::new(),
is_admin: saved.is_admin,
gold: saved.gold,
silver: saved.silver,
copper: saved.copper,
},
channel,
handle,
@@ -433,6 +478,9 @@ impl GameState {
endurance: p.stats.endurance,
max_endurance: p.stats.max_endurance,
is_admin: p.is_admin,
gold: p.gold,
silver: p.silver,
copper: p.copper,
});
}
}

167
src/jsonrpc.rs Normal file
View 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()
}

View File

@@ -8,3 +8,4 @@ pub mod game;
pub mod ssh;
pub mod tick;
pub mod world;
pub mod jsonrpc;

View File

@@ -2,6 +2,8 @@ use std::path::PathBuf;
use std::sync::Arc;
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::server::Server as _;
use tokio::net::TcpListener;
@@ -18,11 +20,12 @@ const DEFAULT_DB_PATH: &str = "./mudserver.db";
#[tokio::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 jsonrpc_port = 2223;
let mut world_dir = PathBuf::from(DEFAULT_WORLD_DIR);
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 mut i = 1;
@@ -35,6 +38,13 @@ async fn main() {
.and_then(|s| s.parse().ok())
.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" => {
i += 1;
world_dir = PathBuf::from(args.get(i).expect("--world requires a path"));
@@ -43,11 +53,22 @@ async fn main() {
i += 1;
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" => {
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!(" --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);
}
other => {
@@ -58,6 +79,42 @@ async fn main() {
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());
let loaded_world = world::World::load(&world_dir).unwrap_or_else(|e| {
eprintln!("Failed to load world: {e}");
@@ -90,6 +147,12 @@ async fn main() {
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 listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap();

View File

@@ -80,9 +80,11 @@ impl MudHandler {
if let Some(saved) = saved {
let handle = session.handle();
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);
log::info!("Player '{}' (id={}) logged in", self.username, self.id);
let msg = format!(
"{}\r\n",
ansi::system_msg("Welcome back! Your character has been restored.")
@@ -127,7 +129,13 @@ impl MudHandler {
let others: Vec<_> = state
.players_in_room(&room_id, self.id)
.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();
let room_view = render_entry_room(&state, &room_id, &player_name, self.id);
@@ -165,13 +173,20 @@ impl MudHandler {
.map(|c| c.name.clone())
.unwrap_or_default();
log::info!(
"New character created: {} (Race: {}, Class: {})",
self.username,
race_name,
class_name
);
state.create_new_player(
self.id,
self.username.clone(),
race_id,
class_id,
channel,
handle,
Some(channel),
Some(handle),
);
state.save_player_to_db(self.id);
drop(state);
@@ -203,7 +218,13 @@ impl MudHandler {
let others: Vec<_> = state
.players_in_room(&conn.player.room_id, self.id)
.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();
drop(state);
for (ch, h) in others {
@@ -415,7 +436,7 @@ impl russh::server::Handler for MudHandler {
}
let keep_going =
commands::execute(&line, self.id, &self.state, session, channel)
commands::execute_for_ssh(&line, self.id, &self.state, session, channel)
.await?;
if !keep_going {
self.handle_disconnect().await;

View File

@@ -26,6 +26,48 @@ pub async fn run_tick_engine(state: SharedState) {
st.tick_count += 1;
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();
// --- 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;
}
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()));
break;
}
@@ -60,6 +102,16 @@ pub async fn run_tick_engine(state: SharedState) {
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 {
let npc_name = st
.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" => {
let heal = eff.magnitude;
let online_pid = st
@@ -291,11 +361,15 @@ pub async fn run_tick_engine(state: SharedState) {
return None;
}
let conn = st.players.get(&pid)?;
if let (Some(ch), Some(h)) = (conn.channel, &conn.handle) {
Some((
conn.channel,
conn.handle.clone(),
ch,
h.clone(),
format!("{}{}", msg, ansi::prompt()),
))
} else {
None
}
})
.collect();

View File

@@ -77,12 +77,26 @@ pub struct RoomFile {
pub description: String,
#[serde(default)]
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 {
#[serde(default)]
pub greeting: Option<String>,
#[serde(default)]
pub keywords: HashMap<String, String>, // keyword -> response
}
#[derive(Deserialize)]
@@ -113,6 +127,14 @@ pub struct NpcFile {
pub dialogue: Option<NpcDialogue>,
#[serde(default)]
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 {
@@ -143,6 +165,12 @@ pub struct ObjectFile {
pub takeable: bool,
#[serde(default)]
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 ---
@@ -387,6 +415,7 @@ pub struct Room {
pub exits: HashMap<String, String>,
pub npcs: Vec<String>,
pub objects: Vec<String>,
pub outdoors: bool,
}
#[derive(Clone)]
@@ -409,7 +438,12 @@ pub struct Npc {
pub fixed_class: Option<String>,
pub respawn_secs: Option<u64>,
pub greeting: Option<String>,
pub keywords: HashMap<String, String>,
pub combat: Option<NpcCombatStats>,
pub shop: Option<ShopFile>,
pub gold: i32,
pub silver: i32,
pub copper: i32,
}
#[derive(Clone, Serialize, Deserialize)]
@@ -429,6 +463,9 @@ pub struct Object {
pub slot: Option<String>,
pub takeable: bool,
pub stats: ObjectStats,
pub value_gold: i32,
pub value_silver: i32,
pub value_copper: i32,
}
pub const DEFAULT_HUMANOID_SLOTS: &[&str] = &[
@@ -631,20 +668,24 @@ impl World {
load_entities_from_dir(&region_path.join("rooms"), &region_name, &mut |id, content| {
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(())
})?;
load_entities_from_dir(&region_path.join("npcs"), &region_name, &mut |id, content| {
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 })
.unwrap_or(NpcCombatStats { max_hp: 20, attack: 4, defense: 2, xp_reward: 5 }));
let greeting = nf.dialogue.and_then(|d| d.greeting);
npcs.insert(id.clone(), Npc {
id: id.clone(), name: nf.name, description: nf.description, room: nf.room,
base_attitude: nf.base_attitude, faction: nf.faction,
fixed_race: nf.race, fixed_class: nf.class,
respawn_secs: nf.respawn_secs, greeting, combat,
respawn_secs: nf.respawn_secs, greeting, keywords, combat,
shop: nf.shop, gold: nf.gold, silver: nf.silver, copper: nf.copper,
});
Ok(())
})?;
@@ -656,6 +697,7 @@ impl World {
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(())
})?;

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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." }

View 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"

View 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"

View 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." }

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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"

View 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." }

View 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"

View 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"

View 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." }

View 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"

View 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"

View 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." }

View 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"

View 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"

View 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

View 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"

View 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"

View 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
View File

@@ -0,0 +1,2 @@
name = "Lawold"
description = "A coastal capital city situated along a river, governed by the Golden Senate."

View 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"

View 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"

View 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"

View File

@@ -0,0 +1,9 @@
name = "The Diviners Guild Hall"
description = """\
A large timber and brick building, decorated with stained glass \
windows. Its steep bluish slate roof is topped with iron weather-vanes \
shaped like eyes and stars. Inside, the air is thick with incense \
and the murmur of voices seeking visions of the future."""
[exits]
west = "lawold:artists_district_lane"

View File

@@ -0,0 +1,11 @@
name = "Artist's District Lane"
description = """\
A narrow lane of well-trodden brown dirt winds through the Artist's \
District. The buildings here are eccentric and colorful, with bluish \
slate roofs and stained glass windows. This is a place of creativity \
and culture, sheltered by the massive black stone walls of the city."""
[exits]
west = "lawold:black_docks_quay"
north = "lawold:saints_market_plaza"
east = "lawold:artists_district_diviners_hall"

View File

@@ -0,0 +1,9 @@
name = "Beggar's Alley"
description = """\
A narrow, dark alleyway winding between tall timber buildings with \
bluish slate roofs. The brown dirt underfoot is damp and smelly. This \
is a dangerous part of the city, where the shadows are deep and the \
walls are close."""
[exits]
east = "lawold:beggars_market_square"

View File

@@ -0,0 +1,12 @@
name = "Beggar's Market Square"
description = """\
A crowded square of well-trodden brown dirt, surrounded by lean-tos \
and weathered timber buildings with bluish slate roofs. This is the \
poorest part of the city, where the Beggar's Market is held. The \
massive black stone inner wall looms to the north."""
[exits]
north = "lawold:hell_hounds_ward_streets"
south = "lawold:dale_village_gate"
east = "lawold:beggars_market_stalls"
west = "lawold:beggars_market_alley"

View File

@@ -0,0 +1,9 @@
name = "Beggar's Market Stalls"
description = """\
A collection of ramshackle stalls and small shops with patched bluish \
slate roofs. The brown dirt road here is muddy and cluttered with \
refuse. Despite the poverty, a brisk trade in second-hand goods and \
simple food takes place here."""
[exits]
west = "lawold:beggars_market_square"

View File

@@ -0,0 +1,10 @@
name = "Broken Abbey Ruins"
description = """\
The remains of an ancient abbey sit atop a cliff overlooking the \
docks. The ruins are said to lie atop catacombs filled with long-forgotten \
treasures. The massive black stone walls of the city pass nearby, \
their shadow falling across the crumbling walls and bluish slate \
debris."""
[exits]
down = "lawold:black_docks_quay"

View File

@@ -0,0 +1,13 @@
name = "Black Docks Quay"
description = """\
The Black Docks are a more industrial area of the city, where coal and \
ore are unloaded from river barges. The quay is paved with well-trodden \
brown cobblestones, slick with moisture. Massive black stone walls \
protect the harbor to the north, while timber buildings with bluish \
slate roofs house warehouses and theaters."""
[exits]
south = "lawold:nobles_district_gilded_avenue"
east = "lawold:artists_district_lane"
west = "lawold:black_docks_verdant_pavilion"
up = "lawold:black_docks_abbey_ruins"

View File

@@ -0,0 +1,9 @@
name = "The Verdant Pavilion"
description = """\
A grand theatre of half-timbered walls, said to be haunted by the \
ghost of a jester. The building's bluish slate roof is decorated with \
carved figures of actors and musicians. Inside, the theatre is known \
for its lavish productions and eerie atmosphere."""
[exits]
east = "lawold:black_docks_quay"

View File

@@ -0,0 +1,8 @@
name = "Central Bridge"
description = "A magnificent arched bridge of black stone spanning the river. To the west, the commerce of Well Market bustles, while to the east, the Gilded Avenue of the Noble's District beckons. The well-trodden brown dirt and cobblestone roads converge here under the watchful shadow of the city's massive black stone walls. Clusters of stone and timber buildings with bluish slate roofs line the riverbanks."
[exits]
west = "lawold:well_market_square"
east = "lawold:nobles_district_gilded_avenue"
north = "lawold:senate_plaza"
south = "lawold:dale_village_gate"

View File

@@ -0,0 +1,9 @@
name = "Berga's Tokens"
description = """\
The workshop of a female human armorer named Berga. The building is a \
sturdy stone structure with a steep bluish slate roof. Finished suits \
of armor and various weapons are displayed in the window, facing the \
well-trodden brown dirt road of Dale Village."""
[exits]
north = "lawold:dale_village_gate"

View File

@@ -0,0 +1,15 @@
name = "South Gate (Dale Village)"
description = """\
The Great South Gate is the primary entrance to Lawold. A massive \
archway is cut through the formidable black stone walls, through \
which a well-trodden brown dirt road leads into the city. Inside the \
gate, the district of Dale Village begins, characterized by dense \
clusters of stone buildings with bluish slate roofs."""
[exits]
north = "lawold:beggars_market_square"
west = "lawold:demons_borough_river_walk"
east = "lawold:servants_ward_quarter"
up = "lawold:dale_village_zielaph_monolith"
down = "town:town_square"
south = "lawold:dale_village_bergas_tokens"

View File

@@ -0,0 +1,10 @@
name = "Monolith of Zielaph"
description = """\
A monolith of rough-hewn stone stands atop a small hill, said to \
entomb a relic of Zielaph, Goddess of Lightning. The massive black \
stone walls of the city are visible nearby. From this vantage point, \
you can see the entire district of Dale Village and its bluish slate \
roofs."""
[exits]
down = "lawold:dale_village_gate"

View File

@@ -0,0 +1,9 @@
name = "Ruins of Ewip Tower"
description = """\
The shattered remains of an old iron tower sit atop a small hill, \
overlooking the district. The massive black stone walls of the city \
loom nearby. From this height, you can see the dense clusters of \
bluish slate roofs that characterize the Demon's Borough below."""
[exits]
down = "lawold:demons_borough_river_walk"

View File

@@ -0,0 +1,9 @@
name = "Teowiu's Pottery"
description = """\
The workshop of a male human potter named Teowiu, known for his \
whimsical mimic-like tankards. The building is a small, round stone \
structure with a conical bluish slate roof. Finished pottery sits on \
shelves outside, facing the well-trodden brown dirt road."""
[exits]
east = "lawold:demons_borough_river_walk"

View File

@@ -0,0 +1,14 @@
name = "Demon's Borough River Walk"
description = """\
A path of well-trodden brown dirt runs along the river's edge in the \
southern part of the city. The buildings here are older and more \
weathered, their bluish slate roofs covered in moss. To the south, the \
river enters the city under a heavily fortified arch in the black \
stone walls."""
[exits]
north = "lawold:central_bridge"
south = "lawold:demons_borough_sawmill"
east = "lawold:dale_village_gate"
west = "lawold:demons_borough_pottery"
up = "lawold:demons_borough_ewip_tower_ruins"

View File

@@ -0,0 +1,11 @@
name = "The Sawmill"
description = """\
A derelict building of brick and slate, filled with complex dwarven \
machines. It is apparent that the building is a sawmill, but no-one \
has been able to work out how to operate it in many generations. \
The bluish slate roof has partially collapsed, revealing the massive \
wooden beams within. The brown dirt road outside is overgrown with \
weeds."""
[exits]
north = "lawold:demons_borough_river_walk"

View File

@@ -0,0 +1,9 @@
name = "Burga's Pewter"
description = """\
A cluttered pewtersmith's workshop, decorated with a collection of \
curious bronze swords. The building is a sturdy stone structure with \
a steep bluish slate roof. The air inside is heavy with the smell of \
molten metal and charcoal."""
[exits]
west = "lawold:hell_hounds_farthing_streets"

Some files were not shown because too many files have changed in this diff Show More