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:
Steve Biedermann 2026-10-18 20:47:43 +02:00
parent 3cf0561401
commit afd3d3a537
14 changed files with 1513 additions and 117 deletions

View File

@ -71,9 +71,13 @@ conventions) in sync with API, configuration, and behavior changes.
- Rune scripts stage changes in `ScriptState`; they are applied after the - Rune scripts stage changes in `ScriptState`; they are applied after the
script returns, and a failing script has no effect. Ctx methods need script returns, and a failing script has no effect. Ctx methods need
`#[rune::function(keep)]`; script-facing numbers are integers. `#[rune::function(keep)]`; script-facing numbers are integers.
- Save files (`persistence.rs`, `SAVE_VERSION`) are the source of truth for an - The database (`persistence.rs`) is the source of truth for an existing world;
existing world; the Tiled map only seeds new worlds. Changing saved types the Tiled map only seeds new worlds. All tables are keyed by the world UUID.
requires a version bump or a compatible migration. 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 - Characters are identified by name only (no authentication); renet uses
unsecure authentication. Do not treat client input as trusted. unsecure authentication. Do not treat client input as trusted.
- Replicon builds its systems in `Plugin::finish`; headless tests must call - Replicon builds its systems in `Plugin::finish`; headless tests must call

View File

@ -26,3 +26,12 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
assembler makes tools. assembler makes tools.
- GitHub `Package` workflow builds archives for Windows (zip), Linux and macOS - GitHub `Package` workflow builds archives for Windows (zip), Linux and macOS
(tar.gz). (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.

681
Cargo.lock generated
View File

@ -102,7 +102,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [ dependencies = [
"crypto-common", "crypto-common 0.1.7",
"generic-array", "generic-array",
] ]
@ -354,6 +354,15 @@ dependencies = [
"portable-atomic", "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]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@ -1284,7 +1293,7 @@ dependencies = [
"portable-atomic", "portable-atomic",
"portable-atomic-util", "portable-atomic-util",
"serde", "serde",
"spin", "spin 0.10.1",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-time", "web-time",
@ -1923,6 +1932,24 @@ dependencies = [
"cpufeatures 0.3.1", "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]] [[package]]
name = "block2" name = "block2"
version = "0.5.1" version = "0.5.1"
@ -2068,6 +2095,17 @@ dependencies = [
"cpufeatures 0.2.17", "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]] [[package]]
name = "chacha20poly1305" name = "chacha20poly1305"
version = "0.10.1" version = "0.10.1"
@ -2075,7 +2113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [ dependencies = [
"aead", "aead",
"chacha20", "chacha20 0.9.1",
"cipher", "cipher",
"poly1305", "poly1305",
"zeroize", "zeroize",
@ -2087,7 +2125,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [ dependencies = [
"crypto-common", "crypto-common 0.1.7",
"inout", "inout",
"zeroize", "zeroize",
] ]
@ -2101,6 +2139,12 @@ dependencies = [
"error-code", "error-code",
] ]
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]] [[package]]
name = "cobs" name = "cobs"
version = "0.3.0" version = "0.3.0"
@ -2328,6 +2372,21 @@ dependencies = [
"libc", "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]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.2" version = "1.5.2"
@ -2384,6 +2443,15 @@ dependencies = [
"typenum", "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]] [[package]]
name = "ctrlc" name = "ctrlc"
version = "3.5.2" version = "3.5.2"
@ -2395,6 +2463,15 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "cursor-icon" name = "cursor-icon"
version = "1.2.0" version = "1.2.0"
@ -2442,6 +2519,48 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11277822c27bde750de02c5dc5159b91e88bf2661a2c1d98106f2fb1c5c6f590" 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]] [[package]]
name = "dispatch" name = "dispatch"
version = "0.2.0" version = "0.2.0"
@ -2495,6 +2614,12 @@ dependencies = [
"litrs", "litrs",
] ]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]] [[package]]
name = "downcast-rs" name = "downcast-rs"
version = "1.2.1" version = "1.2.1"
@ -2518,6 +2643,9 @@ name = "either"
version = "1.19.0" version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "encase" name = "encase"
@ -2594,6 +2722,16 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" 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]] [[package]]
name = "euclid" name = "euclid"
version = "0.22.14" version = "0.22.14"
@ -2671,6 +2809,17 @@ dependencies = [
"zlib-rs", "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]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@ -2748,6 +2897,15 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" 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]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.34" version = "0.3.34"
@ -2755,6 +2913,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@ -2763,6 +2922,28 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" 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]] [[package]]
name = "futures-io" name = "futures-io"
version = "0.3.34" version = "0.3.34"
@ -2793,6 +2974,12 @@ dependencies = [
"syn 3.0.9", "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]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.34" version = "0.3.34"
@ -2806,8 +2993,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-io",
"futures-macro", "futures-macro",
"futures-sink",
"futures-task", "futures-task",
"memchr",
"pin-project-lite", "pin-project-lite",
"slab", "slab",
] ]
@ -2819,6 +3009,7 @@ dependencies = [
"bevy", "bevy",
"bevy_replicon", "bevy_replicon",
"bevy_replicon_renet", "bevy_replicon_renet",
"directories",
"game_core", "game_core",
"game_server", "game_server",
] ]
@ -2847,8 +3038,12 @@ dependencies = [
"rsn", "rsn",
"rune", "rune",
"serde", "serde",
"serde_json",
"sqlx",
"thiserror 2.0.21", "thiserror 2.0.21",
"tiled", "tiled",
"tokio",
"uuid",
] ]
[[package]] [[package]]
@ -2918,6 +3113,7 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core 0.10.1",
] ]
[[package]] [[package]]
@ -3152,6 +3348,15 @@ dependencies = [
"foldhash 0.2.0", "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]] [[package]]
name = "heapless" name = "heapless"
version = "0.9.3" version = "0.9.3"
@ -3163,12 +3368,24 @@ dependencies = [
"stable_deref_trait", "stable_deref_trait",
] ]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.5.3" version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]] [[package]]
name = "hexasphere" name = "hexasphere"
version = "18.0.0" version = "18.0.0"
@ -3186,6 +3403,33 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" 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]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.3.0" version = "2.3.0"
@ -3314,6 +3558,27 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" 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]] [[package]]
name = "image" name = "image"
version = "0.25.10" version = "0.25.10"
@ -3570,6 +3835,17 @@ dependencies = [
"redox_syscall 0.9.5", "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]] [[package]]
name = "libudev-sys" name = "libudev-sys"
version = "0.1.4" version = "0.1.4"
@ -3656,6 +3932,16 @@ dependencies = [
"regex-automata", "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]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.3" version = "2.8.3"
@ -3691,6 +3977,17 @@ dependencies = [
"simd-adler32", "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]] [[package]]
name = "moxcms" name = "moxcms"
version = "0.8.1" version = "0.8.1"
@ -4363,6 +4660,12 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]] [[package]]
name = "orbclient" name = "orbclient"
version = "0.3.55" version = "0.3.55"
@ -4713,6 +5016,7 @@ version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [ dependencies = [
"chacha20 0.10.2",
"getrandom 0.4.3", "getrandom 0.4.3",
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
@ -4820,6 +5124,16 @@ dependencies = [
"bitflags 2.13.2", "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]] [[package]]
name = "regex" name = "regex"
version = "1.13.1" version = "1.13.1"
@ -4887,6 +5201,20 @@ dependencies = [
"log", "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]] [[package]]
name = "rodio" name = "rodio"
version = "0.22.2" version = "0.22.2"
@ -5065,6 +5393,40 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@ -5175,6 +5537,39 @@ dependencies = [
"zmij", "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]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@ -5296,6 +5691,25 @@ dependencies = [
"serde", "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]] [[package]]
name = "spin" name = "spin"
version = "0.10.1" version = "0.10.1"
@ -5314,6 +5728,175 @@ dependencies = [
"bitflags 2.13.2", "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]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@ -5341,6 +5924,17 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" 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]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@ -5566,7 +6160,23 @@ version = "1.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
dependencies = [ dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite", "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]] [[package]]
@ -5605,6 +6215,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"log",
"pin-project-lite", "pin-project-lite",
"tracing-attributes", "tracing-attributes",
"tracing-core", "tracing-core",
@ -5730,12 +6341,33 @@ version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.27" version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" 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]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
version = "1.13.3" version = "1.13.3"
@ -5766,7 +6398,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [ dependencies = [
"crypto-common", "crypto-common 0.1.7",
"subtle", "subtle",
] ]
@ -5781,6 +6413,24 @@ dependencies = [
"rustc-hash 2.1.3", "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]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@ -5826,6 +6476,12 @@ dependencies = [
"unsynn", "unsynn",
] ]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "vec_map" name = "vec_map"
version = "0.8.2" version = "0.8.2"
@ -6066,6 +6722,15 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "wgpu" name = "wgpu"
version = "29.0.4" version = "29.0.4"
@ -6233,6 +6898,12 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "whoami"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"

View File

@ -16,6 +16,11 @@ rsn = "0.2"
rune = "0.14" rune = "0.14"
tiled = "0.16" tiled = "0.16"
thiserror = "2" 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_core = { path = "crates/game_core" }
game_server = { path = "crates/game_server" } game_server = { path = "crates/game_server" }

View File

@ -31,20 +31,42 @@ multiplayer behave identically.
## Running ## Running
```sh ```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 -- --host 5000 # singleplayer world that others can join
cargo run -- --connect 127.0.0.1:5000 # join a server or host 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`. Client options: `--name NAME`, `--save NAME`, `--no-save`, `--map MAP`.
Server options: `--port PORT`, `--save FILE`, `--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 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. 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). The world autosaves periodically (in the background) and on exit (window close,
Characters are identified by name and are restored when that name logs in again. 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 ## 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`. - `item` – custom property `proto` = item id and optional `count`.
- Objects are placed on the tile containing their position (point objects work well). - 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. A map is only used to create a new world; after that the saved world is
Delete the save file to start a fresh world. 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. `tools/gen_placeholder_assets.py` regenerates the placeholder sprites and example map.

View File

@ -13,6 +13,7 @@ game_server = { workspace = true }
bevy = { workspace = true, default-features = true } bevy = { workspace = true, default-features = true }
bevy_replicon = { workspace = true } bevy_replicon = { workspace = true }
bevy_replicon_renet = { workspace = true } bevy_replicon_renet = { workspace = true }
directories = { workspace = true }
[lints] [lints]
workspace = true workspace = true

View File

@ -5,7 +5,7 @@
//! game --host [PORT] singleplayer world that friends can join //! game --host [PORT] singleplayer world that friends can join
//! game --connect ADDR[:PORT] join a dedicated server or host //! 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. // Release builds on Windows run without a console window.
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] #![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
@ -13,9 +13,10 @@
mod input; mod input;
mod net; mod net;
mod render; mod render;
mod saves;
mod ui; mod ui;
use std::{net::SocketAddr, path::PathBuf}; use std::net::SocketAddr;
use bevy::{prelude::*, window::WindowResolution}; use bevy::{prelude::*, window::WindowResolution};
use bevy_replicon::prelude::*; use bevy_replicon::prelude::*;
@ -99,7 +100,7 @@ fn parse_args() -> (Mode, String, ServerSettings) {
.filter(|n| game_server::players::valid_name(n)) .filter(|n| game_server::players::valid_name(n))
.unwrap_or_else(|| "Player".to_owned()); .unwrap_or_else(|| "Player".to_owned());
let mut settings = ServerSettings { let mut settings = ServerSettings {
save_path: Some(PathBuf::from("saves/singleplayer.rsn")), storage: Some(saves::storage("singleplayer")),
..default() ..default()
}; };
let mut args = std::env::args().skip(1).peekable(); let mut args = std::env::args().skip(1).peekable();
@ -134,8 +135,8 @@ fn parse_args() -> (Mode, String, ServerSettings) {
mode = Mode::Join { addr }; mode = Mode::Join { addr };
} }
"--name" => name = value("a name"), "--name" => name = value("a name"),
"--save" => settings.save_path = Some(value("a file").into()), "--save" => settings.storage = Some(saves::storage(&value("a save name"))),
"--no-save" => settings.save_path = None, "--no-save" => settings.storage = None,
"--map" => settings.map = value("a map path"), "--map" => settings.map = value("a map path"),
"-h" | "--help" => usage(""), "-h" | "--help" => usage(""),
other => usage(&format!("unknown argument `{other}`")), other => usage(&format!("unknown argument `{other}`")),
@ -152,15 +153,16 @@ fn usage(error: &str) -> ! {
eprintln!("error: {error}\n"); eprintln!("error: {error}\n");
} }
eprintln!( eprintln!(
"usage: game [--host [PORT] | --connect ADDR[:PORT]] [--name NAME] [--save FILE | --no-save] [--map MAP]\n\n\ "usage: game [--host [PORT] | --connect ADDR[:PORT]] [--name NAME] [--save NAME | --no-save] [--map MAP]\n\n\
(no mode) singleplayer, world saved to saves/singleplayer.rsn\n\ (no mode) singleplayer, world saved as `singleplayer`\n\
--host play your world and let others join (default port {port})\n\ --host play your world and let others join (default port {port})\n\
--connect join a server (default port {port})\n\ --connect join a server (default port {port})\n\
--name character name; characters persist per world\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})", --map Tiled map for new worlds (default {map})",
port = game_core::DEFAULT_PORT, port = game_core::DEFAULT_PORT,
map = game_core::DEFAULT_MAP, map = game_core::DEFAULT_MAP,
saves = saves::saves_dir().display(),
); );
std::process::exit(if error.is_empty() { 0 } else { 2 }) std::process::exit(if error.is_empty() { 0 } else { 2 })
} }

View File

@ -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");
}
}

View File

@ -17,6 +17,10 @@ rsn = { workspace = true }
rune = { workspace = true } rune = { workspace = true }
tiled = { workspace = true } tiled = { workspace = true }
thiserror = { 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. # Enables graceful shutdown (and saving) on SIGTERM/SIGHUP via Bevy's Ctrl-C handler.
ctrlc = { version = "3.5", features = ["termination"] } ctrlc = { version = "3.5", features = ["termination"] }

View File

@ -15,7 +15,7 @@ pub mod scripting;
pub mod survival; pub mod survival;
pub mod world; pub mod world;
use std::{path::PathBuf, sync::Arc, time::Duration}; use std::{sync::Arc, time::Duration};
use bevy::prelude::*; use bevy::prelude::*;
use game_core::GameData; use game_core::GameData;
@ -27,8 +27,8 @@ pub use network::NetworkServerPlugin;
pub struct ServerSettings { pub struct ServerSettings {
/// Tiled map, relative to the assets directory. /// Tiled map, relative to the assets directory.
pub map: String, pub map: String,
/// Where the persistent world is stored. `None` disables saving. /// Database and world to persist to. `None` disables saving.
pub save_path: Option<PathBuf>, pub storage: Option<persistence::StorageSettings>,
pub autosave_interval: Duration, pub autosave_interval: Duration,
} }
@ -36,7 +36,7 @@ impl Default for ServerSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
map: game_core::DEFAULT_MAP.to_owned(), map: game_core::DEFAULT_MAP.to_owned(),
save_path: None, storage: None,
autosave_interval: Duration::from_secs(60), autosave_interval: Duration::from_secs(60),
} }
} }

View File

@ -1,23 +1,28 @@
//! Headless dedicated server. //! Headless dedicated server.
//! //!
//! ```text //! ```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::{app::ScheduleRunnerPlugin, log::LogPlugin, prelude::*, state::app::StatesPlugin};
use bevy_replicon::prelude::*; use bevy_replicon::prelude::*;
use bevy_replicon_renet::RepliconRenetPlugins; use bevy_replicon_renet::RepliconRenetPlugins;
use game_core::CorePlugin; 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 { fn main() -> AppExit {
let mut port = game_core::DEFAULT_PORT; let mut port = game_core::DEFAULT_PORT;
let mut settings = ServerSettings { let mut settings = ServerSettings::default();
save_path: Some(PathBuf::from("saves/server.rsn")), let mut database = Some(DEFAULT_DATABASE.to_owned());
..Default::default() let mut world = WorldSelector::Name(DEFAULT_WORLD.to_owned());
};
let mut args = std::env::args().skip(1); let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() { while let Some(arg) = args.next() {
let mut value = |name: &str| { let mut value = |name: &str| {
@ -30,13 +35,18 @@ fn main() -> AppExit {
.parse() .parse()
.unwrap_or_else(|_| usage("invalid port")) .unwrap_or_else(|_| usage("invalid port"))
} }
"--save" => settings.save_path = Some(value("--save").into()), "--database" => database = Some(value("--database")),
"--no-save" => settings.save_path = None, "--world" => world = WorldSelector::parse(&value("--world")),
"--no-save" => database = None,
"--map" => settings.map = value("--map"), "--map" => settings.map = value("--map"),
"-h" | "--help" => usage(""), "-h" | "--help" => usage(""),
other => usage(&format!("unknown argument `{other}`")), other => usage(&format!("unknown argument `{other}`")),
} }
} }
settings.storage = database.map(|database_url| StorageSettings {
database_url,
world,
});
App::new() App::new()
.add_plugins(( .add_plugins((
@ -60,10 +70,12 @@ fn usage(error: &str) -> ! {
eprintln!("error: {error}\n"); eprintln!("error: {error}\n");
} }
eprintln!( 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\ --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\
--map Tiled map relative to the assets directory (default {})", --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_PORT,
game_core::DEFAULT_MAP game_core::DEFAULT_MAP
); );

View File

@ -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 //! SQLite (default) and PostgreSQL are supported through sqlx's `Any` driver.
//! written to a single human-readable `.rsn` file. Saves are atomic: data is //! Every world has a UUID and all rows are keyed by it, so any number of worlds
//! written to a temporary file which then replaces the previous save. //! 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::{ use std::{
collections::BTreeMap, collections::BTreeMap,
path::{Path, PathBuf}, path::Path,
sync::{Arc, Once},
time::{SystemTime, UNIX_EPOCH},
}; };
use bevy::prelude::*; use bevy::prelude::*;
use game_core::{GameData, components::*, grid::Dir}; use game_core::{GameData, components::*, grid::Dir};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{AnyPool, AssertSqlSafe, Row, any::AnyPoolOptions};
use tokio::{runtime::Runtime, task::JoinHandle};
use uuid::Uuid;
use crate::{ use crate::{
ServerSettings,
players::CharacterStore, players::CharacterStore,
world::{spawn_building_with_state, spawn_ground_item}, 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)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SavedCharacter { pub struct SavedCharacter {
pub name: String,
pub pos: GridPos, pub pos: GridPos,
pub facing: Dir, pub facing: Dir,
pub vitals: Vitals, pub vitals: Vitals,
@ -45,37 +108,488 @@ pub struct SavedItem {
pub pos: GridPos, pub pos: GridPos,
} }
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] /// Full state of a world.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SaveGame { pub struct SaveGame {
pub version: u32,
pub map: String,
pub buildings: Vec<SavedBuilding>, pub buildings: Vec<SavedBuilding>,
pub items: Vec<SavedItem>, pub items: Vec<SavedItem>,
/// All characters (online and offline), by player id.
pub characters: BTreeMap<String, SavedCharacter>, 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)] #[derive(Resource)]
pub struct AutosaveTimer(pub Timer); 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) { pub fn restore(commands: &mut Commands, data: &GameData, save: SaveGame) {
for b in save.buildings { for b in save.buildings {
if data.buildings.contains_key(&b.proto) { 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)); 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. /// Collects the full world state.
pub fn snapshot(world: &mut World) -> SaveGame { pub fn snapshot(world: &mut World) -> SaveGame {
let map = world.resource::<ServerSettings>().map.clone();
let mut characters = world.resource::<CharacterStore>().0.clone(); let mut characters = world.resource::<CharacterStore>().0.clone();
let mut q = world.query::<(&Character, &GridPos, &Facing, &Vitals, &Inventory, &Skills)>(); let mut q = world.query::<(&Character, &GridPos, &Facing, &Vitals, &Inventory, &Skills)>();
for (c, pos, facing, vitals, inventory, skills) in q.iter(world) { for (c, pos, facing, vitals, inventory, skills) in q.iter(world) {
characters.insert( characters.insert(
c.name.clone(), c.name.clone(),
SavedCharacter { SavedCharacter {
name: c.name.clone(),
pos: *pos, pos: *pos,
facing: facing.0, facing: facing.0,
vitals: vitals.clone(), vitals: vitals.clone(),
@ -154,22 +658,32 @@ pub fn snapshot(world: &mut World) -> SaveGame {
}) })
.collect(); .collect();
SaveGame { SaveGame {
version: SAVE_VERSION,
map,
buildings, buildings,
items, items,
characters, characters,
} }
} }
/// Saves in the background (autosave, admin command).
pub fn save_world(world: &mut World) { pub fn save_world(world: &mut World) {
let Some(path) = world.resource::<ServerSettings>().save_path.clone() else { if !world.contains_resource::<Storage>() {
return; return;
}; }
let save = snapshot(world); let save = snapshot(world);
match write(&path, &save) { world.resource_mut::<Storage>().save_in_background(save);
Ok(()) => debug!("world saved to `{}`", path.display()), }
Err(e) => error!("failed to save world to `{}`: {e}", path.display()),
/// 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(); let exiting = !world.resource::<Messages<AppExit>>().is_empty();
if exiting { if exiting {
info!("saving world before exit"); 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 { mod tests {
use super::*; use super::*;
#[test] fn sample() -> SaveGame {
fn save_roundtrip() { let mut save = SaveGame::default();
let mut save = SaveGame {
version: SAVE_VERSION,
map: "maps/test.tmx".into(),
..default()
};
let mut inventory = Inventory::default(); let mut inventory = Inventory::default();
inventory.slots.push(ItemStack::new("apple", 3)); inventory.slots.push(ItemStack::new("apple", 3));
let mut skills = Skills::default(); let mut skills = Skills::default();
skills.add_xp("mining", 120); skills.add_xp("mining", 120);
save.characters.insert( save.characters.insert(
"Alice".into(), "4f1c0c56-1111-4a5e-9a1e-000000000001".into(),
SavedCharacter { SavedCharacter {
name: "Alice".into(),
pos: GridPos::new(3, -4), pos: GridPos::new(3, -4),
facing: Dir::West, facing: Dir::West,
vitals: Vitals { vitals: Vitals {
@ -233,7 +743,7 @@ mod tests {
machine: None, machine: None,
}); });
save.buildings.push(SavedBuilding { save.buildings.push(SavedBuilding {
proto: "wall".into(), proto: "furnace".into(),
pos: GridPos::new(0, 0), pos: GridPos::new(0, 0),
state: BuildingState::default(), state: BuildingState::default(),
machine: Some(Machine { machine: Some(Machine {
@ -243,17 +753,111 @@ mod tests {
progress: 1.5, progress: 1.5,
}), }),
}); });
save.items.push(SavedItem { // More rows than one insert batch.
stack: ItemStack::new("log", 2), for i in 0..250 {
pos: GridPos::new(5, 5), save.items.push(SavedItem {
}); stack: ItemStack::new("log", 2),
pos: GridPos::new(i, 5),
});
}
save
}
let dir = std::env::temp_dir().join(format!("game_save_test_{}", std::process::id())); /// Round trip and isolation of two worlds in one database.
let path = dir.join("world.rsn"); async fn exercise(db: &Database) {
write(&path, &save).unwrap(); let save = sample();
let loaded = load(Some(&path)).unwrap(); 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(); 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())
);
} }
} }

View File

@ -89,7 +89,7 @@ pub fn on_login(
let (saved, returning) = match store.0.remove(&name) { let (saved, returning) = match store.0.remove(&name) {
Some(saved) => (saved, true), 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 pos = find_free_tile(saved.pos, &layout.0, &buildings);
let entity = commands let entity = commands
@ -115,12 +115,13 @@ pub fn on_login(
tell(&mut commands, client, greeting); 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(); let mut inventory = Inventory::default();
if data.items.contains_key("apple") { if data.items.contains_key("apple") {
inventory.add("apple", 2, data); inventory.add("apple", 2, data);
} }
SavedCharacter { SavedCharacter {
name: name.to_owned(),
pos: spawn, pos: spawn,
facing: Default::default(), facing: Default::default(),
vitals: Vitals::default(), vitals: Vitals::default(),
@ -145,6 +146,7 @@ pub fn on_disconnect(
store.0.insert( store.0.insert(
c.name.clone(), c.name.clone(),
SavedCharacter { SavedCharacter {
name: c.name.clone(),
pos: *pos, pos: *pos,
facing: facing.0, facing: facing.0,
vitals: vitals.clone(), vitals: vitals.clone(),

View File

@ -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) => { Some(save) => {
info!( info!(
"restoring persistent world: {} buildings, {} items, {} characters", "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.items.len(),
save.characters.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); persistence::restore(&mut commands, &data, save);
} }
None => { None => {