diff --git a/AGENTS.md b/AGENTS.md index 2ca6bd9..4949b21 100644 --- a/AGENTS.md +++ b/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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 97f5c58..2d0a6ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 509d6a2..f615dfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 1e57563..2ece7ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/README.md b/README.md index fb81569..4957921 100644 --- a/README.md +++ b/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. diff --git a/crates/game_client/Cargo.toml b/crates/game_client/Cargo.toml index 023cdfe..6000ff6 100644 --- a/crates/game_client/Cargo.toml +++ b/crates/game_client/Cargo.toml @@ -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 diff --git a/crates/game_client/src/main.rs b/crates/game_client/src/main.rs index 99c1ecf..325cf35 100644 --- a/crates/game_client/src/main.rs +++ b/crates/game_client/src/main.rs @@ -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 }) } diff --git a/crates/game_client/src/saves.rs b/crates/game_client/src/saves.rs new file mode 100644 index 0000000..3aaccf4 --- /dev/null +++ b/crates/game_client/src/saves.rs @@ -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"); + } +} diff --git a/crates/game_server/Cargo.toml b/crates/game_server/Cargo.toml index efd4c2e..617ff6c 100644 --- a/crates/game_server/Cargo.toml +++ b/crates/game_server/Cargo.toml @@ -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"] } diff --git a/crates/game_server/src/lib.rs b/crates/game_server/src/lib.rs index 8887380..3b57c76 100644 --- a/crates/game_server/src/lib.rs +++ b/crates/game_server/src/lib.rs @@ -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, + /// Database and world to persist to. `None` disables saving. + pub storage: Option, 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), } } diff --git a/crates/game_server/src/main.rs b/crates/game_server/src/main.rs index ff96a46..8b60d37 100644 --- a/crates/game_server/src/main.rs +++ b/crates/game_server/src/main.rs @@ -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,10 +70,12 @@ fn usage(error: &str) -> ! { eprintln!("error: {error}\n"); } eprintln!( - "usage: game-server [--port PORT] [--save FILE | --no-save] [--map MAP]\n\n\ - --port UDP port to listen on (default {})\n\ - --save persistent world file (default saves/server.rsn)\n\ - --map Tiled map relative to the assets directory (default {})", + "usage: game-server [--port PORT] [--database URL | --no-save] [--world NAME|UUID] [--map MAP]\n\n\ + --port UDP port to listen on (default {})\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 ); diff --git a/crates/game_server/src/persistence.rs b/crates/game_server/src/persistence.rs index 2ec4c0f..b9135f0 100644 --- a/crates/game_server/src/persistence.rs +++ b/crates/game_server/src/persistence.rs @@ -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, pub items: Vec, + /// All characters (online and offline), by player id. pub characters: BTreeMap, } +/// 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 { + 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 = 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, 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, 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 { + 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 { + 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 = 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::(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 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 { + 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 { + 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, + pub db: Database, + pub world: WorldInfo, + pending: Option>, +} + +impl Storage { + pub fn new(runtime: Arc, 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), 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 { - 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::().map.clone(); let mut characters = world.resource::().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::().save_path.clone() else { + if !world.contains_resource::() { 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::().save_in_background(save); +} + +/// Saves and waits until the data is written. +pub fn save_world_blocking(world: &mut World) { + if !world.contains_resource::() { + return; + } + let save = snapshot(world); + let mut storage = world.resource_mut::(); + 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::>().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, }), }); - save.items.push(SavedItem { - stack: ItemStack::new("log", 2), - pos: GridPos::new(5, 5), - }); + // More rows than one insert batch. + for i in 0..250 { + 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())); - 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()) + ); } } diff --git a/crates/game_server/src/players.rs b/crates/game_server/src/players.rs index e739da3..4f063d9 100644 --- a/crates/game_server/src/players.rs +++ b/crates/game_server/src/players.rs @@ -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(), diff --git a/crates/game_server/src/world.rs b/crates/game_server/src/world.rs index 368e463..68ca0a6 100644 --- a/crates/game_server/src/world.rs +++ b/crates/game_server/src/world.rs @@ -41,7 +41,21 @@ pub fn setup_world(mut commands: Commands, settings: Res, 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, 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 => {