feat(server): store worlds in sqlite or postgres with sqlx
Worlds are identified by a UUID and every table is keyed by it, so many worlds can share one database. The schema is created and migrated automatically. Autosaves are written on a background tokio runtime; exit saves block until done. Singleplayer saves are one SQLite file per save game in the platform data directory. The dedicated server takes --database URL (default sqlite://saves/server.sqlite) and --world NAME|UUID. BREAKING CHANGE: the rsn save format and the server's --save FILE option are removed; existing saves are not migrated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
3cf0561401
commit
afd3d3a537
10
AGENTS.md
10
AGENTS.md
|
|
@ -71,9 +71,13 @@ conventions) in sync with API, configuration, and behavior changes.
|
|||
- Rune scripts stage changes in `ScriptState`; they are applied after the
|
||||
script returns, and a failing script has no effect. Ctx methods need
|
||||
`#[rune::function(keep)]`; script-facing numbers are integers.
|
||||
- Save files (`persistence.rs`, `SAVE_VERSION`) are the source of truth for an
|
||||
existing world; the Tiled map only seeds new worlds. Changing saved types
|
||||
requires a version bump or a compatible migration.
|
||||
- The database (`persistence.rs`) is the source of truth for an existing world;
|
||||
the Tiled map only seeds new worlds. All tables are keyed by the world UUID.
|
||||
Schema changes need a new entry in `MIGRATIONS` and a `SCHEMA_VERSION` bump.
|
||||
Use portable SQL (SQLite and PostgreSQL) with `$N` placeholders. Saved
|
||||
component types are stored as JSON, so changes must stay serde-compatible.
|
||||
- Test PostgreSQL with `STATION_TEST_POSTGRES_URL=postgres://... cargo nextest run`
|
||||
(for example against a throwaway docker `postgres` container).
|
||||
- Characters are identified by name only (no authentication); renet uses
|
||||
unsecure authentication. Do not treat client input as trusted.
|
||||
- Replicon builds its systems in `Plugin::finish`; headless tests must call
|
||||
|
|
|
|||
|
|
@ -26,3 +26,12 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||
assembler makes tools.
|
||||
- GitHub `Package` workflow builds archives for Windows (zip), Linux and macOS
|
||||
(tar.gz).
|
||||
- Worlds are stored with sqlx in SQLite (default) or PostgreSQL, keyed by a world
|
||||
UUID so many worlds can share one database. Singleplayer saves are SQLite files
|
||||
in the platform data directory. Autosaves run in the background. The server has
|
||||
new `--database URL` and `--world NAME|UUID` options.
|
||||
|
||||
### Removed
|
||||
|
||||
- The rsn save file format and the `--save FILE` server option. Existing rsn saves
|
||||
are not migrated.
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ version = "0.5.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"crypto-common 0.1.7",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
|
|
@ -354,6 +354,15 @@ dependencies = [
|
|||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atoi"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
|
|
@ -1284,7 +1293,7 @@ dependencies = [
|
|||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde",
|
||||
"spin",
|
||||
"spin 0.10.1",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-time",
|
||||
|
|
@ -1923,6 +1932,24 @@ dependencies = [
|
|||
"cpufeatures 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
|
|
@ -2068,6 +2095,17 @@ dependencies = [
|
|||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.10.1"
|
||||
|
|
@ -2075,7 +2113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"chacha20 0.9.1",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
|
|
@ -2087,7 +2125,7 @@ version = "0.4.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"crypto-common 0.1.7",
|
||||
"inout",
|
||||
"zeroize",
|
||||
]
|
||||
|
|
@ -2101,6 +2139,12 @@ dependencies = [
|
|||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
|
|
@ -2328,6 +2372,21 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
|
||||
dependencies = [
|
||||
"crc-catalog",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc-catalog"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
|
|
@ -2384,6 +2443,15 @@ dependencies = [
|
|||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctrlc"
|
||||
version = "3.5.2"
|
||||
|
|
@ -2395,6 +2463,15 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
|
|
@ -2442,6 +2519,48 @@ version = "1.0.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11277822c27bde750de02c5dc5159b91e88bf2661a2c1d98106f2fb1c5c6f590"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "directories"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
|
|
@ -2495,6 +2614,12 @@ dependencies = [
|
|||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenvy"
|
||||
version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
|
|
@ -2518,6 +2643,9 @@ name = "either"
|
|||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encase"
|
||||
|
|
@ -2594,6 +2722,16 @@ version = "3.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
|
||||
|
||||
[[package]]
|
||||
name = "etcetera"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "euclid"
|
||||
version = "0.22.14"
|
||||
|
|
@ -2671,6 +2809,17 @@ dependencies = [
|
|||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"spin 0.9.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
|
|
@ -2748,6 +2897,15 @@ version = "0.3.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.34"
|
||||
|
|
@ -2755,6 +2913,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2763,6 +2922,28 @@ version = "0.3.34"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-intrusive"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"lock_api",
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.34"
|
||||
|
|
@ -2793,6 +2974,12 @@ dependencies = [
|
|||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
|
|
@ -2806,8 +2993,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
|
@ -2819,6 +3009,7 @@ dependencies = [
|
|||
"bevy",
|
||||
"bevy_replicon",
|
||||
"bevy_replicon_renet",
|
||||
"directories",
|
||||
"game_core",
|
||||
"game_server",
|
||||
]
|
||||
|
|
@ -2847,8 +3038,12 @@ dependencies = [
|
|||
"rsn",
|
||||
"rune",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"thiserror 2.0.21",
|
||||
"tiled",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2918,6 +3113,7 @@ dependencies = [
|
|||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3152,6 +3348,15 @@ dependencies = [
|
|||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.9.3"
|
||||
|
|
@ -3163,12 +3368,24 @@ dependencies = [
|
|||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hexasphere"
|
||||
version = "18.0.0"
|
||||
|
|
@ -3186,6 +3403,33 @@ version = "0.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.3.0"
|
||||
|
|
@ -3314,6 +3558,27 @@ version = "2.3.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
|
|
@ -3570,6 +3835,17 @@ dependencies = [
|
|||
"redox_syscall 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libudev-sys"
|
||||
version = "0.1.4"
|
||||
|
|
@ -3656,6 +3932,16 @@ dependencies = [
|
|||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
|
|
@ -3691,6 +3977,17 @@ dependencies = [
|
|||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
|
|
@ -4363,6 +4660,12 @@ version = "0.3.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.55"
|
||||
|
|
@ -4713,6 +5016,7 @@ version = "0.10.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20 0.10.2",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
|
@ -4820,6 +5124,16 @@ dependencies = [
|
|||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
|
||||
dependencies = [
|
||||
"libredox",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.1"
|
||||
|
|
@ -4887,6 +5201,20 @@ dependencies = [
|
|||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rodio"
|
||||
version = "0.22.2"
|
||||
|
|
@ -5065,6 +5393,40 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
|
|
@ -5175,6 +5537,39 @@ dependencies = [
|
|||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
|
|
@ -5296,6 +5691,25 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.10.1"
|
||||
|
|
@ -5314,6 +5728,175 @@ dependencies = [
|
|||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
|
||||
dependencies = [
|
||||
"sqlx-core",
|
||||
"sqlx-macros",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
"sqlx-sqlite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-core"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
"crc",
|
||||
"crossbeam-queue",
|
||||
"either",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-intrusive",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"hashbrown 0.16.1",
|
||||
"hashlink",
|
||||
"indexmap",
|
||||
"log",
|
||||
"memchr",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"url",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-macros"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"sqlx-core",
|
||||
"sqlx-macros-core",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-macros-core"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dotenvy",
|
||||
"either",
|
||||
"heck",
|
||||
"hex",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sqlx-core",
|
||||
"sqlx-postgres",
|
||||
"sqlx-sqlite",
|
||||
"syn 2.0.119",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-mysql"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"digest 0.11.3",
|
||||
"dotenvy",
|
||||
"either",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"generic-array",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"sha1",
|
||||
"sha2 0.11.1",
|
||||
"sqlx-core",
|
||||
"thiserror 2.0.21",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-postgres"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64",
|
||||
"bitflags 2.13.2",
|
||||
"byteorder",
|
||||
"crc",
|
||||
"dotenvy",
|
||||
"etcetera",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"itoa",
|
||||
"log",
|
||||
"md-5",
|
||||
"memchr",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror 2.0.21",
|
||||
"tracing",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-sqlite"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"flume",
|
||||
"form_urlencoded",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-intrusive",
|
||||
"futures-util",
|
||||
"libsqlite3-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"sqlx-core",
|
||||
"thiserror 2.0.21",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
|
@ -5341,6 +5924,17 @@ version = "0.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
|
||||
dependencies = [
|
||||
"unicode-bidi",
|
||||
"unicode-normalization",
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
|
|
@ -5566,7 +6160,23 @@ version = "1.53.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5605,6 +6215,7 @@ version = "0.1.44"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
|
|
@ -5730,12 +6341,33 @@ version = "1.15.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
|
|
@ -5766,7 +6398,7 @@ version = "0.5.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
|
|
@ -5781,6 +6413,24 @@ dependencies = [
|
|||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
|
|
@ -5826,6 +6476,12 @@ dependencies = [
|
|||
"unsynn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vec_map"
|
||||
version = "0.8.2"
|
||||
|
|
@ -6066,6 +6722,15 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu"
|
||||
version = "29.0.4"
|
||||
|
|
@ -6233,6 +6898,12 @@ dependencies = [
|
|||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ rsn = "0.2"
|
|||
rune = "0.14"
|
||||
tiled = "0.16"
|
||||
thiserror = "2"
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
sqlx = { version = "0.9", default-features = false, features = ["any", "sqlite", "postgres", "runtime-tokio", "tls-rustls-ring-webpki"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
directories = "6"
|
||||
game_core = { path = "crates/game_core" }
|
||||
game_server = { path = "crates/game_server" }
|
||||
|
||||
|
|
|
|||
38
README.md
38
README.md
|
|
@ -31,20 +31,42 @@ multiplayer behave identically.
|
|||
## Running
|
||||
|
||||
```sh
|
||||
cargo run # singleplayer (world: saves/singleplayer.rsn)
|
||||
cargo run # singleplayer (save game `singleplayer`)
|
||||
cargo run -- --host 5000 # singleplayer world that others can join
|
||||
cargo run -- --connect 127.0.0.1:5000 # join a server or host
|
||||
cargo run -p game_server --bin game-server # dedicated server (world: saves/server.rsn)
|
||||
cargo run -p game_server --bin game-server # dedicated server (saves/server.sqlite, world `main`)
|
||||
```
|
||||
|
||||
Client options: `--name NAME`, `--save FILE`, `--no-save`, `--map MAP`.
|
||||
Server options: `--port PORT`, `--save FILE`, `--no-save`, `--map MAP`.
|
||||
Client options: `--name NAME`, `--save NAME`, `--no-save`, `--map MAP`.
|
||||
Server options: `--port PORT`, `--database URL`, `--world NAME|UUID`, `--no-save`, `--map MAP`.
|
||||
|
||||
On Windows, release builds of the client open no console window, so command-line
|
||||
output such as `--help` and logs isn't shown. Use a debug build to see it.
|
||||
|
||||
The world autosaves periodically and on exit (window close, Ctrl-C, SIGTERM).
|
||||
Characters are identified by name and are restored when that name logs in again.
|
||||
The world autosaves periodically (in the background) and on exit (window close,
|
||||
Ctrl-C, SIGTERM). Characters are identified by name and are restored when that
|
||||
name logs in again.
|
||||
|
||||
### Save games and databases
|
||||
|
||||
Worlds are stored with [sqlx](https://crates.io/crates/sqlx) in SQLite or
|
||||
PostgreSQL. Every world has a UUID, and all rows are keyed by it, so any number of
|
||||
worlds can share one database.
|
||||
|
||||
- **Singleplayer/host:** each save game is its own SQLite file in the platform data
|
||||
directory: `~/.local/share/station/saves/` on Linux,
|
||||
`%APPDATA%\station\data\saves\` on Windows and
|
||||
`~/Library/Application Support/station/saves/` on macOS.
|
||||
- **Dedicated server:** `--database` takes a sqlx URL and defaults to
|
||||
`sqlite://saves/server.sqlite`. Use `postgres://user:password@host/db` for
|
||||
PostgreSQL. `--world` selects a world by name (created if missing) or by UUID.
|
||||
|
||||
```sh
|
||||
game-server --database postgres://station:secret@db/station --world europa
|
||||
```
|
||||
|
||||
The schema is created and migrated automatically. To run the PostgreSQL tests,
|
||||
set `STATION_TEST_POSTGRES_URL` to a database URL; without it they are skipped.
|
||||
|
||||
## Controls
|
||||
|
||||
|
|
@ -200,8 +222,8 @@ game tile (drawn at 32×32; 32 px tilesets look best).
|
|||
- `item` – custom property `proto` = item id and optional `count`.
|
||||
- Objects are placed on the tile containing their position (point objects work well).
|
||||
|
||||
A map is only used to create a new world; after that the save file is authoritative.
|
||||
Delete the save file to start a fresh world.
|
||||
A map is only used to create a new world; after that the saved world is
|
||||
authoritative. Start a new save game (or world) to get a fresh copy of the map.
|
||||
|
||||
`tools/gen_placeholder_assets.py` regenerates the placeholder sprites and example map.
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ game_server = { workspace = true }
|
|||
bevy = { workspace = true, default-features = true }
|
||||
bevy_replicon = { workspace = true }
|
||||
bevy_replicon_renet = { workspace = true }
|
||||
directories = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
//! game --host [PORT] singleplayer world that friends can join
|
||||
//! game --connect ADDR[:PORT] join a dedicated server or host
|
||||
//! ```
|
||||
//! Common options: `--name NAME`, `--save FILE`, `--map MAP`.
|
||||
//! Common options: `--name NAME`, `--save NAME`, `--map MAP`.
|
||||
|
||||
// Release builds on Windows run without a console window.
|
||||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
|
@ -13,9 +13,10 @@
|
|||
mod input;
|
||||
mod net;
|
||||
mod render;
|
||||
mod saves;
|
||||
mod ui;
|
||||
|
||||
use std::{net::SocketAddr, path::PathBuf};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use bevy::{prelude::*, window::WindowResolution};
|
||||
use bevy_replicon::prelude::*;
|
||||
|
|
@ -99,7 +100,7 @@ fn parse_args() -> (Mode, String, ServerSettings) {
|
|||
.filter(|n| game_server::players::valid_name(n))
|
||||
.unwrap_or_else(|| "Player".to_owned());
|
||||
let mut settings = ServerSettings {
|
||||
save_path: Some(PathBuf::from("saves/singleplayer.rsn")),
|
||||
storage: Some(saves::storage("singleplayer")),
|
||||
..default()
|
||||
};
|
||||
let mut args = std::env::args().skip(1).peekable();
|
||||
|
|
@ -134,8 +135,8 @@ fn parse_args() -> (Mode, String, ServerSettings) {
|
|||
mode = Mode::Join { addr };
|
||||
}
|
||||
"--name" => name = value("a name"),
|
||||
"--save" => settings.save_path = Some(value("a file").into()),
|
||||
"--no-save" => settings.save_path = None,
|
||||
"--save" => settings.storage = Some(saves::storage(&value("a save name"))),
|
||||
"--no-save" => settings.storage = None,
|
||||
"--map" => settings.map = value("a map path"),
|
||||
"-h" | "--help" => usage(""),
|
||||
other => usage(&format!("unknown argument `{other}`")),
|
||||
|
|
@ -152,15 +153,16 @@ fn usage(error: &str) -> ! {
|
|||
eprintln!("error: {error}\n");
|
||||
}
|
||||
eprintln!(
|
||||
"usage: game [--host [PORT] | --connect ADDR[:PORT]] [--name NAME] [--save FILE | --no-save] [--map MAP]\n\n\
|
||||
(no mode) singleplayer, world saved to saves/singleplayer.rsn\n\
|
||||
"usage: game [--host [PORT] | --connect ADDR[:PORT]] [--name NAME] [--save NAME | --no-save] [--map MAP]\n\n\
|
||||
(no mode) singleplayer, world saved as `singleplayer`\n\
|
||||
--host play your world and let others join (default port {port})\n\
|
||||
--connect join a server (default port {port})\n\
|
||||
--name character name; characters persist per world\n\
|
||||
--save world file for singleplayer/host\n\
|
||||
--save save game for singleplayer/host, stored in {saves}\n\
|
||||
--map Tiled map for new worlds (default {map})",
|
||||
port = game_core::DEFAULT_PORT,
|
||||
map = game_core::DEFAULT_MAP,
|
||||
saves = saves::saves_dir().display(),
|
||||
);
|
||||
std::process::exit(if error.is_empty() { 0 } else { 2 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
//! Local save games: one SQLite database per save in the platform's data directory
|
||||
//! (e.g. `~/.local/share/station/saves` on Linux, `%APPDATA%\station\data\saves` on Windows).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use game_server::persistence::{StorageSettings, WorldSelector, sqlite_url};
|
||||
|
||||
/// Directory holding the save databases. Falls back to `./saves` if the platform has no home.
|
||||
pub fn saves_dir() -> PathBuf {
|
||||
directories::ProjectDirs::from("", "", "station")
|
||||
.map(|dirs| dirs.data_dir().join("saves"))
|
||||
.unwrap_or_else(|| PathBuf::from("saves"))
|
||||
}
|
||||
|
||||
/// File name for a save: the name reduced to `[a-z0-9_-]`.
|
||||
pub fn slug(name: &str) -> String {
|
||||
let slug: String = name
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'a'..='z' | '0'..='9' | '-' | '_' => c,
|
||||
'A'..='Z' => c.to_ascii_lowercase(),
|
||||
_ => '_',
|
||||
})
|
||||
.collect();
|
||||
if slug.is_empty() {
|
||||
"world".into()
|
||||
} else {
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage for the save game called `name`.
|
||||
pub fn storage(name: &str) -> StorageSettings {
|
||||
let path = saves_dir().join(format!("{}.sqlite", slug(name)));
|
||||
StorageSettings {
|
||||
database_url: sqlite_url(&path),
|
||||
world: WorldSelector::Name(name.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slugs() {
|
||||
assert_eq!(slug("My World 2"), "my_world_2");
|
||||
assert_eq!(slug(" "), "world");
|
||||
assert_eq!(slug("a/../b"), "a____b");
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,10 @@ rsn = { workspace = true }
|
|||
rune = { workspace = true }
|
||||
tiled = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
# Enables graceful shutdown (and saving) on SIGTERM/SIGHUP via Bevy's Ctrl-C handler.
|
||||
ctrlc = { version = "3.5", features = ["termination"] }
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ pub mod scripting;
|
|||
pub mod survival;
|
||||
pub mod world;
|
||||
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use game_core::GameData;
|
||||
|
|
@ -27,8 +27,8 @@ pub use network::NetworkServerPlugin;
|
|||
pub struct ServerSettings {
|
||||
/// Tiled map, relative to the assets directory.
|
||||
pub map: String,
|
||||
/// Where the persistent world is stored. `None` disables saving.
|
||||
pub save_path: Option<PathBuf>,
|
||||
/// Database and world to persist to. `None` disables saving.
|
||||
pub storage: Option<persistence::StorageSettings>,
|
||||
pub autosave_interval: Duration,
|
||||
}
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ impl Default for ServerSettings {
|
|||
fn default() -> Self {
|
||||
Self {
|
||||
map: game_core::DEFAULT_MAP.to_owned(),
|
||||
save_path: None,
|
||||
storage: None,
|
||||
autosave_interval: Duration::from_secs(60),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,28 @@
|
|||
//! Headless dedicated server.
|
||||
//!
|
||||
//! ```text
|
||||
//! game-server [--port 5000] [--save saves/server.rsn | --no-save] [--map maps/station.tmx]
|
||||
//! game-server [--port 5000] [--database URL | --no-save] [--world NAME|UUID] [--map maps/station.tmx]
|
||||
//! ```
|
||||
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
use std::time::Duration;
|
||||
|
||||
use bevy::{app::ScheduleRunnerPlugin, log::LogPlugin, prelude::*, state::app::StatesPlugin};
|
||||
use bevy_replicon::prelude::*;
|
||||
use bevy_replicon_renet::RepliconRenetPlugins;
|
||||
use game_core::CorePlugin;
|
||||
use game_server::{GameServerPlugin, NetworkServerPlugin, ServerSettings};
|
||||
use game_server::{
|
||||
GameServerPlugin, NetworkServerPlugin, ServerSettings,
|
||||
persistence::{StorageSettings, WorldSelector},
|
||||
};
|
||||
|
||||
const DEFAULT_DATABASE: &str = "sqlite://saves/server.sqlite";
|
||||
const DEFAULT_WORLD: &str = "main";
|
||||
|
||||
fn main() -> AppExit {
|
||||
let mut port = game_core::DEFAULT_PORT;
|
||||
let mut settings = ServerSettings {
|
||||
save_path: Some(PathBuf::from("saves/server.rsn")),
|
||||
..Default::default()
|
||||
};
|
||||
let mut settings = ServerSettings::default();
|
||||
let mut database = Some(DEFAULT_DATABASE.to_owned());
|
||||
let mut world = WorldSelector::Name(DEFAULT_WORLD.to_owned());
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
let mut value = |name: &str| {
|
||||
|
|
@ -30,13 +35,18 @@ fn main() -> AppExit {
|
|||
.parse()
|
||||
.unwrap_or_else(|_| usage("invalid port"))
|
||||
}
|
||||
"--save" => settings.save_path = Some(value("--save").into()),
|
||||
"--no-save" => settings.save_path = None,
|
||||
"--database" => database = Some(value("--database")),
|
||||
"--world" => world = WorldSelector::parse(&value("--world")),
|
||||
"--no-save" => database = None,
|
||||
"--map" => settings.map = value("--map"),
|
||||
"-h" | "--help" => usage(""),
|
||||
other => usage(&format!("unknown argument `{other}`")),
|
||||
}
|
||||
}
|
||||
settings.storage = database.map(|database_url| StorageSettings {
|
||||
database_url,
|
||||
world,
|
||||
});
|
||||
|
||||
App::new()
|
||||
.add_plugins((
|
||||
|
|
@ -60,9 +70,11 @@ fn usage(error: &str) -> ! {
|
|||
eprintln!("error: {error}\n");
|
||||
}
|
||||
eprintln!(
|
||||
"usage: game-server [--port PORT] [--save FILE | --no-save] [--map MAP]\n\n\
|
||||
"usage: game-server [--port PORT] [--database URL | --no-save] [--world NAME|UUID] [--map MAP]\n\n\
|
||||
--port UDP port to listen on (default {})\n\
|
||||
--save persistent world file (default saves/server.rsn)\n\
|
||||
--database sqlite:// or postgres:// URL (default {DEFAULT_DATABASE})\n\
|
||||
--world world name or UUID in the database, created if missing (default {DEFAULT_WORLD})\n\
|
||||
--no-save don't persist the world\n\
|
||||
--map Tiled map relative to the assets directory (default {})",
|
||||
game_core::DEFAULT_PORT,
|
||||
game_core::DEFAULT_MAP
|
||||
|
|
|
|||
|
|
@ -1,28 +1,91 @@
|
|||
//! Persistent world storage in rsn format.
|
||||
//! Persistent world storage with sqlx.
|
||||
//!
|
||||
//! The world (buildings, ground items, all characters including offline ones) is
|
||||
//! written to a single human-readable `.rsn` file. Saves are atomic: data is
|
||||
//! written to a temporary file which then replaces the previous save.
|
||||
//! SQLite (default) and PostgreSQL are supported through sqlx's `Any` driver.
|
||||
//! Every world has a UUID and all rows are keyed by it, so any number of worlds
|
||||
//! can share one database. Singleplayer uses one SQLite file per save game.
|
||||
//!
|
||||
//! The simulation is snapshotted into a [`SaveGame`] on the main thread; writing
|
||||
//! it happens on a tokio runtime so autosaves don't stall the game.
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
path::Path,
|
||||
sync::{Arc, Once},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use game_core::{GameData, components::*, grid::Dir};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{AnyPool, AssertSqlSafe, Row, any::AnyPoolOptions};
|
||||
use tokio::{runtime::Runtime, task::JoinHandle};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
ServerSettings,
|
||||
players::CharacterStore,
|
||||
world::{spawn_building_with_state, spawn_ground_item},
|
||||
};
|
||||
|
||||
pub const SAVE_VERSION: u32 = 1;
|
||||
/// Bump together with a new entry in [`MIGRATIONS`].
|
||||
const SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
/// Schema migrations, applied in order. Index `i` upgrades from version `i` to `i + 1`.
|
||||
/// Only portable SQL (SQLite and PostgreSQL) may be used.
|
||||
const MIGRATIONS: &[&[&str]] = &[&[
|
||||
"CREATE TABLE worlds (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
map TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
saved_at BIGINT NOT NULL
|
||||
)",
|
||||
"CREATE TABLE buildings (
|
||||
world_id TEXT NOT NULL REFERENCES worlds(id) ON DELETE CASCADE,
|
||||
proto TEXT NOT NULL,
|
||||
x BIGINT NOT NULL,
|
||||
y BIGINT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
machine TEXT
|
||||
)",
|
||||
"CREATE INDEX buildings_world ON buildings (world_id)",
|
||||
"CREATE TABLE ground_items (
|
||||
world_id TEXT NOT NULL REFERENCES worlds(id) ON DELETE CASCADE,
|
||||
item TEXT NOT NULL,
|
||||
count BIGINT NOT NULL,
|
||||
x BIGINT NOT NULL,
|
||||
y BIGINT NOT NULL
|
||||
)",
|
||||
"CREATE INDEX ground_items_world ON ground_items (world_id)",
|
||||
"CREATE TABLE characters (
|
||||
world_id TEXT NOT NULL REFERENCES worlds(id) ON DELETE CASCADE,
|
||||
player_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
PRIMARY KEY (world_id, player_id)
|
||||
)",
|
||||
]];
|
||||
|
||||
const DELETE_WORLD_ROWS: [&str; 3] = [
|
||||
"DELETE FROM buildings WHERE world_id = $1",
|
||||
"DELETE FROM ground_items WHERE world_id = $1",
|
||||
"DELETE FROM characters WHERE world_id = $1",
|
||||
];
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StorageError {
|
||||
#[error("database error: {0}")]
|
||||
Sql(#[from] sqlx::Error),
|
||||
#[error("corrupt data in the save: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("the database schema (version {0}) is newer than this game supports")]
|
||||
TooNew(i64),
|
||||
#[error("world {0} not found")]
|
||||
NoSuchWorld(Uuid),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SavedCharacter {
|
||||
pub name: String,
|
||||
pub pos: GridPos,
|
||||
pub facing: Dir,
|
||||
pub vitals: Vitals,
|
||||
|
|
@ -45,37 +108,488 @@ pub struct SavedItem {
|
|||
pub pos: GridPos,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
/// Full state of a world.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct SaveGame {
|
||||
pub version: u32,
|
||||
pub map: String,
|
||||
pub buildings: Vec<SavedBuilding>,
|
||||
pub items: Vec<SavedItem>,
|
||||
/// All characters (online and offline), by player id.
|
||||
pub characters: BTreeMap<String, SavedCharacter>,
|
||||
}
|
||||
|
||||
/// Metadata of a stored world.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WorldInfo {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub map: String,
|
||||
/// Unix timestamps in seconds.
|
||||
pub created_at: i64,
|
||||
pub saved_at: i64,
|
||||
}
|
||||
|
||||
/// Selects a world in a database.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum WorldSelector {
|
||||
Id(Uuid),
|
||||
/// The world with this name; created if it does not exist.
|
||||
Name(String),
|
||||
}
|
||||
|
||||
impl WorldSelector {
|
||||
/// Parses a UUID, anything else is treated as a name.
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match Uuid::parse_str(s) {
|
||||
Ok(id) => Self::Id(id),
|
||||
Err(_) => Self::Name(s.to_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WorldSelector {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Id(id) => write!(f, "{id}"),
|
||||
Self::Name(name) => write!(f, "{name}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where and what to store.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StorageSettings {
|
||||
/// sqlx connection URL, e.g. `sqlite://saves/world.db` or `postgres://user:pw@host/db`.
|
||||
pub database_url: String,
|
||||
pub world: WorldSelector,
|
||||
}
|
||||
|
||||
/// URL of a SQLite database file, created if missing.
|
||||
pub fn sqlite_url(path: &Path) -> String {
|
||||
// sqlx accepts plain paths after the scheme; use forward slashes on Windows too.
|
||||
let path = path.to_string_lossy().replace('\\', "/");
|
||||
format!("sqlite://{path}?mode=rwc")
|
||||
}
|
||||
|
||||
fn now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as i64)
|
||||
}
|
||||
|
||||
/// A connection pool to a world database.
|
||||
#[derive(Clone)]
|
||||
pub struct Database {
|
||||
pool: AnyPool,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Connects and brings the schema up to date. SQLite files are created as needed.
|
||||
pub async fn connect(url: &str) -> Result<Self, StorageError> {
|
||||
static DRIVERS: Once = Once::new();
|
||||
DRIVERS.call_once(sqlx::any::install_default_drivers);
|
||||
if let Some(path) = url
|
||||
.strip_prefix("sqlite://")
|
||||
.or_else(|| url.strip_prefix("sqlite:"))
|
||||
{
|
||||
let path = path.split('?').next().unwrap_or_default();
|
||||
if let Some(dir) = Path::new(path).parent()
|
||||
&& !dir.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(dir).map_err(sqlx::Error::Io)?;
|
||||
}
|
||||
}
|
||||
let is_sqlite = url.starts_with("sqlite:");
|
||||
let url = if is_sqlite && !url.contains("mode=") {
|
||||
let sep = if url.contains('?') { '&' } else { '?' };
|
||||
format!("{url}{sep}mode=rwc")
|
||||
} else {
|
||||
url.to_owned()
|
||||
};
|
||||
let pool = AnyPoolOptions::new()
|
||||
// A single writer avoids SQLite lock contention.
|
||||
.max_connections(if is_sqlite { 1 } else { 4 })
|
||||
.after_connect(move |conn, _| {
|
||||
Box::pin(async move {
|
||||
if is_sqlite {
|
||||
sqlx::query("PRAGMA foreign_keys = ON")
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.connect(&url)
|
||||
.await?;
|
||||
let db = Self { pool };
|
||||
db.migrate().await?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
async fn migrate(&self) -> Result<(), StorageError> {
|
||||
sqlx::query("CREATE TABLE IF NOT EXISTS station_schema (version BIGINT NOT NULL)")
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let version: Option<i64> = sqlx::query("SELECT version FROM station_schema")
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.map(|row| row.try_get(0))
|
||||
.transpose()?;
|
||||
let current = version.unwrap_or(0);
|
||||
if current > SCHEMA_VERSION {
|
||||
return Err(StorageError::TooNew(current));
|
||||
}
|
||||
for migration in &MIGRATIONS[current as usize..] {
|
||||
for statement in *migration {
|
||||
sqlx::query(*statement).execute(&mut *tx).await?;
|
||||
}
|
||||
}
|
||||
if version.is_none() {
|
||||
sqlx::query("INSERT INTO station_schema (version) VALUES ($1)")
|
||||
.bind(SCHEMA_VERSION)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
} else if current < SCHEMA_VERSION {
|
||||
sqlx::query("UPDATE station_schema SET version = $1")
|
||||
.bind(SCHEMA_VERSION)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn close(&self) {
|
||||
self.pool.close().await;
|
||||
}
|
||||
|
||||
/// All worlds, most recently saved first.
|
||||
pub async fn list_worlds(&self) -> Result<Vec<WorldInfo>, StorageError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, name, map, created_at, saved_at FROM worlds ORDER BY saved_at DESC",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.iter().map(world_info).collect()
|
||||
}
|
||||
|
||||
pub async fn find_world(
|
||||
&self,
|
||||
selector: &WorldSelector,
|
||||
) -> Result<Option<WorldInfo>, StorageError> {
|
||||
let row = match selector {
|
||||
WorldSelector::Id(id) => {
|
||||
sqlx::query("SELECT id, name, map, created_at, saved_at FROM worlds WHERE id = $1")
|
||||
.bind(id.to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
}
|
||||
WorldSelector::Name(name) => {
|
||||
sqlx::query(
|
||||
"SELECT id, name, map, created_at, saved_at FROM worlds WHERE name = $1 \
|
||||
ORDER BY saved_at DESC",
|
||||
)
|
||||
.bind(name.clone())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
row.as_ref().map(world_info).transpose()
|
||||
}
|
||||
|
||||
/// Registers a new, empty world.
|
||||
pub async fn create_world(
|
||||
&self,
|
||||
id: Uuid,
|
||||
name: &str,
|
||||
map: &str,
|
||||
) -> Result<WorldInfo, StorageError> {
|
||||
let time = now();
|
||||
sqlx::query(
|
||||
"INSERT INTO worlds (id, name, map, created_at, saved_at) VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.bind(name.to_owned())
|
||||
.bind(map.to_owned())
|
||||
.bind(time)
|
||||
.bind(time)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(WorldInfo {
|
||||
id,
|
||||
name: name.to_owned(),
|
||||
map: map.to_owned(),
|
||||
created_at: time,
|
||||
saved_at: time,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_world(&self, id: Uuid) -> Result<(), StorageError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
for statement in DELETE_WORLD_ROWS {
|
||||
sqlx::query(statement)
|
||||
.bind(id.to_string())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query("DELETE FROM worlds WHERE id = $1")
|
||||
.bind(id.to_string())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_world(&self, id: Uuid) -> Result<SaveGame, StorageError> {
|
||||
let world = id.to_string();
|
||||
let mut save = SaveGame::default();
|
||||
let rows =
|
||||
sqlx::query("SELECT proto, x, y, state, machine FROM buildings WHERE world_id = $1")
|
||||
.bind(world.clone())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
for row in rows {
|
||||
let machine: Option<String> = row.try_get(4)?;
|
||||
save.buildings.push(SavedBuilding {
|
||||
proto: row.try_get(0)?,
|
||||
pos: grid_pos(row.try_get(1)?, row.try_get(2)?),
|
||||
state: serde_json::from_str(row.try_get::<&str, _>(3)?)?,
|
||||
machine: machine.as_deref().map(serde_json::from_str).transpose()?,
|
||||
});
|
||||
}
|
||||
let rows = sqlx::query("SELECT item, count, x, y FROM ground_items WHERE world_id = $1")
|
||||
.bind(world.clone())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
for row in rows {
|
||||
let count: i64 = row.try_get(1)?;
|
||||
save.items.push(SavedItem {
|
||||
stack: ItemStack::new(row.try_get::<String, _>(0)?, count as u32),
|
||||
pos: grid_pos(row.try_get(2)?, row.try_get(3)?),
|
||||
});
|
||||
}
|
||||
let rows = sqlx::query("SELECT player_id, data FROM characters WHERE world_id = $1")
|
||||
.bind(world)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
for row in rows {
|
||||
save.characters.insert(
|
||||
row.try_get(0)?,
|
||||
serde_json::from_str(row.try_get::<&str, _>(1)?)?,
|
||||
);
|
||||
}
|
||||
Ok(save)
|
||||
}
|
||||
|
||||
/// Replaces the stored state of a world atomically.
|
||||
pub async fn save_world(&self, id: Uuid, save: &SaveGame) -> Result<(), StorageError> {
|
||||
// Rows are inserted in batches to keep round trips to remote databases low.
|
||||
const BATCH: usize = 100;
|
||||
let world = id.to_string();
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let updated = sqlx::query("UPDATE worlds SET saved_at = $1 WHERE id = $2")
|
||||
.bind(now())
|
||||
.bind(world.clone())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if updated.rows_affected() == 0 {
|
||||
return Err(StorageError::NoSuchWorld(id));
|
||||
}
|
||||
for statement in DELETE_WORLD_ROWS {
|
||||
sqlx::query(statement)
|
||||
.bind(world.clone())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
for chunk in save.buildings.chunks(BATCH) {
|
||||
let sql = insert_sql(
|
||||
"buildings (world_id, proto, x, y, state, machine)",
|
||||
6,
|
||||
chunk.len(),
|
||||
);
|
||||
let mut query = sqlx::query(AssertSqlSafe(sql));
|
||||
for b in chunk {
|
||||
query = query
|
||||
.bind(world.clone())
|
||||
.bind(b.proto.clone())
|
||||
.bind(i64::from(b.pos.x))
|
||||
.bind(i64::from(b.pos.y))
|
||||
.bind(serde_json::to_string(&b.state)?)
|
||||
.bind(b.machine.as_ref().map(serde_json::to_string).transpose()?);
|
||||
}
|
||||
query.execute(&mut *tx).await?;
|
||||
}
|
||||
for chunk in save.items.chunks(BATCH) {
|
||||
let sql = insert_sql("ground_items (world_id, item, count, x, y)", 5, chunk.len());
|
||||
let mut query = sqlx::query(AssertSqlSafe(sql));
|
||||
for item in chunk {
|
||||
query = query
|
||||
.bind(world.clone())
|
||||
.bind(item.stack.item.clone())
|
||||
.bind(i64::from(item.stack.count))
|
||||
.bind(i64::from(item.pos.x))
|
||||
.bind(i64::from(item.pos.y));
|
||||
}
|
||||
query.execute(&mut *tx).await?;
|
||||
}
|
||||
let characters: Vec<_> = save.characters.iter().collect();
|
||||
for chunk in characters.chunks(BATCH) {
|
||||
let sql = insert_sql(
|
||||
"characters (world_id, player_id, name, data)",
|
||||
4,
|
||||
chunk.len(),
|
||||
);
|
||||
let mut query = sqlx::query(AssertSqlSafe(sql));
|
||||
for (player, c) in chunk {
|
||||
query = query
|
||||
.bind(world.clone())
|
||||
.bind((*player).clone())
|
||||
.bind(c.name.clone())
|
||||
.bind(serde_json::to_string(c)?);
|
||||
}
|
||||
query.execute(&mut *tx).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `INSERT INTO <table> VALUES ($1, ..), ($n, ..)` with numbered placeholders,
|
||||
/// which both SQLite and PostgreSQL understand. Only called with constant table
|
||||
/// specs; all values are bound, so the result is safe from injection.
|
||||
fn insert_sql(table: &str, columns: usize, rows: usize) -> String {
|
||||
let mut sql = format!("INSERT INTO {table} VALUES ");
|
||||
for row in 0..rows {
|
||||
if row > 0 {
|
||||
sql.push_str(", ");
|
||||
}
|
||||
sql.push('(');
|
||||
for col in 0..columns {
|
||||
if col > 0 {
|
||||
sql.push_str(", ");
|
||||
}
|
||||
sql.push_str(&format!("${}", row * columns + col + 1));
|
||||
}
|
||||
sql.push(')');
|
||||
}
|
||||
sql
|
||||
}
|
||||
|
||||
fn grid_pos(x: i64, y: i64) -> GridPos {
|
||||
GridPos::new(x as i32, y as i32)
|
||||
}
|
||||
|
||||
fn world_info(row: &sqlx::any::AnyRow) -> Result<WorldInfo, StorageError> {
|
||||
let id: String = row.try_get(0)?;
|
||||
Ok(WorldInfo {
|
||||
id: Uuid::parse_str(&id).map_err(|e| sqlx::Error::Decode(Box::new(e)))?,
|
||||
name: row.try_get(1)?,
|
||||
map: row.try_get(2)?,
|
||||
created_at: row.try_get(3)?,
|
||||
saved_at: row.try_get(4)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates the runtime that runs database work off the main thread.
|
||||
pub fn runtime() -> Arc<Runtime> {
|
||||
Arc::new(
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(1)
|
||||
.thread_name("station-db")
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to start the database runtime"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Database of the running world. Absent when saving is disabled.
|
||||
#[derive(Resource)]
|
||||
pub struct Storage {
|
||||
pub runtime: Arc<Runtime>,
|
||||
pub db: Database,
|
||||
pub world: WorldInfo,
|
||||
pending: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub fn new(runtime: Arc<Runtime>, db: Database, world: WorldInfo) -> Self {
|
||||
Self {
|
||||
runtime,
|
||||
db,
|
||||
world,
|
||||
pending: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens (or creates) the configured world and loads its state. Returns
|
||||
/// `None` as the save for newly created worlds.
|
||||
pub fn open(
|
||||
settings: &StorageSettings,
|
||||
map: &str,
|
||||
) -> Result<(Self, Option<SaveGame>), StorageError> {
|
||||
let runtime = runtime();
|
||||
let (db, world, save) = runtime.block_on(async {
|
||||
let db = Database::connect(&settings.database_url).await?;
|
||||
match db.find_world(&settings.world).await? {
|
||||
Some(world) => {
|
||||
let save = db.load_world(world.id).await?;
|
||||
Ok::<_, StorageError>((db, world, Some(save)))
|
||||
}
|
||||
None => {
|
||||
let (id, name) = match &settings.world {
|
||||
WorldSelector::Id(id) => (*id, id.to_string()),
|
||||
WorldSelector::Name(name) => (Uuid::new_v4(), name.clone()),
|
||||
};
|
||||
let world = db.create_world(id, &name, map).await?;
|
||||
Ok((db, world, None))
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok((Self::new(runtime, db, world), save))
|
||||
}
|
||||
|
||||
fn finish_pending(&mut self) {
|
||||
if let Some(handle) = self.pending.take()
|
||||
&& let Err(e) = self.runtime.block_on(handle)
|
||||
{
|
||||
error!("save task failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes in the background. Skipped if the previous save is still running.
|
||||
pub fn save_in_background(&mut self, save: SaveGame) {
|
||||
if self.pending.as_ref().is_some_and(|h| !h.is_finished()) {
|
||||
warn!("previous save still running, skipping autosave");
|
||||
return;
|
||||
}
|
||||
let db = self.db.clone();
|
||||
let id = self.world.id;
|
||||
self.pending = Some(self.runtime.spawn(async move {
|
||||
match db.save_world(id, &save).await {
|
||||
Ok(()) => debug!("world {id} saved"),
|
||||
Err(e) => error!("failed to save world {id}: {e}"),
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Writes and waits for completion (e.g. on exit).
|
||||
pub fn save_blocking(&mut self, save: &SaveGame) -> Result<(), StorageError> {
|
||||
self.finish_pending();
|
||||
self.runtime
|
||||
.block_on(self.db.save_world(self.world.id, save))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Storage {
|
||||
fn drop(&mut self) {
|
||||
self.finish_pending();
|
||||
self.runtime.block_on(self.db.close());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
pub struct AutosaveTimer(pub Timer);
|
||||
|
||||
pub fn load(path: Option<&Path>) -> Option<SaveGame> {
|
||||
let path = path?;
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
|
||||
Err(e) => panic!("failed to read save `{}`: {e}", path.display()),
|
||||
};
|
||||
let save: SaveGame = rsn::from_str(&text)
|
||||
.unwrap_or_else(|e| panic!("save `{}` is corrupt: {e}", path.display()));
|
||||
if save.version != SAVE_VERSION {
|
||||
panic!(
|
||||
"save `{}` has version {}, expected {SAVE_VERSION}",
|
||||
path.display(),
|
||||
save.version
|
||||
);
|
||||
}
|
||||
Some(save)
|
||||
}
|
||||
|
||||
pub fn restore(commands: &mut Commands, data: &GameData, save: SaveGame) {
|
||||
for b in save.buildings {
|
||||
if data.buildings.contains_key(&b.proto) {
|
||||
|
|
@ -108,25 +622,15 @@ pub fn restore(commands: &mut Commands, data: &GameData, save: SaveGame) {
|
|||
commands.insert_resource(CharacterStore(save.characters));
|
||||
}
|
||||
|
||||
pub fn write(path: &Path, save: &SaveGame) -> std::io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let text = rsn::to_string_pretty(save).map_err(std::io::Error::other)?;
|
||||
let tmp: PathBuf = path.with_extension("rsn.tmp");
|
||||
std::fs::write(&tmp, text)?;
|
||||
std::fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
/// Collects the full world state.
|
||||
pub fn snapshot(world: &mut World) -> SaveGame {
|
||||
let map = world.resource::<ServerSettings>().map.clone();
|
||||
let mut characters = world.resource::<CharacterStore>().0.clone();
|
||||
let mut q = world.query::<(&Character, &GridPos, &Facing, &Vitals, &Inventory, &Skills)>();
|
||||
for (c, pos, facing, vitals, inventory, skills) in q.iter(world) {
|
||||
characters.insert(
|
||||
c.name.clone(),
|
||||
SavedCharacter {
|
||||
name: c.name.clone(),
|
||||
pos: *pos,
|
||||
facing: facing.0,
|
||||
vitals: vitals.clone(),
|
||||
|
|
@ -154,22 +658,32 @@ pub fn snapshot(world: &mut World) -> SaveGame {
|
|||
})
|
||||
.collect();
|
||||
SaveGame {
|
||||
version: SAVE_VERSION,
|
||||
map,
|
||||
buildings,
|
||||
items,
|
||||
characters,
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves in the background (autosave, admin command).
|
||||
pub fn save_world(world: &mut World) {
|
||||
let Some(path) = world.resource::<ServerSettings>().save_path.clone() else {
|
||||
if !world.contains_resource::<Storage>() {
|
||||
return;
|
||||
};
|
||||
}
|
||||
let save = snapshot(world);
|
||||
match write(&path, &save) {
|
||||
Ok(()) => debug!("world saved to `{}`", path.display()),
|
||||
Err(e) => error!("failed to save world to `{}`: {e}", path.display()),
|
||||
world.resource_mut::<Storage>().save_in_background(save);
|
||||
}
|
||||
|
||||
/// Saves and waits until the data is written.
|
||||
pub fn save_world_blocking(world: &mut World) {
|
||||
if !world.contains_resource::<Storage>() {
|
||||
return;
|
||||
}
|
||||
let save = snapshot(world);
|
||||
let mut storage = world.resource_mut::<Storage>();
|
||||
let name = storage.world.name.clone();
|
||||
match storage.save_blocking(&save) {
|
||||
Ok(()) => info!("world `{name}` saved"),
|
||||
Err(e) => error!("failed to save world `{name}`: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -189,7 +703,7 @@ pub fn save_on_exit(world: &mut World) {
|
|||
let exiting = !world.resource::<Messages<AppExit>>().is_empty();
|
||||
if exiting {
|
||||
info!("saving world before exit");
|
||||
save_world(world);
|
||||
save_world_blocking(world);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,20 +711,16 @@ pub fn save_on_exit(world: &mut World) {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn save_roundtrip() {
|
||||
let mut save = SaveGame {
|
||||
version: SAVE_VERSION,
|
||||
map: "maps/test.tmx".into(),
|
||||
..default()
|
||||
};
|
||||
fn sample() -> SaveGame {
|
||||
let mut save = SaveGame::default();
|
||||
let mut inventory = Inventory::default();
|
||||
inventory.slots.push(ItemStack::new("apple", 3));
|
||||
let mut skills = Skills::default();
|
||||
skills.add_xp("mining", 120);
|
||||
save.characters.insert(
|
||||
"Alice".into(),
|
||||
"4f1c0c56-1111-4a5e-9a1e-000000000001".into(),
|
||||
SavedCharacter {
|
||||
name: "Alice".into(),
|
||||
pos: GridPos::new(3, -4),
|
||||
facing: Dir::West,
|
||||
vitals: Vitals {
|
||||
|
|
@ -233,7 +743,7 @@ mod tests {
|
|||
machine: None,
|
||||
});
|
||||
save.buildings.push(SavedBuilding {
|
||||
proto: "wall".into(),
|
||||
proto: "furnace".into(),
|
||||
pos: GridPos::new(0, 0),
|
||||
state: BuildingState::default(),
|
||||
machine: Some(Machine {
|
||||
|
|
@ -243,17 +753,111 @@ mod tests {
|
|||
progress: 1.5,
|
||||
}),
|
||||
});
|
||||
// More rows than one insert batch.
|
||||
for i in 0..250 {
|
||||
save.items.push(SavedItem {
|
||||
stack: ItemStack::new("log", 2),
|
||||
pos: GridPos::new(5, 5),
|
||||
pos: GridPos::new(i, 5),
|
||||
});
|
||||
}
|
||||
save
|
||||
}
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("game_save_test_{}", std::process::id()));
|
||||
let path = dir.join("world.rsn");
|
||||
write(&path, &save).unwrap();
|
||||
let loaded = load(Some(&path)).unwrap();
|
||||
/// Round trip and isolation of two worlds in one database.
|
||||
async fn exercise(db: &Database) {
|
||||
let save = sample();
|
||||
let a = db
|
||||
.create_world(Uuid::new_v4(), "Alpha", "maps/a.tmx")
|
||||
.await
|
||||
.unwrap();
|
||||
let b = db
|
||||
.create_world(Uuid::new_v4(), "Beta", "maps/b.tmx")
|
||||
.await
|
||||
.unwrap();
|
||||
db.save_world(a.id, &save).await.unwrap();
|
||||
db.save_world(b.id, &SaveGame::default()).await.unwrap();
|
||||
let mut loaded = db.load_world(a.id).await.unwrap();
|
||||
loaded.items.sort_by_key(|i| i.pos.x);
|
||||
loaded.buildings.sort_by_key(|b| b.pos.x);
|
||||
let mut expected = save.clone();
|
||||
expected.buildings.sort_by_key(|b| b.pos.x);
|
||||
assert_eq!(loaded, expected);
|
||||
assert_eq!(db.load_world(b.id).await.unwrap(), SaveGame::default());
|
||||
|
||||
// Saving again replaces the previous state.
|
||||
let mut smaller = save.clone();
|
||||
smaller.items.truncate(1);
|
||||
db.save_world(a.id, &smaller).await.unwrap();
|
||||
assert_eq!(db.load_world(a.id).await.unwrap().items.len(), 1);
|
||||
|
||||
let found = db
|
||||
.find_world(&WorldSelector::Name("Beta".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(found.map(|w| w.id), Some(b.id));
|
||||
let found = db.find_world(&WorldSelector::Id(a.id)).await.unwrap();
|
||||
assert_eq!(found.map(|w| w.map), Some("maps/a.tmx".into()));
|
||||
assert!(db.list_worlds().await.unwrap().len() >= 2);
|
||||
|
||||
db.delete_world(a.id).await.unwrap();
|
||||
db.delete_world(b.id).await.unwrap();
|
||||
assert!(
|
||||
db.find_world(&WorldSelector::Id(a.id))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(matches!(
|
||||
db.save_world(a.id, &save).await,
|
||||
Err(StorageError::NoSuchWorld(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_roundtrip() {
|
||||
let dir = std::env::temp_dir().join(format!("station_db_test_{}", std::process::id()));
|
||||
let url = sqlite_url(&dir.join("nested/world.db"));
|
||||
let rt = runtime();
|
||||
rt.block_on(async {
|
||||
let db = Database::connect(&url).await.unwrap();
|
||||
exercise(&db).await;
|
||||
db.close().await;
|
||||
// Reconnecting keeps the schema.
|
||||
let db = Database::connect(&url).await.unwrap();
|
||||
db.close().await;
|
||||
});
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
assert_eq!(loaded, save);
|
||||
assert!(load(Some(&dir.join("missing.rsn"))).is_none());
|
||||
}
|
||||
|
||||
/// Runs against PostgreSQL when `STATION_TEST_POSTGRES_URL` is set.
|
||||
#[test]
|
||||
fn postgres_roundtrip() {
|
||||
let Ok(url) = std::env::var("STATION_TEST_POSTGRES_URL") else {
|
||||
eprintln!("STATION_TEST_POSTGRES_URL not set, skipping");
|
||||
return;
|
||||
};
|
||||
runtime().block_on(async {
|
||||
let db = Database::connect(&url).await.unwrap();
|
||||
exercise(&db).await;
|
||||
db.close().await;
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_placeholders() {
|
||||
assert_eq!(
|
||||
insert_sql("t (a, b)", 2, 2),
|
||||
"INSERT INTO t (a, b) VALUES ($1, $2), ($3, $4)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_selector() {
|
||||
let id = Uuid::new_v4();
|
||||
assert_eq!(WorldSelector::parse(&id.to_string()), WorldSelector::Id(id));
|
||||
assert_eq!(
|
||||
WorldSelector::parse("main"),
|
||||
WorldSelector::Name("main".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ pub fn on_login(
|
|||
|
||||
let (saved, returning) = match store.0.remove(&name) {
|
||||
Some(saved) => (saved, true),
|
||||
None => (new_character(&data, spawn.0), false),
|
||||
None => (new_character(&data, &name, spawn.0), false),
|
||||
};
|
||||
let pos = find_free_tile(saved.pos, &layout.0, &buildings);
|
||||
let entity = commands
|
||||
|
|
@ -115,12 +115,13 @@ pub fn on_login(
|
|||
tell(&mut commands, client, greeting);
|
||||
}
|
||||
|
||||
fn new_character(data: &GameData, spawn: GridPos) -> SavedCharacter {
|
||||
fn new_character(data: &GameData, name: &str, spawn: GridPos) -> SavedCharacter {
|
||||
let mut inventory = Inventory::default();
|
||||
if data.items.contains_key("apple") {
|
||||
inventory.add("apple", 2, data);
|
||||
}
|
||||
SavedCharacter {
|
||||
name: name.to_owned(),
|
||||
pos: spawn,
|
||||
facing: Default::default(),
|
||||
vitals: Vitals::default(),
|
||||
|
|
@ -145,6 +146,7 @@ pub fn on_disconnect(
|
|||
store.0.insert(
|
||||
c.name.clone(),
|
||||
SavedCharacter {
|
||||
name: c.name.clone(),
|
||||
pos: *pos,
|
||||
facing: facing.0,
|
||||
vitals: vitals.clone(),
|
||||
|
|
|
|||
|
|
@ -41,7 +41,21 @@ pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data:
|
|||
},
|
||||
));
|
||||
|
||||
match persistence::load(settings.save_path.as_deref()) {
|
||||
let mut save = None;
|
||||
if let Some(storage) = &settings.storage {
|
||||
let (storage, loaded) = persistence::Storage::open(storage, &settings.map)
|
||||
.unwrap_or_else(|e| panic!("failed to open the world database: {e}"));
|
||||
info!("world `{}` ({})", storage.world.name, storage.world.id);
|
||||
if loaded.is_some() && storage.world.map != settings.map {
|
||||
warn!(
|
||||
"world was created on map `{}`, now running `{}`",
|
||||
storage.world.map, settings.map
|
||||
);
|
||||
}
|
||||
save = loaded;
|
||||
commands.insert_resource(storage);
|
||||
}
|
||||
match save {
|
||||
Some(save) => {
|
||||
info!(
|
||||
"restoring persistent world: {} buildings, {} items, {} characters",
|
||||
|
|
@ -49,12 +63,6 @@ pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data:
|
|||
save.items.len(),
|
||||
save.characters.len()
|
||||
);
|
||||
if save.map != settings.map {
|
||||
warn!(
|
||||
"save was made on map `{}`, now running `{}`",
|
||||
save.map, settings.map
|
||||
);
|
||||
}
|
||||
persistence::restore(&mut commands, &data, save);
|
||||
}
|
||||
None => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue