Compare commits
19 Commits
df757ba37d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 394a9b8355 | |||
| 7bab50b431 | |||
|
|
b81362d4d1 | ||
| 93b1c1e301 | |||
| d9f2929c0c | |||
|
|
2689f9e29e | ||
| 8b49ef2c46 | |||
|
|
1f4955db82 | ||
| 03122f2901 | |||
|
|
678543dd9a | ||
|
|
0914b5a32b | ||
|
|
1a545bbae7 | ||
|
|
3a2a606c4a | ||
|
|
f183daa16c | ||
| 3baa0091f9 | |||
|
|
87baaee46f | ||
|
|
52b333fa48 | ||
|
|
0722a2f1d7 | ||
|
|
2e1794b799 |
@@ -2,19 +2,27 @@ name: Smoke tests
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
smoke:
|
smoke:
|
||||||
name: Build and smoke test
|
name: Build and smoke test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
TEST_DB: ./mudserver.db.test
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install needed tools
|
||||||
|
env:
|
||||||
|
DEBIAN_FRONTEND: noninteractive
|
||||||
run: |
|
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
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build
|
run: cargo build
|
||||||
@@ -22,5 +30,202 @@ jobs:
|
|||||||
- name: Validate world data
|
- name: Validate world data
|
||||||
run: ./target/debug/mudtool validate -w ./world
|
run: ./target/debug/mudtool validate -w ./world
|
||||||
|
|
||||||
- name: Run smoke tests
|
- name: Reset smoke database
|
||||||
run: ./run-tests.sh
|
run: rm -f "$TEST_DB"
|
||||||
|
|
||||||
|
- name: Smoke - new player and basics
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RUST_LOG=info ./target/debug/mudserver -d "$TEST_DB" &
|
||||||
|
MUD_PID=$!
|
||||||
|
trap 'kill $MUD_PID 2>/dev/null || true' EXIT
|
||||||
|
bash scripts/ci/wait-for-tcp.sh 127.0.0.1 2222
|
||||||
|
set +e
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF'
|
||||||
|
1
|
||||||
|
1
|
||||||
|
look
|
||||||
|
stats
|
||||||
|
go south
|
||||||
|
go down
|
||||||
|
go north
|
||||||
|
talk barkeep
|
||||||
|
go south
|
||||||
|
go south
|
||||||
|
examine thief
|
||||||
|
attack thief
|
||||||
|
flee
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
r=$?
|
||||||
|
set -e
|
||||||
|
[[ $r -eq 0 || $r -eq 255 ]]
|
||||||
|
|
||||||
|
- name: Smoke - weather and time
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RUST_LOG=info ./target/debug/mudserver -d "$TEST_DB" &
|
||||||
|
MUD_PID=$!
|
||||||
|
trap 'kill $MUD_PID 2>/dev/null || true' EXIT
|
||||||
|
bash scripts/ci/wait-for-tcp.sh 127.0.0.1 2222
|
||||||
|
set +e
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF' > weather_test.out
|
||||||
|
go south
|
||||||
|
go down
|
||||||
|
look
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
r=$?
|
||||||
|
set -e
|
||||||
|
[[ $r -eq 0 || $r -eq 255 ]]
|
||||||
|
if ! grep -q "The sky is\|raining\|storm\|snow\|fog" weather_test.out; then
|
||||||
|
echo "Error: Weather info not found in look output"
|
||||||
|
cat weather_test.out
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q "\[Night\]\|\[Morning\]\|\[Afternoon\]\|\[Evening\]" weather_test.out; then
|
||||||
|
echo "Error: Time of day info not found in look output"
|
||||||
|
cat weather_test.out
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm weather_test.out
|
||||||
|
|
||||||
|
- name: Smoke - persistence (reconnect)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RUST_LOG=info ./target/debug/mudserver -d "$TEST_DB" &
|
||||||
|
MUD_PID=$!
|
||||||
|
trap 'kill $MUD_PID 2>/dev/null || true' EXIT
|
||||||
|
bash scripts/ci/wait-for-tcp.sh 127.0.0.1 2222
|
||||||
|
set +e
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF'
|
||||||
|
look
|
||||||
|
stats
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
r=$?
|
||||||
|
set -e
|
||||||
|
[[ $r -eq 0 || $r -eq 255 ]]
|
||||||
|
|
||||||
|
- name: Smoke - mudtool admin
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players list
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players set-admin smoketest true
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players show smoketest
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" settings set registration_open false
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" settings list
|
||||||
|
|
||||||
|
- name: Smoke - in-game admin
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RUST_LOG=info ./target/debug/mudserver -d "$TEST_DB" &
|
||||||
|
MUD_PID=$!
|
||||||
|
trap 'kill $MUD_PID 2>/dev/null || true' EXIT
|
||||||
|
bash scripts/ci/wait-for-tcp.sh 127.0.0.1 2222
|
||||||
|
set +e
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost <<'EOF'
|
||||||
|
admin help
|
||||||
|
admin list
|
||||||
|
admin registration on
|
||||||
|
admin info smoketest
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
r=$?
|
||||||
|
set -e
|
||||||
|
[[ $r -eq 0 || $r -eq 255 ]]
|
||||||
|
|
||||||
|
- name: Smoke - registration gate
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" settings set registration_open false
|
||||||
|
RUST_LOG=info ./target/debug/mudserver -d "$TEST_DB" &
|
||||||
|
MUD_PID=$!
|
||||||
|
trap 'kill $MUD_PID 2>/dev/null || true' EXIT
|
||||||
|
bash scripts/ci/wait-for-tcp.sh 127.0.0.1 2222
|
||||||
|
set +e
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 newplayer@localhost <<'EOF'
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
r=$?
|
||||||
|
set -e
|
||||||
|
[[ $r -eq 0 || $r -eq 255 ]]
|
||||||
|
|
||||||
|
- name: Smoke - tick-based combat
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" settings set registration_open true
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players delete smoketest
|
||||||
|
RUST_LOG=info ./target/debug/mudserver -d "$TEST_DB" &
|
||||||
|
MUD_PID=$!
|
||||||
|
trap 'kill $MUD_PID 2>/dev/null || true' EXIT
|
||||||
|
bash scripts/ci/wait-for-tcp.sh 127.0.0.1 2222
|
||||||
|
set +e
|
||||||
|
(
|
||||||
|
echo "1"
|
||||||
|
echo "1"
|
||||||
|
echo "go south"
|
||||||
|
echo "go down"
|
||||||
|
echo "go south"
|
||||||
|
echo "attack thief"
|
||||||
|
sleep 15
|
||||||
|
echo "stats"
|
||||||
|
echo "quit"
|
||||||
|
) | ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 smoketest@localhost
|
||||||
|
r=$?
|
||||||
|
set -e
|
||||||
|
[[ $r -eq 0 || $r -eq 255 ]]
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" settings set registration_open true
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players delete smoketest
|
||||||
|
|
||||||
|
- name: Smoke - JSON-RPC list_commands
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RUST_LOG=info ./target/debug/mudserver -d "$TEST_DB" &
|
||||||
|
MUD_PID=$!
|
||||||
|
trap 'kill $MUD_PID 2>/dev/null || true' EXIT
|
||||||
|
bash scripts/ci/wait-for-tcp.sh 127.0.0.1 2222
|
||||||
|
set +e
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 rpctest@localhost <<'EOF'
|
||||||
|
1
|
||||||
|
1
|
||||||
|
quit
|
||||||
|
EOF
|
||||||
|
r=$?
|
||||||
|
set -e
|
||||||
|
[[ $r -eq 0 || $r -eq 255 ]]
|
||||||
|
echo '{"_jsonrpc": "2.0", "method": "login", "params": {"username": "rpctest"}, "id": 1}' | nc -w 2 localhost 2223 > rpc_resp.json
|
||||||
|
echo '{"_jsonrpc": "2.0", "method": "list_commands", "params": {}, "id": 2}' | nc -w 2 localhost 2223 >> rpc_resp.json
|
||||||
|
grep -q '"shop"' rpc_resp.json
|
||||||
|
rm rpc_resp.json
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players delete rpctest
|
||||||
|
|
||||||
|
- name: Verify logging
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
if [ ! -d "logs" ]; then
|
||||||
|
echo "Error: logs directory not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
FAILED=0
|
||||||
|
|
||||||
|
echo "Checking mudserver logs..."
|
||||||
|
grep -q "World '.*': .* rooms" logs/mudserver_*.log || { echo "Failed: World loading log missing"; FAILED=1; }
|
||||||
|
grep -q "MUD server listening on" logs/mudserver_*.log || { echo "Failed: Listen log missing"; FAILED=1; }
|
||||||
|
grep -q "New character created: smoketest" logs/mudserver_*.log || { echo "Failed: smoketest creation log missing"; FAILED=1; }
|
||||||
|
grep -q "Admin action: registration setting updated: '.*'" logs/mudserver_*.log || { echo "Failed: Admin action log missing"; FAILED=1; }
|
||||||
|
|
||||||
|
echo "Checking combat logs..."
|
||||||
|
grep -q "Combat: Player 'smoketest' (ID .*) engaged NPC 'Shadowy Thief'" logs/combat_*.log || { echo "Failed: Combat engagement log missing"; FAILED=1; }
|
||||||
|
grep -q "Combat: Player 'smoketest' (ID .*) killed NPC 'Shadowy Thief'" logs/combat_*.log || { echo "Failed: NPC kill log missing"; FAILED=1; }
|
||||||
|
|
||||||
|
if [ $FAILED -ne 0 ]; then
|
||||||
|
echo "--- LOG VERIFICATION FAILED ---"
|
||||||
|
echo "--- MUDSERVER LOG CONTENTS ---"
|
||||||
|
cat logs/mudserver_*.log
|
||||||
|
echo "--- COMBAT LOG CONTENTS ---"
|
||||||
|
cat logs/combat_*.log
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Logging verification passed."
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,3 +2,5 @@
|
|||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
/logs
|
||||||
|
/manual_logs
|
||||||
|
|||||||
174
Cargo.lock
generated
174
Cargo.lock
generated
@@ -61,56 +61,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstream"
|
|
||||||
version = "0.6.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
|
||||||
dependencies = [
|
|
||||||
"anstyle",
|
|
||||||
"anstyle-parse",
|
|
||||||
"anstyle-query",
|
|
||||||
"anstyle-wincon",
|
|
||||||
"colorchoice",
|
|
||||||
"is_terminal_polyfill",
|
|
||||||
"utf8parse",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle"
|
|
||||||
version = "1.0.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle-parse"
|
|
||||||
version = "0.2.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
|
||||||
dependencies = [
|
|
||||||
"utf8parse",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle-query"
|
|
||||||
version = "1.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
|
||||||
dependencies = [
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle-wincon"
|
|
||||||
version = "3.0.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|
||||||
dependencies = [
|
|
||||||
"anstyle",
|
|
||||||
"once_cell_polyfill",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.102"
|
||||||
@@ -335,12 +285,6 @@ dependencies = [
|
|||||||
"inout",
|
"inout",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "colorchoice"
|
|
||||||
version = "1.0.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compact_str"
|
name = "compact_str"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -385,6 +329,30 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-channel"
|
||||||
|
version = "0.5.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-queue"
|
||||||
|
version = "0.3.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-utils"
|
||||||
|
version = "0.8.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossterm"
|
name = "crossterm"
|
||||||
version = "0.28.1"
|
version = "0.28.1"
|
||||||
@@ -694,29 +662,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "env_filter"
|
|
||||||
version = "1.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"regex",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "env_logger"
|
|
||||||
version = "0.11.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
|
|
||||||
dependencies = [
|
|
||||||
"anstream",
|
|
||||||
"anstyle",
|
|
||||||
"env_filter",
|
|
||||||
"jiff",
|
|
||||||
"log",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -809,6 +754,21 @@ version = "0.4.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flexi_logger"
|
||||||
|
version = "0.29.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "88a5a6882b2e137c4f2664562995865084eb5a00611fba30c582ef10354c4ad8"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"crossbeam-channel",
|
||||||
|
"crossbeam-queue",
|
||||||
|
"log",
|
||||||
|
"nu-ansi-term",
|
||||||
|
"regex",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fnv"
|
name = "fnv"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -1166,12 +1126,6 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "is_terminal_polyfill"
|
|
||||||
version = "1.70.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -1187,30 +1141,6 @@ version = "1.0.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jiff"
|
|
||||||
version = "0.2.23"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
|
|
||||||
dependencies = [
|
|
||||||
"jiff-static",
|
|
||||||
"log",
|
|
||||||
"portable-atomic",
|
|
||||||
"portable-atomic-util",
|
|
||||||
"serde_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jiff-static"
|
|
||||||
version = "0.2.23"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.91"
|
version = "0.3.91"
|
||||||
@@ -1387,7 +1317,7 @@ name = "mudserver"
|
|||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossterm 0.28.1",
|
"crossterm 0.28.1",
|
||||||
"env_logger",
|
"flexi_logger",
|
||||||
"log",
|
"log",
|
||||||
"rand",
|
"rand",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
@@ -1423,6 +1353,15 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"minimal-lexical",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nu-ansi-term"
|
||||||
|
version = "0.50.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-bigint"
|
name = "num-bigint"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@@ -1512,12 +1451,6 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "once_cell_polyfill"
|
|
||||||
version = "1.70.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opaque-debug"
|
name = "opaque-debug"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1815,15 +1748,6 @@ version = "1.13.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "portable-atomic-util"
|
|
||||||
version = "0.2.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
|
|
||||||
dependencies = [
|
|
||||||
"portable-atomic",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "powerfmt"
|
name = "powerfmt"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ rusqlite = { version = "0.35", features = ["bundled"] }
|
|||||||
ratatui = "0.30"
|
ratatui = "0.30"
|
||||||
crossterm = "0.28"
|
crossterm = "0.28"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
flexi_logger = { version = "0.29", features = ["async"] }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
|||||||
12
PLANNED.md
12
PLANNED.md
@@ -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
|
## 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.
|
New state, commands, or mechanics with bounded scope.
|
||||||
|
|
||||||
- **Weather** — Weather system (e.g., rain, snow, fog) affecting areas or atmosphere; scope TBD.
|
- **Robust Logging** — Structured logging, rotation, and persistence; better visibility into server state and player actions.
|
||||||
- **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).
|
|
||||||
- **Quests or objectives** — Simple “kill X” / “bring Y” goals; quest state in DB and hooks in combat/loot/NPCs.
|
- **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.
|
- **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.
|
- **PvP** — Player-vs-player combat; consent/flagging, safe zones, and balance TBD.
|
||||||
|
|||||||
96
TESTING.md
96
TESTING.md
@@ -6,9 +6,9 @@
|
|||||||
./run-tests.sh
|
./run-tests.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This builds the server and mudtool, starts the server with a temporary DB, runs the smoke test below (new player, persistence, admin, registration gate, combat), then cleans up. Use it locally to match what Gitea Actions run on push/pull_request. The script uses `MUD_TEST_DB` (default `./mudserver.db.test`) so it does not overwrite your normal `mudserver.db`.
|
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), ssh client. In CI, Rust is installed by the workflow.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -113,6 +113,13 @@ Run through the checks below before every commit to ensure consistent feature co
|
|||||||
- [ ] Negative status effects cleared on player death/respawn
|
- [ ] Negative status effects cleared on player death/respawn
|
||||||
- [ ] Status effects on offline players resolve by wall-clock time on next login
|
- [ ] Status effects on offline players resolve by wall-clock time on next login
|
||||||
|
|
||||||
|
## Weather & Time
|
||||||
|
- [ ] Outdoor rooms display time of day (e.g., `[Night]`, `[Morning]`).
|
||||||
|
- [ ] Outdoor rooms display current weather (e.g., `The sky is clear`, `It is raining`).
|
||||||
|
- [ ] Indoor rooms do not show weather or time of day.
|
||||||
|
- [ ] Rain or storm applies the `wet` status effect to players in outdoor rooms.
|
||||||
|
- [ ] Weather changes periodically and broadcasts messages to players in outdoor rooms.
|
||||||
|
|
||||||
## Guilds
|
## Guilds
|
||||||
- [ ] `guild list` shows all available guilds with descriptions
|
- [ ] `guild list` shows all available guilds with descriptions
|
||||||
- [ ] `guild info <name>` shows guild details, growth stats, and spell list
|
- [ ] `guild info <name>` shows guild details, growth stats, and spell list
|
||||||
@@ -238,82 +245,13 @@ Run through the checks below before every commit to ensure consistent feature co
|
|||||||
- [ ] ←→ switches player on Attitudes tab
|
- [ ] ←→ switches player on Attitudes tab
|
||||||
- [ ] 'q' exits TUI
|
- [ ] 'q' exits TUI
|
||||||
|
|
||||||
|
## JSON-RPC Interface
|
||||||
|
- [ ] `list_commands` returns the currently handleable command list
|
||||||
|
- [ ] New commands added in `commands.rs` are automatically discovered
|
||||||
|
- [ ] `login` accepts an existing player name (requires character to be created first)
|
||||||
|
- [ ] Command output is stripped of ANSI color codes for API consumption
|
||||||
|
- [ ] Verify manually with: `echo '{"_jsonrpc": "2.0", "method": "list_commands", "params": {}, "id": 1}' | nc localhost 2223`
|
||||||
|
|
||||||
## Quick Smoke Test Script
|
## Quick Smoke Test Script
|
||||||
|
|
||||||
The canonical implementation is **`./run-tests.sh`** (see top of this file). The following is the same sequence for reference; when writing or extending tests, keep `run-tests.sh` and this section in sync.
|
**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.
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start server in background (use -d for test DB so you don't overwrite mudserver.db)
|
|
||||||
TEST_DB=./mudserver.db.test
|
|
||||||
RUST_LOG=info ./target/debug/mudserver -d "$TEST_DB" &
|
|
||||||
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 (use same test DB)
|
|
||||||
./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
|
|
||||||
|
|
||||||
# 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 -d "$TEST_DB" 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 -d "$TEST_DB" settings set registration_open true
|
|
||||||
./target/debug/mudtool -d "$TEST_DB" 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 -d "$TEST_DB" settings set registration_open true
|
|
||||||
./target/debug/mudtool -d "$TEST_DB" players delete smoketest
|
|
||||||
kill $SERVER_PID
|
|
||||||
```
|
|
||||||
|
|||||||
50
run-tests.sh
50
run-tests.sh
@@ -1,10 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -ex
|
set -ex
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
TEST_DB=${MUD_TEST_DB:-./mudserver.db.test}
|
TEST_DB=${MUD_TEST_DB:-./mudserver.db.test}
|
||||||
SERVER_PID=
|
SERVER_PID=
|
||||||
|
|
||||||
# SSH returns 255 when MUD closes connection after quit — treat as success
|
|
||||||
ssh_mud() {
|
ssh_mud() {
|
||||||
set +e
|
set +e
|
||||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 "$@"
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 "$@"
|
||||||
@@ -23,9 +25,8 @@ trap cleanup EXIT
|
|||||||
cargo build
|
cargo build
|
||||||
RUST_LOG=info ./target/debug/mudserver -d "$TEST_DB" &
|
RUST_LOG=info ./target/debug/mudserver -d "$TEST_DB" &
|
||||||
SERVER_PID=$!
|
SERVER_PID=$!
|
||||||
sleep 2
|
bash "$ROOT/scripts/ci/wait-for-tcp.sh" 127.0.0.1 2222
|
||||||
|
|
||||||
# Test 1: New player creation + basic commands
|
|
||||||
ssh_mud smoketest@localhost <<'EOF'
|
ssh_mud smoketest@localhost <<'EOF'
|
||||||
1
|
1
|
||||||
1
|
1
|
||||||
@@ -43,21 +44,37 @@ flee
|
|||||||
quit
|
quit
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Test 2: Persistence - reconnect
|
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'
|
ssh_mud smoketest@localhost <<'EOF'
|
||||||
look
|
look
|
||||||
stats
|
stats
|
||||||
quit
|
quit
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Test 3: Admin via mudtool (use same test DB)
|
|
||||||
./target/debug/mudtool -d "$TEST_DB" players list
|
./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 set-admin smoketest true
|
||||||
./target/debug/mudtool -d "$TEST_DB" players show smoketest
|
./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 set registration_open false
|
||||||
./target/debug/mudtool -d "$TEST_DB" settings list
|
./target/debug/mudtool -d "$TEST_DB" settings list
|
||||||
|
|
||||||
# Test 4: Admin commands in-game
|
|
||||||
ssh_mud smoketest@localhost <<'EOF'
|
ssh_mud smoketest@localhost <<'EOF'
|
||||||
admin help
|
admin help
|
||||||
admin list
|
admin list
|
||||||
@@ -66,16 +83,13 @@ admin info smoketest
|
|||||||
quit
|
quit
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Test 5: Registration gate
|
|
||||||
./target/debug/mudtool -d "$TEST_DB" settings set registration_open false
|
./target/debug/mudtool -d "$TEST_DB" settings set registration_open false
|
||||||
ssh_mud newplayer@localhost <<'EOF'
|
ssh_mud newplayer@localhost <<'EOF'
|
||||||
quit
|
quit
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Test 6: Tick-based combat (connect and wait for ticks)
|
|
||||||
./target/debug/mudtool -d "$TEST_DB" settings set registration_open true
|
./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 smoketest
|
||||||
# Use subshell to pipe commands with a delay between them while staying connected
|
|
||||||
(
|
(
|
||||||
echo "1"
|
echo "1"
|
||||||
echo "1"
|
echo "1"
|
||||||
@@ -88,6 +102,22 @@ EOF
|
|||||||
echo "quit"
|
echo "quit"
|
||||||
) | ssh_mud smoketest@localhost
|
) | ssh_mud smoketest@localhost
|
||||||
|
|
||||||
# Cleanup (trap handles server kill)
|
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" settings set registration_open true
|
||||||
./target/debug/mudtool -d "$TEST_DB" players delete smoketest
|
./target/debug/mudtool -d "$TEST_DB" players delete smoketest
|
||||||
|
./target/debug/mudtool -d "$TEST_DB" players delete rpctest
|
||||||
|
|||||||
13
scripts/ci/wait-for-tcp.sh
Executable file
13
scripts/ci/wait-for-tcp.sh
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
host=${1:-127.0.0.1}
|
||||||
|
port=${2:-2222}
|
||||||
|
max_attempts=${3:-30}
|
||||||
|
for _ in $(seq 1 "$max_attempts"); do
|
||||||
|
if nc -z -w 1 "$host" "$port" 2>/dev/null; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "timeout waiting for $host:$port" >&2
|
||||||
|
exit 1
|
||||||
@@ -74,6 +74,7 @@ async fn admin_promote(target: &str, state: &SharedState) -> CommandResult {
|
|||||||
if target.is_empty() {
|
if target.is_empty() {
|
||||||
return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin promote <player>")));
|
return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin promote <player>")));
|
||||||
}
|
}
|
||||||
|
log::info!("Admin action: promote player '{}'", target);
|
||||||
let st = state.lock().await;
|
let st = state.lock().await;
|
||||||
if st.db.set_admin(target, true) {
|
if st.db.set_admin(target, true) {
|
||||||
// Also update in-memory if online
|
// Also update in-memory if online
|
||||||
@@ -124,6 +125,7 @@ async fn admin_demote(target: &str, state: &SharedState) -> CommandResult {
|
|||||||
if target.is_empty() {
|
if target.is_empty() {
|
||||||
return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin demote <player>")));
|
return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin demote <player>")));
|
||||||
}
|
}
|
||||||
|
log::info!("Admin action: demote player '{}'", target);
|
||||||
let st = state.lock().await;
|
let st = state.lock().await;
|
||||||
if st.db.set_admin(target, false) {
|
if st.db.set_admin(target, false) {
|
||||||
simple(&format!(
|
simple(&format!(
|
||||||
@@ -142,6 +144,7 @@ async fn admin_kick(target: &str, player_id: usize, state: &SharedState) -> Comm
|
|||||||
if target.is_empty() {
|
if target.is_empty() {
|
||||||
return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin kick <player>")));
|
return simple(&format!("{}\r\n", ansi::error_msg("Usage: admin kick <player>")));
|
||||||
}
|
}
|
||||||
|
log::info!("Admin action: kick player '{}'", target);
|
||||||
let mut st = state.lock().await;
|
let mut st = state.lock().await;
|
||||||
let low = target.to_lowercase();
|
let low = target.to_lowercase();
|
||||||
|
|
||||||
@@ -237,6 +240,7 @@ async fn admin_teleport(room_id: &str, player_id: usize, state: &SharedState) ->
|
|||||||
ansi::error_msg("Usage: admin teleport <room_id>")
|
ansi::error_msg("Usage: admin teleport <room_id>")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
log::info!("Admin action: teleport player ID {} to '{}'", player_id, room_id);
|
||||||
let mut st = state.lock().await;
|
let mut st = state.lock().await;
|
||||||
if st.world.get_room(room_id).is_none() {
|
if st.world.get_room(room_id).is_none() {
|
||||||
let rooms: Vec<&String> = st.world.rooms.keys().collect();
|
let rooms: Vec<&String> = st.world.rooms.keys().collect();
|
||||||
@@ -329,6 +333,7 @@ async fn admin_teleport(room_id: &str, player_id: usize, state: &SharedState) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_registration(args: &str, state: &SharedState) -> CommandResult {
|
async fn admin_registration(args: &str, state: &SharedState) -> CommandResult {
|
||||||
|
log::info!("Admin action: registration setting updated: '{}'", args);
|
||||||
let st = state.lock().await;
|
let st = state.lock().await;
|
||||||
match args.to_lowercase().as_str() {
|
match args.to_lowercase().as_str() {
|
||||||
"on" | "true" | "open" => {
|
"on" | "true" | "open" => {
|
||||||
@@ -365,6 +370,7 @@ async fn admin_announce(msg: &str, player_id: usize, state: &SharedState) -> Com
|
|||||||
ansi::error_msg("Usage: admin announce <message>")
|
ansi::error_msg("Usage: admin announce <message>")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
log::info!("Admin action: announcement by player ID {}: '{}'", player_id, msg);
|
||||||
let st = state.lock().await;
|
let st = state.lock().await;
|
||||||
let announcement = CryptoVec::from(
|
let announcement = CryptoVec::from(
|
||||||
format!(
|
format!(
|
||||||
@@ -405,6 +411,7 @@ async fn admin_announce(msg: &str, player_id: usize, state: &SharedState) -> Com
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_heal(args: &str, player_id: usize, state: &SharedState) -> CommandResult {
|
async fn admin_heal(args: &str, player_id: usize, state: &SharedState) -> CommandResult {
|
||||||
|
log::info!("Admin action: heal player '{}' (empty means self)", args);
|
||||||
let mut st = state.lock().await;
|
let mut st = state.lock().await;
|
||||||
|
|
||||||
if args.is_empty() {
|
if args.is_empty() {
|
||||||
@@ -564,6 +571,7 @@ async fn admin_info(target: &str, state: &SharedState) -> CommandResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_setattitude(args: &str, state: &SharedState) -> CommandResult {
|
async fn admin_setattitude(args: &str, state: &SharedState) -> CommandResult {
|
||||||
|
log::info!("Admin action: setattitude '{}'", args);
|
||||||
let parts: Vec<&str> = args.splitn(3, ' ').collect();
|
let parts: Vec<&str> = args.splitn(3, ' ').collect();
|
||||||
if parts.len() < 3 {
|
if parts.len() < 3 {
|
||||||
return simple(&format!(
|
return simple(&format!(
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ pub fn resolve_combat_tick(
|
|||||||
));
|
));
|
||||||
|
|
||||||
if new_npc_hp <= 0 {
|
if new_npc_hp <= 0 {
|
||||||
|
let player_name = state.players.get(&player_id).map(|c| c.player.name.clone()).unwrap_or_else(|| "Unknown".into());
|
||||||
|
log::info!(target: "{combat}", "Combat: Player '{}' (ID {}) killed NPC '{}' ({})", player_name, player_id, npc_template.name, npc_id);
|
||||||
if let Some(inst) = state.npc_instances.get_mut(&npc_id) {
|
if let Some(inst) = state.npc_instances.get_mut(&npc_id) {
|
||||||
inst.alive = false;
|
inst.alive = false;
|
||||||
inst.hp = 0;
|
inst.hp = 0;
|
||||||
@@ -76,17 +78,24 @@ pub fn resolve_combat_tick(
|
|||||||
}
|
}
|
||||||
npc_died = true;
|
npc_died = true;
|
||||||
xp_gained = npc_combat.xp_reward;
|
xp_gained = npc_combat.xp_reward;
|
||||||
|
let gold_gained = npc_template.gold;
|
||||||
|
let silver_gained = npc_template.silver;
|
||||||
|
let copper_gained = npc_template.copper;
|
||||||
|
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
" {} {} collapses! You gain {} XP.\r\n",
|
" {} {} collapses! You gain {} XP and {}g {}s {}c.\r\n",
|
||||||
ansi::color(ansi::GREEN, "**"),
|
ansi::color(ansi::GREEN, "**"),
|
||||||
ansi::color(ansi::RED, &npc_template.name),
|
ansi::color(ansi::RED, &npc_template.name),
|
||||||
ansi::bold(&xp_gained.to_string()),
|
ansi::bold(&xp_gained.to_string()),
|
||||||
|
gold_gained, silver_gained, copper_gained
|
||||||
));
|
));
|
||||||
|
|
||||||
if let Some(conn) = state.players.get_mut(&player_id) {
|
if let Some(conn) = state.players.get_mut(&player_id) {
|
||||||
conn.combat = None;
|
conn.combat = None;
|
||||||
conn.player.stats.xp += xp_gained;
|
conn.player.stats.xp += xp_gained;
|
||||||
|
conn.player.gold += gold_gained;
|
||||||
|
conn.player.silver += silver_gained;
|
||||||
|
conn.player.copper += copper_gained;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if let Some(inst) = state.npc_instances.get_mut(&npc_id) {
|
if let Some(inst) = state.npc_instances.get_mut(&npc_id) {
|
||||||
@@ -344,7 +353,9 @@ pub fn player_death_respawn(player_id: usize, state: &mut GameState) -> String {
|
|||||||
.players
|
.players
|
||||||
.get(&player_id)
|
.get(&player_id)
|
||||||
.map(|c| c.player.name.clone())
|
.map(|c| c.player.name.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_else(|| "Unknown".into());
|
||||||
|
|
||||||
|
log::info!(target: "{combat}", "Combat: Player '{}' (ID {}) died and respawned at {}", player_name, player_id, spawn_room);
|
||||||
|
|
||||||
if let Some(conn) = state.players.get_mut(&player_id) {
|
if let Some(conn) = state.players.get_mut(&player_id) {
|
||||||
conn.player.stats.hp = conn.player.stats.max_hp;
|
conn.player.stats.hp = conn.player.stats.max_hp;
|
||||||
|
|||||||
274
src/commands.rs
274
src/commands.rs
@@ -134,6 +134,7 @@ pub async fn execute(
|
|||||||
"spells" | "skills" => cmd_spells(player_id, state).await,
|
"spells" | "skills" => cmd_spells(player_id, state).await,
|
||||||
"guild" => cmd_guild(player_id, &args, state).await,
|
"guild" => cmd_guild(player_id, &args, state).await,
|
||||||
"stats" | "st" => cmd_stats(player_id, 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,
|
"admin" => cmd_admin(player_id, &args, state).await,
|
||||||
"help" | "h" | "?" => cmd_help(player_id, state).await,
|
"help" | "h" | "?" => cmd_help(player_id, state).await,
|
||||||
"quit" | "exit" => CommandResult {
|
"quit" | "exit" => CommandResult {
|
||||||
@@ -156,6 +157,15 @@ pub async fn execute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
fn send(session: &mut Session, channel: ChannelId, text: &str) -> Result<(), russh::Error> {
|
||||||
session.data(channel, CryptoVec::from(text.as_bytes()))?;
|
session.data(channel, CryptoVec::from(text.as_bytes()))?;
|
||||||
@@ -170,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(
|
pub fn render_room_view(
|
||||||
room_id: &str,
|
room_id: &str,
|
||||||
player_id: usize,
|
player_id: usize,
|
||||||
@@ -185,13 +203,19 @@ pub fn render_room_view(
|
|||||||
.map(|c| c.player.name.as_str())
|
.map(|c| c.player.name.as_str())
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let time_of_day = get_time_of_day(st.tick_count);
|
||||||
let mut out = format!(
|
let mut out = format!(
|
||||||
"\r\n{} {}\r\n {}\r\n",
|
"\r\n{} {} {}\r\n {}\r\n",
|
||||||
ansi::room_name(&room.name),
|
ansi::room_name(&room.name),
|
||||||
ansi::system_msg(&format!("[{}]", room.region)),
|
ansi::system_msg(&format!("[{}]", room.region)),
|
||||||
|
ansi::color(ansi::YELLOW, &format!("[{}]", time_of_day)),
|
||||||
room.description
|
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
|
let npc_strs: Vec<String> = room
|
||||||
.npcs
|
.npcs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -999,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 {
|
async fn cmd_talk(pid: usize, input: &str, state: &SharedState) -> CommandResult {
|
||||||
if target.is_empty() {
|
if input.is_empty() {
|
||||||
return simple("Talk to whom?\r\n");
|
return simple("Talk to whom? (Usage: talk <npc> [keyword])\r\n");
|
||||||
}
|
}
|
||||||
let st = state.lock().await;
|
let st = state.lock().await;
|
||||||
let conn = match st.players.get(&pid) {
|
let conn = match st.players.get(&pid) {
|
||||||
@@ -1012,12 +1036,17 @@ async fn cmd_talk(pid: usize, target: &str, state: &SharedState) -> CommandResul
|
|||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
None => return simple("Void\r\n"),
|
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;
|
let pname = &conn.player.name;
|
||||||
|
|
||||||
for nid in &room.npcs {
|
for nid in &room.npcs {
|
||||||
if let Some(npc) = st.world.get_npc(nid) {
|
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) {
|
if !st.npc_instances.get(nid).map(|i| i.alive).unwrap_or(true) {
|
||||||
return simple(&format!(
|
return simple(&format!(
|
||||||
"{}\r\n",
|
"{}\r\n",
|
||||||
@@ -1031,13 +1060,54 @@ async fn cmd_talk(pid: usize, target: &str, state: &SharedState) -> CommandResul
|
|||||||
ansi::color(ansi::RED, &npc.name)
|
ansi::color(ansi::RED, &npc.name)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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, 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 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 {
|
return CommandResult {
|
||||||
output: format!(
|
output,
|
||||||
"\r\n{} says: \"{}\"\r\n",
|
|
||||||
ansi::color(ansi::YELLOW, &npc.name),
|
|
||||||
ansi::color(ansi::WHITE, greeting)
|
|
||||||
),
|
|
||||||
broadcasts: Vec::new(),
|
broadcasts: Vec::new(),
|
||||||
kick_targets: Vec::new(),
|
kick_targets: Vec::new(),
|
||||||
quit: false,
|
quit: false,
|
||||||
@@ -1166,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 {
|
CommandResult {
|
||||||
output: format!(
|
output: format!(
|
||||||
"{}\r\n{}\r\n{}",
|
"{}\r\n{}\r\n{}",
|
||||||
@@ -1427,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 {
|
async fn cmd_guild(pid: usize, args: &str, state: &SharedState) -> CommandResult {
|
||||||
let (subcmd, subargs) = match args.split_once(' ') {
|
let (subcmd, subargs) = match args.split_once(' ') {
|
||||||
Some((c, a)) => (c.to_lowercase(), a.trim().to_string()),
|
Some((c, a)) => (c.to_lowercase(), a.trim().to_string()),
|
||||||
@@ -1665,6 +1909,14 @@ async fn cmd_stats(pid: usize, state: &SharedState) -> CommandResult {
|
|||||||
s.xp,
|
s.xp,
|
||||||
s.xp_to_next
|
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() {
|
if !p.guilds.is_empty() {
|
||||||
out.push_str(&format!(" {}\r\n", ansi::color(ansi::DIM, "Guilds:")));
|
out.push_str(&format!(" {}\r\n", ansi::color(ansi::DIM, "Guilds:")));
|
||||||
let mut guild_list: Vec<_> = p.guilds.iter().collect();
|
let mut guild_list: Vec<_> = p.guilds.iter().collect();
|
||||||
|
|||||||
65
src/db.rs
65
src/db.rs
@@ -20,6 +20,9 @@ pub struct SavedPlayer {
|
|||||||
pub endurance: i32,
|
pub endurance: i32,
|
||||||
pub max_endurance: i32,
|
pub max_endurance: i32,
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
|
pub gold: i32,
|
||||||
|
pub silver: i32,
|
||||||
|
pub copper: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct NpcAttitudeRow {
|
pub struct NpcAttitudeRow {
|
||||||
@@ -75,7 +78,7 @@ impl SqliteDb {
|
|||||||
.map_err(|e| format!("Failed to set pragmas: {e}"))?;
|
.map_err(|e| format!("Failed to set pragmas: {e}"))?;
|
||||||
|
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"CREATE TABLE IF NOT EXISTS players (
|
r#"CREATE TABLE IF NOT EXISTS players (
|
||||||
name TEXT PRIMARY KEY,
|
name TEXT PRIMARY KEY,
|
||||||
race_id TEXT NOT NULL,
|
race_id TEXT NOT NULL,
|
||||||
class_id TEXT NOT NULL,
|
class_id TEXT NOT NULL,
|
||||||
@@ -86,11 +89,19 @@ impl SqliteDb {
|
|||||||
max_hp INTEGER NOT NULL,
|
max_hp INTEGER NOT NULL,
|
||||||
attack INTEGER NOT NULL,
|
attack INTEGER NOT NULL,
|
||||||
defense INTEGER NOT NULL,
|
defense INTEGER NOT NULL,
|
||||||
inventory_json TEXT NOT NULL DEFAULT '[]',
|
inventory_json TEXT NOT NULL DEFAULT "[]",
|
||||||
equipped_json TEXT NOT NULL DEFAULT '{}',
|
equipped_json TEXT NOT NULL DEFAULT "{}",
|
||||||
is_admin INTEGER NOT NULL DEFAULT 0
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
mana INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_mana INTEGER NOT NULL DEFAULT 0,
|
||||||
|
endurance INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_endurance INTEGER NOT NULL DEFAULT 0,
|
||||||
|
gold INTEGER NOT NULL DEFAULT 0,
|
||||||
|
silver INTEGER NOT NULL DEFAULT 0,
|
||||||
|
copper INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS npc_attitudes (
|
CREATE TABLE IF NOT EXISTS npc_attitudes (
|
||||||
player_name TEXT NOT NULL,
|
player_name TEXT NOT NULL,
|
||||||
npc_id TEXT NOT NULL,
|
npc_id TEXT NOT NULL,
|
||||||
@@ -116,13 +127,13 @@ impl SqliteDb {
|
|||||||
guild_id TEXT NOT NULL,
|
guild_id TEXT NOT NULL,
|
||||||
level INTEGER NOT NULL DEFAULT 1,
|
level INTEGER NOT NULL DEFAULT 1,
|
||||||
PRIMARY KEY (player_name, guild_id)
|
PRIMARY KEY (player_name, guild_id)
|
||||||
);",
|
);"#,
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("Failed to create tables: {e}"))?;
|
.map_err(|e| format!("Failed to create tables: {e}"))?;
|
||||||
|
|
||||||
// Migration: add is_admin column if missing
|
// Migration: add is_admin column if missing
|
||||||
let has_admin: bool = conn
|
let has_admin: bool = conn
|
||||||
.prepare("SELECT COUNT(*) FROM pragma_table_info('players') WHERE name='is_admin'")
|
.prepare(r#"SELECT COUNT(*) FROM pragma_table_info("players") WHERE name="is_admin""#)
|
||||||
.and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0)))
|
.and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0)))
|
||||||
.map(|c| c > 0)
|
.map(|c| c > 0)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
@@ -135,34 +146,34 @@ impl SqliteDb {
|
|||||||
|
|
||||||
// Migration: equipped_weapon_json/equipped_armor_json -> equipped_json
|
// Migration: equipped_weapon_json/equipped_armor_json -> equipped_json
|
||||||
let has_old_weapon: bool = conn
|
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)))
|
.and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0)))
|
||||||
.map(|c| c > 0)
|
.map(|c| c > 0)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let has_equipped: bool = conn
|
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)))
|
.and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0)))
|
||||||
.map(|c| c > 0)
|
.map(|c| c > 0)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if has_old_weapon && !has_equipped {
|
if has_old_weapon && !has_equipped {
|
||||||
let _ = conn.execute(
|
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...");
|
log::info!("Migrating equipped_weapon_json/equipped_armor_json to equipped_json...");
|
||||||
let _ = conn.execute_batch(
|
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 {
|
} else if !has_equipped {
|
||||||
let _ = conn.execute(
|
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
|
// Migration: add mana/endurance columns
|
||||||
let has_mana: bool = conn
|
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)))
|
.and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0)))
|
||||||
.map(|c| c > 0)
|
.map(|c| c > 0)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
@@ -173,6 +184,18 @@ impl SqliteDb {
|
|||||||
let _ = conn.execute("ALTER TABLE players ADD COLUMN max_endurance INTEGER NOT NULL DEFAULT 0", []);
|
let _ = conn.execute("ALTER TABLE players ADD COLUMN max_endurance INTEGER NOT NULL DEFAULT 0", []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: add currency columns
|
||||||
|
let has_gold: bool = conn
|
||||||
|
.prepare(r#"SELECT COUNT(*) FROM pragma_table_info("players") WHERE name="gold""#)
|
||||||
|
.and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0)))
|
||||||
|
.map(|c| c > 0)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !has_gold {
|
||||||
|
let _ = conn.execute("ALTER TABLE players ADD COLUMN gold INTEGER NOT NULL DEFAULT 0", []);
|
||||||
|
let _ = conn.execute("ALTER TABLE players ADD COLUMN silver INTEGER NOT NULL DEFAULT 0", []);
|
||||||
|
let _ = conn.execute("ALTER TABLE players ADD COLUMN copper INTEGER NOT NULL DEFAULT 0", []);
|
||||||
|
}
|
||||||
|
|
||||||
log::info!("Database opened: {}", path.display());
|
log::info!("Database opened: {}", path.display());
|
||||||
Ok(SqliteDb {
|
Ok(SqliteDb {
|
||||||
conn: std::sync::Mutex::new(conn),
|
conn: std::sync::Mutex::new(conn),
|
||||||
@@ -186,7 +209,7 @@ impl GameDb for SqliteDb {
|
|||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
||||||
attack, defense, inventory_json, equipped_json, is_admin,
|
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",
|
FROM players WHERE name = ?1",
|
||||||
[name],
|
[name],
|
||||||
|row| {
|
|row| {
|
||||||
@@ -208,6 +231,9 @@ impl GameDb for SqliteDb {
|
|||||||
max_mana: row.get::<_, i32>(14).unwrap_or(0),
|
max_mana: row.get::<_, i32>(14).unwrap_or(0),
|
||||||
endurance: row.get::<_, i32>(15).unwrap_or(0),
|
endurance: row.get::<_, i32>(15).unwrap_or(0),
|
||||||
max_endurance: row.get::<_, i32>(16).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(
|
let _ = conn.execute(
|
||||||
"INSERT INTO players (name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
"INSERT INTO players (name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
||||||
attack, defense, inventory_json, equipped_json, is_admin,
|
attack, defense, inventory_json, equipped_json, is_admin,
|
||||||
mana, max_mana, endurance, max_endurance)
|
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)
|
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20)
|
||||||
ON CONFLICT(name) DO UPDATE SET
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
room_id=excluded.room_id, level=excluded.level, xp=excluded.xp,
|
room_id=excluded.room_id, level=excluded.level, xp=excluded.xp,
|
||||||
hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack,
|
hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack,
|
||||||
defense=excluded.defense, inventory_json=excluded.inventory_json,
|
defense=excluded.defense, inventory_json=excluded.inventory_json,
|
||||||
equipped_json=excluded.equipped_json, is_admin=excluded.is_admin,
|
equipped_json=excluded.equipped_json, is_admin=excluded.is_admin,
|
||||||
mana=excluded.mana, max_mana=excluded.max_mana,
|
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![
|
rusqlite::params![
|
||||||
p.name, p.race_id, p.class_id, p.room_id, p.level, p.xp,
|
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.hp, p.max_hp, p.attack, p.defense,
|
||||||
p.inventory_json, p.equipped_json, p.is_admin as i32,
|
p.inventory_json, p.equipped_json, p.is_admin as i32,
|
||||||
p.mana, p.max_mana, p.endurance, p.max_endurance,
|
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(
|
.prepare(
|
||||||
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
||||||
attack, defense, inventory_json, equipped_json, is_admin,
|
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",
|
FROM players ORDER BY name",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -285,6 +313,9 @@ impl GameDb for SqliteDb {
|
|||||||
max_mana: row.get::<_, i32>(14).unwrap_or(0),
|
max_mana: row.get::<_, i32>(14).unwrap_or(0),
|
||||||
endurance: row.get::<_, i32>(15).unwrap_or(0),
|
endurance: row.get::<_, i32>(15).unwrap_or(0),
|
||||||
max_endurance: row.get::<_, i32>(16).unwrap_or(0),
|
max_endurance: row.get::<_, i32>(16).unwrap_or(0),
|
||||||
|
gold: row.get::<_, i32>(17).unwrap_or(0),
|
||||||
|
silver: row.get::<_, i32>(18).unwrap_or(0),
|
||||||
|
copper: row.get::<_, i32>(19).unwrap_or(0),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
46
src/game.rs
46
src/game.rs
@@ -9,6 +9,35 @@ use russh::ChannelId;
|
|||||||
use crate::db::{GameDb, SavedPlayer};
|
use crate::db::{GameDb, SavedPlayer};
|
||||||
use crate::world::{Attitude, Class, Object, Race, World};
|
use crate::world::{Attitude, Class, Object, Race, World};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum WeatherKind {
|
||||||
|
Clear,
|
||||||
|
Cloudy,
|
||||||
|
Rain,
|
||||||
|
Storm,
|
||||||
|
Snow,
|
||||||
|
Fog,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WeatherKind {
|
||||||
|
pub fn description(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
WeatherKind::Clear => "The sky is clear.",
|
||||||
|
WeatherKind::Cloudy => "The sky is overcast with clouds.",
|
||||||
|
WeatherKind::Rain => "It is raining.",
|
||||||
|
WeatherKind::Storm => "A powerful storm is raging.",
|
||||||
|
WeatherKind::Snow => "Soft snowflakes are falling from the sky.",
|
||||||
|
WeatherKind::Fog => "A thick fog blankets the area.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WeatherState {
|
||||||
|
pub kind: WeatherKind,
|
||||||
|
pub remaining_ticks: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct PlayerStats {
|
pub struct PlayerStats {
|
||||||
pub max_hp: i32,
|
pub max_hp: i32,
|
||||||
@@ -35,6 +64,9 @@ pub struct Player {
|
|||||||
pub guilds: HashMap<String, i32>,
|
pub guilds: HashMap<String, i32>,
|
||||||
pub cooldowns: HashMap<String, i32>,
|
pub cooldowns: HashMap<String, i32>,
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
|
pub gold: i32,
|
||||||
|
pub silver: i32,
|
||||||
|
pub copper: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Player {
|
impl Player {
|
||||||
@@ -139,6 +171,7 @@ pub struct GameState {
|
|||||||
pub npc_instances: HashMap<String, NpcInstance>,
|
pub npc_instances: HashMap<String, NpcInstance>,
|
||||||
pub rng: XorShift64,
|
pub rng: XorShift64,
|
||||||
pub tick_count: u64,
|
pub tick_count: u64,
|
||||||
|
pub weather: WeatherState,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type SharedState = Arc<Mutex<GameState>>;
|
pub type SharedState = Arc<Mutex<GameState>>;
|
||||||
@@ -223,6 +256,10 @@ impl GameState {
|
|||||||
npc_instances,
|
npc_instances,
|
||||||
rng,
|
rng,
|
||||||
tick_count: 0,
|
tick_count: 0,
|
||||||
|
weather: WeatherState {
|
||||||
|
kind: WeatherKind::Clear,
|
||||||
|
remaining_ticks: 100,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,6 +381,9 @@ impl GameState {
|
|||||||
guilds,
|
guilds,
|
||||||
cooldowns: HashMap::new(),
|
cooldowns: HashMap::new(),
|
||||||
is_admin: false,
|
is_admin: false,
|
||||||
|
gold: 0,
|
||||||
|
silver: 0,
|
||||||
|
copper: 10, // Start with some copper
|
||||||
},
|
},
|
||||||
channel,
|
channel,
|
||||||
handle,
|
handle,
|
||||||
@@ -401,6 +441,9 @@ impl GameState {
|
|||||||
guilds,
|
guilds,
|
||||||
cooldowns: HashMap::new(),
|
cooldowns: HashMap::new(),
|
||||||
is_admin: saved.is_admin,
|
is_admin: saved.is_admin,
|
||||||
|
gold: saved.gold,
|
||||||
|
silver: saved.silver,
|
||||||
|
copper: saved.copper,
|
||||||
},
|
},
|
||||||
channel,
|
channel,
|
||||||
handle,
|
handle,
|
||||||
@@ -435,6 +478,9 @@ impl GameState {
|
|||||||
endurance: p.stats.endurance,
|
endurance: p.stats.endurance,
|
||||||
max_endurance: p.stats.max_endurance,
|
max_endurance: p.stats.max_endurance,
|
||||||
is_admin: p.is_admin,
|
is_admin: p.is_admin,
|
||||||
|
gold: p.gold,
|
||||||
|
silver: p.silver,
|
||||||
|
copper: p.copper,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use crate::commands;
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct JsonRpcRequest {
|
struct JsonRpcRequest {
|
||||||
jsonrpc: String,
|
_jsonrpc: String,
|
||||||
method: String,
|
method: String,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
id: Option<serde_json::Value>,
|
id: Option<serde_json::Value>,
|
||||||
@@ -126,12 +126,7 @@ async fn handle_request(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"list_commands" => {
|
"list_commands" => {
|
||||||
json!([
|
json!(commands::get_command_list())
|
||||||
"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"
|
|
||||||
])
|
|
||||||
},
|
},
|
||||||
"execute" => {
|
"execute" => {
|
||||||
if let Some(pid) = *current_player_id {
|
if let Some(pid) = *current_player_id {
|
||||||
|
|||||||
52
src/main.rs
52
src/main.rs
@@ -2,6 +2,8 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use flexi_logger::writers::FileLogWriter;
|
||||||
|
use flexi_logger::{Cleanup, Criterion, Duplicate, FileSpec, Logger, Naming, WriteMode};
|
||||||
use russh::keys::ssh_key::rand_core::OsRng;
|
use russh::keys::ssh_key::rand_core::OsRng;
|
||||||
use russh::server::Server as _;
|
use russh::server::Server as _;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
@@ -18,12 +20,12 @@ const DEFAULT_DB_PATH: &str = "./mudserver.db";
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
|
||||||
|
|
||||||
let mut port = DEFAULT_PORT;
|
let mut port = DEFAULT_PORT;
|
||||||
let mut jsonrpc_port = 2223;
|
let mut jsonrpc_port = 2223;
|
||||||
let mut world_dir = PathBuf::from(DEFAULT_WORLD_DIR);
|
let mut world_dir = PathBuf::from(DEFAULT_WORLD_DIR);
|
||||||
let mut db_path = PathBuf::from(DEFAULT_DB_PATH);
|
let mut db_path = PathBuf::from(DEFAULT_DB_PATH);
|
||||||
|
let mut log_dir = "logs".to_string();
|
||||||
|
let mut log_level = "info".to_string();
|
||||||
|
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
let mut i = 1;
|
let mut i = 1;
|
||||||
@@ -51,12 +53,22 @@ async fn main() {
|
|||||||
i += 1;
|
i += 1;
|
||||||
db_path = PathBuf::from(args.get(i).expect("--db requires a path"));
|
db_path = PathBuf::from(args.get(i).expect("--db requires a path"));
|
||||||
}
|
}
|
||||||
|
"--log-dir" => {
|
||||||
|
i += 1;
|
||||||
|
log_dir = args.get(i).expect("--log-dir requires a path").to_string();
|
||||||
|
}
|
||||||
|
"--log-level" => {
|
||||||
|
i += 1;
|
||||||
|
log_level = args.get(i).expect("--log-level requires a level").to_string();
|
||||||
|
}
|
||||||
"--help" => {
|
"--help" => {
|
||||||
eprintln!("Usage: mudserver [OPTIONS]");
|
eprintln!("Usage: mudserver [OPTIONS]");
|
||||||
eprintln!(" --port, -p SSH listen port (default: {DEFAULT_PORT})");
|
eprintln!(" --port, -p SSH listen port (default: {DEFAULT_PORT})");
|
||||||
eprintln!(" --rpc-port JSON-RPC listen port (default: 2223)");
|
eprintln!(" --rpc-port JSON-RPC listen port (default: 2223)");
|
||||||
eprintln!(" --world, -w World directory (default: {DEFAULT_WORLD_DIR})");
|
eprintln!(" --world, -w World directory (default: {DEFAULT_WORLD_DIR})");
|
||||||
eprintln!(" --db, -d Database path (default: {DEFAULT_DB_PATH})");
|
eprintln!(" --db, -d Database path (default: {DEFAULT_DB_PATH})");
|
||||||
|
eprintln!(" --log-dir Directory for log files (default: logs)");
|
||||||
|
eprintln!(" --log-level Logging level (default: info)");
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
@@ -67,6 +79,42 @@ async fn main() {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure log directory exists
|
||||||
|
std::fs::create_dir_all(&log_dir).unwrap_or_else(|e| {
|
||||||
|
eprintln!("Failed to create log directory: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize logger
|
||||||
|
let combat_writer = FileLogWriter::builder(FileSpec::default().directory(&log_dir).basename("combat"))
|
||||||
|
.rotate(
|
||||||
|
Criterion::Size(10_000_000), // 10 MB
|
||||||
|
Naming::Numbers,
|
||||||
|
Cleanup::KeepLogFiles(7),
|
||||||
|
)
|
||||||
|
.append()
|
||||||
|
.write_mode(WriteMode::Direct)
|
||||||
|
.try_build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
Logger::try_with_str(&log_level)
|
||||||
|
.unwrap()
|
||||||
|
.log_to_file(FileSpec::default().directory(&log_dir).basename("mudserver"))
|
||||||
|
.append()
|
||||||
|
.duplicate_to_stderr(Duplicate::All)
|
||||||
|
.rotate(
|
||||||
|
Criterion::Size(10_000_000), // 10 MB
|
||||||
|
Naming::Numbers,
|
||||||
|
Cleanup::KeepLogFiles(7),
|
||||||
|
)
|
||||||
|
.write_mode(WriteMode::Direct)
|
||||||
|
.add_writer("combat", Box::new(combat_writer))
|
||||||
|
.start()
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
eprintln!("Failed to initialize logger: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
log::info!("Loading world from: {}", world_dir.display());
|
log::info!("Loading world from: {}", world_dir.display());
|
||||||
let loaded_world = world::World::load(&world_dir).unwrap_or_else(|e| {
|
let loaded_world = world::World::load(&world_dir).unwrap_or_else(|e| {
|
||||||
eprintln!("Failed to load world: {e}");
|
eprintln!("Failed to load world: {e}");
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ impl MudHandler {
|
|||||||
state.load_existing_player(self.id, saved, Some(channel), Some(handle));
|
state.load_existing_player(self.id, saved, Some(channel), Some(handle));
|
||||||
drop(state);
|
drop(state);
|
||||||
|
|
||||||
|
log::info!("Player '{}' (id={}) logged in", self.username, self.id);
|
||||||
|
|
||||||
let msg = format!(
|
let msg = format!(
|
||||||
"{}\r\n",
|
"{}\r\n",
|
||||||
ansi::system_msg("Welcome back! Your character has been restored.")
|
ansi::system_msg("Welcome back! Your character has been restored.")
|
||||||
@@ -171,6 +173,13 @@ impl MudHandler {
|
|||||||
.map(|c| c.name.clone())
|
.map(|c| c.name.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"New character created: {} (Race: {}, Class: {})",
|
||||||
|
self.username,
|
||||||
|
race_name,
|
||||||
|
class_name
|
||||||
|
);
|
||||||
|
|
||||||
state.create_new_player(
|
state.create_new_player(
|
||||||
self.id,
|
self.id,
|
||||||
self.username.clone(),
|
self.username.clone(),
|
||||||
|
|||||||
72
src/tick.rs
72
src/tick.rs
@@ -26,6 +26,48 @@ pub async fn run_tick_engine(state: SharedState) {
|
|||||||
st.tick_count += 1;
|
st.tick_count += 1;
|
||||||
let tick = st.tick_count;
|
let tick = st.tick_count;
|
||||||
|
|
||||||
|
let mut weather_msg = None;
|
||||||
|
st.weather.remaining_ticks = st.weather.remaining_ticks.saturating_sub(1);
|
||||||
|
if st.weather.remaining_ticks == 0 {
|
||||||
|
let old_kind = st.weather.kind;
|
||||||
|
st.weather.kind = match st.rng.next_range(0, 5) {
|
||||||
|
0 => crate::game::WeatherKind::Clear,
|
||||||
|
1 => crate::game::WeatherKind::Cloudy,
|
||||||
|
2 => crate::game::WeatherKind::Rain,
|
||||||
|
3 => crate::game::WeatherKind::Storm,
|
||||||
|
4 => crate::game::WeatherKind::Snow,
|
||||||
|
5 => crate::game::WeatherKind::Fog,
|
||||||
|
_ => crate::game::WeatherKind::Clear,
|
||||||
|
};
|
||||||
|
st.weather.remaining_ticks = st.rng.next_range(100, 400) as u32;
|
||||||
|
|
||||||
|
if old_kind != st.weather.kind {
|
||||||
|
weather_msg = Some(format!(
|
||||||
|
"\r\n {}\r\n",
|
||||||
|
ansi::color(ansi::CYAN, st.weather.kind.description())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply "wet" effect if raining/storming
|
||||||
|
if st.weather.kind == crate::game::WeatherKind::Rain
|
||||||
|
|| st.weather.kind == crate::game::WeatherKind::Storm
|
||||||
|
{
|
||||||
|
let wet_players: Vec<String> = st
|
||||||
|
.players
|
||||||
|
.values()
|
||||||
|
.filter_map(|c| {
|
||||||
|
st.world
|
||||||
|
.get_room(&c.player.room_id)
|
||||||
|
.filter(|r| r.outdoors)
|
||||||
|
.map(|_| c.player.name.clone())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for name in wet_players {
|
||||||
|
st.db.save_effect(&name, "wet", 10, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
st.check_respawns();
|
st.check_respawns();
|
||||||
|
|
||||||
// --- NPC auto-aggro: hostile NPCs initiate combat with players in their room ---
|
// --- NPC auto-aggro: hostile NPCs initiate combat with players in their room ---
|
||||||
@@ -51,7 +93,7 @@ pub async fn run_tick_engine(state: SharedState) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let att = st.npc_attitude_toward(npc_id, &conn.player.name);
|
let att = st.npc_attitude_toward(npc_id, &conn.player.name);
|
||||||
if att.will_attack() {
|
if att.is_hostile() {
|
||||||
new_combats.push((*pid, npc_id.clone()));
|
new_combats.push((*pid, npc_id.clone()));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -60,6 +102,16 @@ pub async fn run_tick_engine(state: SharedState) {
|
|||||||
|
|
||||||
let mut messages: HashMap<usize, String> = HashMap::new();
|
let mut messages: HashMap<usize, String> = HashMap::new();
|
||||||
|
|
||||||
|
if let Some(msg) = weather_msg {
|
||||||
|
for (&pid, conn) in st.players.iter() {
|
||||||
|
if let Some(room) = st.world.get_room(&conn.player.room_id) {
|
||||||
|
if room.outdoors {
|
||||||
|
messages.entry(pid).or_default().push_str(&msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (pid, npc_id) in &new_combats {
|
for (pid, npc_id) in &new_combats {
|
||||||
let npc_name = st
|
let npc_name = st
|
||||||
.world
|
.world
|
||||||
@@ -183,6 +235,24 @@ pub async fn run_tick_engine(state: SharedState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"wet" => {
|
||||||
|
let online_pid = st
|
||||||
|
.players
|
||||||
|
.iter()
|
||||||
|
.find(|(_, c)| c.player.name == eff.player_name)
|
||||||
|
.map(|(&id, _)| id);
|
||||||
|
|
||||||
|
if let Some(pid) = online_pid {
|
||||||
|
if let Some(_conn) = st.players.get_mut(&pid) {
|
||||||
|
if eff.remaining_ticks <= 0 {
|
||||||
|
messages.entry(pid).or_default().push_str(&format!(
|
||||||
|
"\r\n {} You dry off.\r\n",
|
||||||
|
ansi::color(ansi::CYAN, "~~"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
"regen" => {
|
"regen" => {
|
||||||
let heal = eff.magnitude;
|
let heal = eff.magnitude;
|
||||||
let online_pid = st
|
let online_pid = st
|
||||||
|
|||||||
50
src/world.rs
50
src/world.rs
@@ -77,12 +77,26 @@ pub struct RoomFile {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub exits: HashMap<String, String>,
|
pub exits: HashMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub outdoors: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct ShopFile {
|
||||||
|
pub buys: Vec<String>, // List of item kinds or IDs the shop buys
|
||||||
|
pub sells: Vec<String>, // List of item IDs the shop sells
|
||||||
|
#[serde(default)]
|
||||||
|
pub markup: f32, // Multiplier for sell price (default 1.0)
|
||||||
|
#[serde(default)]
|
||||||
|
pub markdown: f32, // Multiplier for buy price (default 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
pub struct NpcDialogue {
|
pub struct NpcDialogue {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub greeting: Option<String>,
|
pub greeting: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub keywords: HashMap<String, String>, // keyword -> response
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -113,6 +127,14 @@ pub struct NpcFile {
|
|||||||
pub dialogue: Option<NpcDialogue>,
|
pub dialogue: Option<NpcDialogue>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub combat: Option<NpcCombatFile>,
|
pub combat: Option<NpcCombatFile>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub shop: Option<ShopFile>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gold: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub silver: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub copper: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_attitude() -> Attitude {
|
fn default_attitude() -> Attitude {
|
||||||
@@ -143,6 +165,12 @@ pub struct ObjectFile {
|
|||||||
pub takeable: bool,
|
pub takeable: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stats: Option<ObjectStatsFile>,
|
pub stats: Option<ObjectStatsFile>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub value_gold: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub value_silver: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub value_copper: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Race TOML schema ---
|
// --- Race TOML schema ---
|
||||||
@@ -387,6 +415,7 @@ pub struct Room {
|
|||||||
pub exits: HashMap<String, String>,
|
pub exits: HashMap<String, String>,
|
||||||
pub npcs: Vec<String>,
|
pub npcs: Vec<String>,
|
||||||
pub objects: Vec<String>,
|
pub objects: Vec<String>,
|
||||||
|
pub outdoors: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -409,7 +438,12 @@ pub struct Npc {
|
|||||||
pub fixed_class: Option<String>,
|
pub fixed_class: Option<String>,
|
||||||
pub respawn_secs: Option<u64>,
|
pub respawn_secs: Option<u64>,
|
||||||
pub greeting: Option<String>,
|
pub greeting: Option<String>,
|
||||||
|
pub keywords: HashMap<String, String>,
|
||||||
pub combat: Option<NpcCombatStats>,
|
pub combat: Option<NpcCombatStats>,
|
||||||
|
pub shop: Option<ShopFile>,
|
||||||
|
pub gold: i32,
|
||||||
|
pub silver: i32,
|
||||||
|
pub copper: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
@@ -429,6 +463,9 @@ pub struct Object {
|
|||||||
pub slot: Option<String>,
|
pub slot: Option<String>,
|
||||||
pub takeable: bool,
|
pub takeable: bool,
|
||||||
pub stats: ObjectStats,
|
pub stats: ObjectStats,
|
||||||
|
pub value_gold: i32,
|
||||||
|
pub value_silver: i32,
|
||||||
|
pub value_copper: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const DEFAULT_HUMANOID_SLOTS: &[&str] = &[
|
pub const DEFAULT_HUMANOID_SLOTS: &[&str] = &[
|
||||||
@@ -631,20 +668,24 @@ impl World {
|
|||||||
|
|
||||||
load_entities_from_dir(®ion_path.join("rooms"), ®ion_name, &mut |id, content| {
|
load_entities_from_dir(®ion_path.join("rooms"), ®ion_name, &mut |id, content| {
|
||||||
let rf: RoomFile = toml::from_str(content).map_err(|e| format!("Bad room {id}: {e}"))?;
|
let rf: RoomFile = toml::from_str(content).map_err(|e| format!("Bad room {id}: {e}"))?;
|
||||||
rooms.insert(id.clone(), Room { id: id.clone(), region: region_name.clone(), name: rf.name, description: rf.description, exits: rf.exits, npcs: Vec::new(), objects: Vec::new() });
|
rooms.insert(id.clone(), Room { id: id.clone(), region: region_name.clone(), name: rf.name, description: rf.description, exits: rf.exits, npcs: Vec::new(), objects: Vec::new(), outdoors: rf.outdoors });
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
load_entities_from_dir(®ion_path.join("npcs"), ®ion_name, &mut |id, content| {
|
load_entities_from_dir(®ion_path.join("npcs"), ®ion_name, &mut |id, content| {
|
||||||
let nf: NpcFile = toml::from_str(content).map_err(|e| format!("Bad npc {id}: {e}"))?;
|
let nf: NpcFile = toml::from_str(content).map_err(|e| format!("Bad npc {id}: {e}"))?;
|
||||||
|
let (greeting, keywords) = match nf.dialogue {
|
||||||
|
Some(d) => (d.greeting, d.keywords),
|
||||||
|
None => (None, HashMap::new()),
|
||||||
|
};
|
||||||
let combat = Some(nf.combat.map(|c| NpcCombatStats { max_hp: c.max_hp, attack: c.attack, defense: c.defense, xp_reward: c.xp_reward })
|
let combat = Some(nf.combat.map(|c| NpcCombatStats { max_hp: c.max_hp, attack: c.attack, defense: c.defense, xp_reward: c.xp_reward })
|
||||||
.unwrap_or(NpcCombatStats { max_hp: 20, attack: 4, defense: 2, xp_reward: 5 }));
|
.unwrap_or(NpcCombatStats { max_hp: 20, attack: 4, defense: 2, xp_reward: 5 }));
|
||||||
let greeting = nf.dialogue.and_then(|d| d.greeting);
|
|
||||||
npcs.insert(id.clone(), Npc {
|
npcs.insert(id.clone(), Npc {
|
||||||
id: id.clone(), name: nf.name, description: nf.description, room: nf.room,
|
id: id.clone(), name: nf.name, description: nf.description, room: nf.room,
|
||||||
base_attitude: nf.base_attitude, faction: nf.faction,
|
base_attitude: nf.base_attitude, faction: nf.faction,
|
||||||
fixed_race: nf.race, fixed_class: nf.class,
|
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(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
@@ -656,6 +697,7 @@ impl World {
|
|||||||
id: id.clone(), name: of.name, description: of.description, room: of.room,
|
id: id.clone(), name: of.name, description: of.description, room: of.room,
|
||||||
kind: of.kind, slot: of.slot, takeable: of.takeable,
|
kind: of.kind, slot: of.slot, takeable: of.takeable,
|
||||||
stats: ObjectStats { damage: stats.damage, armor: stats.armor, heal_amount: stats.heal_amount },
|
stats: ObjectStats { damage: stats.damage, armor: stats.armor, heal_amount: stats.heal_amount },
|
||||||
|
value_gold: of.value_gold, value_silver: of.value_silver, value_copper: of.value_copper,
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -2,4 +2,14 @@ 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."
|
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"
|
room = "lawold:well_market_trade_stalls"
|
||||||
race = "race:human"
|
race = "race:human"
|
||||||
base_attitude = "aggressive"
|
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
|
||||||
|
|||||||
@@ -4,3 +4,9 @@ room = "lawold:senate_hall"
|
|||||||
race = "race:human"
|
race = "race:human"
|
||||||
class = "class:mage"
|
class = "class:mage"
|
||||||
base_attitude = "neutral"
|
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." }
|
||||||
|
|||||||
@@ -4,3 +4,8 @@ room = "lawold:central_bridge"
|
|||||||
race = "race:human"
|
race = "race:human"
|
||||||
class = "class:warrior"
|
class = "class:warrior"
|
||||||
base_attitude = "neutral"
|
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." }
|
||||||
|
|||||||
@@ -3,3 +3,8 @@ description = "Saege has auburn hair and blue eyes. He wears modest garments and
|
|||||||
room = "lawold:well_market_square"
|
room = "lawold:well_market_square"
|
||||||
race = "race:human"
|
race = "race:human"
|
||||||
base_attitude = "friendly"
|
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." }
|
||||||
|
|||||||
@@ -4,3 +4,9 @@ room = "lawold:palace_village_palace_gate"
|
|||||||
race = "race:human"
|
race = "race:human"
|
||||||
class = "class:warden"
|
class = "class:warden"
|
||||||
base_attitude = "friendly"
|
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." }
|
||||||
|
|||||||
@@ -4,3 +4,8 @@ room = "lawold:artists_district_lane"
|
|||||||
race = "race:dwarf"
|
race = "race:dwarf"
|
||||||
class = "class:warrior"
|
class = "class:warrior"
|
||||||
base_attitude = "friendly"
|
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." }
|
||||||
|
|||||||
@@ -3,3 +3,14 @@ description = "Wisym is fair in appearance, with silver hair and sharp green eye
|
|||||||
room = "lawold:saints_market_plaza"
|
room = "lawold:saints_market_plaza"
|
||||||
race = "race:human"
|
race = "race:human"
|
||||||
base_attitude = "neutral"
|
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
|
||||||
|
|||||||
@@ -4,3 +4,8 @@ room = "lawold:senate_plaza"
|
|||||||
race = "race:human"
|
race = "race:human"
|
||||||
class = "class:rogue"
|
class = "class:rogue"
|
||||||
base_attitude = "friendly"
|
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." }
|
||||||
|
|||||||
7
world/town/objects/chisel.toml
Normal file
7
world/town/objects/chisel.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
name = "Chisel"
|
||||||
|
description = "A sharp steel chisel, used for fine woodwork or stone carving."
|
||||||
|
kind = "tool"
|
||||||
|
takeable = true
|
||||||
|
value_gold = 0
|
||||||
|
value_silver = 3
|
||||||
|
value_copper = 50
|
||||||
7
world/town/objects/small_hammer.toml
Normal file
7
world/town/objects/small_hammer.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
name = "Small Hammer"
|
||||||
|
description = "A sturdy iron hammer with a wooden handle, suitable for small repairs or light construction."
|
||||||
|
kind = "tool"
|
||||||
|
takeable = true
|
||||||
|
value_gold = 0
|
||||||
|
value_silver = 5
|
||||||
|
value_copper = 0
|
||||||
@@ -3,6 +3,7 @@ description = """\
|
|||||||
Towering iron-reinforced wooden gates mark the southern edge of town. \
|
Towering iron-reinforced wooden gates mark the southern edge of town. \
|
||||||
Guards in dented armor lean on their spears, watching the dusty road \
|
Guards in dented armor lean on their spears, watching the dusty road \
|
||||||
that stretches into the wilderness beyond."""
|
that stretches into the wilderness beyond."""
|
||||||
|
outdoors = true
|
||||||
|
|
||||||
[exits]
|
[exits]
|
||||||
north = "town:dark_alley"
|
north = "town:dark_alley"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ description = """\
|
|||||||
Colorful stalls line both sides of a narrow street. Merchants hawk their \
|
Colorful stalls line both sides of a narrow street. Merchants hawk their \
|
||||||
wares — bolts of cloth, exotic spices, gleaming trinkets. The air is thick \
|
wares — bolts of cloth, exotic spices, gleaming trinkets. The air is thick \
|
||||||
with competing smells and the chatter of commerce."""
|
with competing smells and the chatter of commerce."""
|
||||||
|
outdoors = true
|
||||||
|
|
||||||
[exits]
|
[exits]
|
||||||
west = "town:town_square"
|
west = "town:town_square"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ description = """\
|
|||||||
You stand in the heart of Thornwall. A worn stone fountain sits at the \
|
You stand in the heart of Thornwall. A worn stone fountain sits at the \
|
||||||
center, water trickling quietly. Cobblestone paths branch in every \
|
center, water trickling quietly. Cobblestone paths branch in every \
|
||||||
direction. The sounds of merchants and travelers fill the air."""
|
direction. The sounds of merchants and travelers fill the air."""
|
||||||
|
outdoors = true
|
||||||
|
|
||||||
[exits]
|
[exits]
|
||||||
north = "town:tavern"
|
north = "town:tavern"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ The well-maintained cobblestone of Thornwall yields to a winding dirt \
|
|||||||
path that disappears into the dense, dark eaves of the forest. The air \
|
path that disappears into the dense, dark eaves of the forest. The air \
|
||||||
is cooler here, smelling of damp earth and pine needles. The city \
|
is cooler here, smelling of damp earth and pine needles. The city \
|
||||||
gates loom to the north."""
|
gates loom to the north."""
|
||||||
|
outdoors = true
|
||||||
|
|
||||||
[exits]
|
[exits]
|
||||||
north = "town:gate"
|
north = "town:gate"
|
||||||
|
|||||||
Reference in New Issue
Block a user