From d965fbb2a145308feb090f069b6a6aa6364fc4cc Mon Sep 17 00:00:00 2001 From: Devin Dixon Date: Mon, 29 Jun 2026 08:16:25 -0500 Subject: [PATCH] feat: add hosted WebGL and PostgreSQL support Co-Authored-By: Claude --- .gitignore | 14 +- README.md | 10 +- TODO.md | 49 +- .../BlocksBeyondTheStars.Client.asmdef | 3 +- .../Editor/BuildScript.cs | 156 ++- .../BlocksBeyondTheStars/Scripts/AppShell.cs | 153 ++- .../BrowserWebSocketClientTransport.cs | 247 ++++ .../BrowserWebSocketClientTransport.cs.meta | 11 + .../Scripts/GameBootstrap.cs | 53 +- .../BlocksBeyondTheStars/Scripts/GameMenu.cs | 11 + .../Scripts/GlitchIntegration.cs | 759 ++++++++++++ .../Scripts/GlitchIntegration.cs.meta | 11 + .../Scripts/GlitchIntegrationSecrets.cs | 105 ++ .../Scripts/GlitchIntegrationSecrets.cs.meta | 11 + .../Scripts/LoadingScreen.cs | 5 + .../Scripts/LocalServerLauncher.cs | 5 + .../Scripts/MinigameCatalog.cs | 6 +- .../Scripts/StreamingAssetsCache.cs | 271 ++++ .../Scripts/StreamingAssetsCache.cs.meta | 11 + .../Scripts/UiMainMenu.cs | 8 +- .../BlocksBeyondTheStars/Scripts/WikiUI.cs | 4 +- client/Assets/Plugins/BbsWebSocket.jslib | 122 ++ client/Assets/Plugins/BbsWebSocket.jslib.meta | 26 + client/Assets/link.xml | 10 + client/Assets/link.xml.meta | 7 + client/Packages/manifest.json | 1 + client/Packages/packages-lock.json | 6 + client/ProjectSettings/ProjectSettings.asset | 12 +- data/locales/de.json | 1 + data/locales/en.json | 1 + docs/developer/ARCHITECTURE.md | 30 +- docs/developer/MINIGAMES_AND_WIKI.md | 6 +- docs/developer/README.md | 3 +- docs/developer/SELF_HOSTING.md | 50 +- docs/developer/WEBCLIENT_FEASIBILITY.md | 72 +- docs/user/USER_MANUAL.md | 10 +- .../AdminDashboard.cs | 1 + src/BlocksBeyondTheStars.Api/AdminService.cs | 48 +- .../GameServer.cs | 10 + .../Program.cs | 3 +- .../NetCodec.cs | 109 ++ .../Transport/WebSocketServerTransport.cs | 55 +- .../BlocksBeyondTheStars.Persistence.csproj | 1 + .../IWorldRepository.cs | 5 +- .../PostgreSqlWorldRepository.cs | 1097 +++++++++++++++++ .../WorldRepositoryFactory.cs | 42 + .../Configuration/GameRules.cs | 2 +- .../Configuration/ServerConfig.cs | 25 + src/BlocksBeyondTheStars.Tools/Program.cs | 38 +- .../AdminServiceTests.cs | 1 + .../BlocksBeyondTheStars.Tests.csproj | 1 + .../PostgreSqlRepositoryTests.cs | 122 ++ .../ServerConfigTests.cs | 34 + .../WebSocketTransportTests.cs | 155 +++ .../WorldOptionsTests.cs | 30 + 55 files changed, 3922 insertions(+), 117 deletions(-) create mode 100644 client/Assets/BlocksBeyondTheStars/Scripts/BrowserWebSocketClientTransport.cs create mode 100644 client/Assets/BlocksBeyondTheStars/Scripts/BrowserWebSocketClientTransport.cs.meta create mode 100644 client/Assets/BlocksBeyondTheStars/Scripts/GlitchIntegration.cs create mode 100644 client/Assets/BlocksBeyondTheStars/Scripts/GlitchIntegration.cs.meta create mode 100644 client/Assets/BlocksBeyondTheStars/Scripts/GlitchIntegrationSecrets.cs create mode 100644 client/Assets/BlocksBeyondTheStars/Scripts/GlitchIntegrationSecrets.cs.meta create mode 100644 client/Assets/BlocksBeyondTheStars/Scripts/StreamingAssetsCache.cs create mode 100644 client/Assets/BlocksBeyondTheStars/Scripts/StreamingAssetsCache.cs.meta create mode 100644 client/Assets/Plugins/BbsWebSocket.jslib create mode 100644 client/Assets/Plugins/BbsWebSocket.jslib.meta create mode 100644 client/Assets/link.xml create mode 100644 client/Assets/link.xml.meta create mode 100644 src/BlocksBeyondTheStars.Persistence/PostgreSqlWorldRepository.cs create mode 100644 src/BlocksBeyondTheStars.Persistence/WorldRepositoryFactory.cs create mode 100644 tests/BlocksBeyondTheStars.Tests/PostgreSqlRepositoryTests.cs create mode 100644 tests/BlocksBeyondTheStars.Tests/WebSocketTransportTests.cs diff --git a/.gitignore b/.gitignore index 447054f1..c7df0694 100644 --- a/.gitignore +++ b/.gitignore @@ -50,8 +50,10 @@ backups/ plans/ # Generated Unity client content (reproduce via scripts/sync-client-libs.ps1). -# Ignore the whole synced folders incl. their folder .meta so no dangling metas are tracked. -client/Assets/Plugins/ +# Ignore synced plugin payloads, but keep hand-written WebGL browser plugins versioned. +client/Assets/Plugins/* +!client/Assets/Plugins/BbsWebSocket.jslib +!client/Assets/Plugins/BbsWebSocket.jslib.meta client/Assets/Plugins.meta client/Assets/StreamingAssets/ client/Assets/StreamingAssets.meta @@ -92,6 +94,14 @@ client/schtask-build.log client/Assets/BlocksBeyondTheStars/Scripts/BugReportBuildSecrets.Generated.cs client/Assets/BlocksBeyondTheStars/Scripts/BugReportBuildSecrets.Generated.cs.meta +# Local secrets and Glitch build credentials (never committed) +.env +.env.* +!.env.example +glitch.local.env +client/Assets/BlocksBeyondTheStars/Scripts/GlitchIntegrationSecrets.Generated.cs +client/Assets/BlocksBeyondTheStars/Scripts/GlitchIntegrationSecrets.Generated.cs.meta + # Build/capture run logs (client) client/build-run.log client/capture-run.log diff --git a/README.md b/README.md index c825f526..25419615 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![GitHub stars](https://img.shields.io/github/stars/marceld23/BlocksBeyondTheStars?style=social)](https://github.com/marceld23/BlocksBeyondTheStars/stargazers) -⬇️ **[Download & Play](https://github.com/marceld23/BlocksBeyondTheStars/releases/latest)** (latest release — Windows · Linux · experimental macOS) · 🌐 [Website](https://www.blocksbeyondthestars.com/en) · ⭐ [Play on Itch.io](https://jumavegames.itch.io/blocks-beyond-the-stars) · 🎬 [Let's Play](https://youtu.be/43oAgdaT1OE) (German audio) · ⭐ [Star us on GitHub](https://github.com/marceld23/BlocksBeyondTheStars) · 🐛 [Report a Bug](CONTRIBUTING.md) +⬇️ **[Download & Play](https://github.com/marceld23/BlocksBeyondTheStars/releases/latest)** (latest release — Windows · Linux · experimental macOS) · 🌐 [Website](https://www.blocksbeyondthestars.com/en) · ⭐ **[Play on Itch.io](https://jumavegames.itch.io/blocks-beyond-the-stars)** · 🎬 [Let's Play](https://youtu.be/43oAgdaT1OE) (German audio) · ⭐ [Star us on GitHub](https://github.com/marceld23/BlocksBeyondTheStars) · 🐛 [Report a Bug](CONTRIBUTING.md) > **An experimental 3D Voxel Space Game, 100% generated by AI, driven by the imagination of a 10-year-old.** @@ -216,8 +216,8 @@ oxygen, damage, blueprints or travel. | Client | Unity 6 LTS (6000.4.x), URP + C# (Windows, Linux, experimental macOS) — see [`client/`](client/) | | Server | .NET 8, standalone console host (no Unity runtime) | | Admin UI | ASP.NET Core 8 minimal API + HTML dashboard | -| Database | SQLite (default, portable); PostgreSQL later | -| Realtime net | LiteNetLib (UDP) + MessagePack | +| Database | SQLite (default, portable); optional PostgreSQL for hosted realms | +| Realtime net | LiteNetLib UDP + MessagePack for native clients; WebSocket + JSON envelope for WebGL | | Shared logic | `netstandard2.1` so the same code runs in Unity *and* the server | ## Repository layout @@ -225,8 +225,8 @@ oxygen, damage, blueprints or travel. ``` src/BlocksBeyondTheStars.Shared/ data models, data-driven definitions, localization, protocol DTOs src/BlocksBeyondTheStars.WorldGeneration/ seed-based deterministic chunk generation -src/BlocksBeyondTheStars.Persistence/ SQLite repository, savegame layout, autosave, backups -src/BlocksBeyondTheStars.Networking/ transport abstraction (LiteNetLib + loopback), messages, codec +src/BlocksBeyondTheStars.Persistence/ SQLite/PostgreSQL repositories, savegame layout, autosave, backups +src/BlocksBeyondTheStars.Networking/ transport abstraction (LiteNetLib + WebSocket + loopback), messages, codec src/BlocksBeyondTheStars.GameServer/ authoritative tick loop + console host src/BlocksBeyondTheStars.Api/ admin web UI + API src/BlocksBeyondTheStars.Tools/ validate/info/backup CLI diff --git a/TODO.md b/TODO.md index 4c36b4b3..ecbd6a42 100644 --- a/TODO.md +++ b/TODO.md @@ -6,14 +6,14 @@ plans live under [docs/](docs/) (committed); this file is the high-level status. keep it current when controls/features change. Last consolidated 2026-06-04. **Build:** `scripts/build-client.ps1` (Windows) or `scripts/build-client.sh` (Linux) — publishes shared libs + bundled server + Unity player. -**Test:** `dotnet test` — currently **779 server + 96 client passing** (2026-06-28). Locale parity (en/de) is enforced by a test. +**Test:** `./scripts/run-tests.sh` — currently **783 server + 96 client passing** (2026-06-28). Locale parity (en/de) is enforced by a test. **Conventions:** English docs/comments; in-game text bilingual DE+EN; commit to `main` with the Claude `Co-Authored-By` trailer; OpenAI texture + ElevenLabs sound generation is blanket-approved (no per-batch gate). Architecture: Unity 6 (URP since 2026-06-10) client + authoritative .NET 8 server, everything built in -code (no scene authoring). One shared world; contractless MessagePack networking; deterministic seed -world-gen; SQLite persistence. +code (no scene authoring). One shared world; MessagePack networking for native clients plus a WebGL JSON +envelope at the WebSocket edge; deterministic seed world-gen; SQLite default persistence with opt-in PostgreSQL. --- @@ -81,6 +81,36 @@ Per-item detail lives in the dated work log below. --- +### ★ Hosted WebGL transport + optional browser platform hooks (2026-06-28) — ✅ DONE locally +Added the reusable hosted-browser path without committing provider credentials: the Unity client now has a +dormant-by-default `GlitchIntegration` (Aegis heartbeat/validation plus helper calls for scores, stats, +leaderboards, achievements and raw cloud saves), with credentials injectable only through a git-ignored generated +partial. The committed defaults are empty, so public builds never call a platform API unless a build explicitly +provides title id, token and API base URL. `BuildScript.BuildWebGL()` creates a Unity WebGL folder while +stripping stale native-server StreamingAssets from browser builds. +- **Browser play follow-up (2026-06-29):** WebGL now has a browser `IClientTransport` backed by WebSockets, a + JSON `NetCodec` envelope for IL2CPP/AOT, and a JavaScript bridge that handles `ArrayBuffer`, typed-array, + string and `Blob` frames. A local WebGL build joined a real .NET server, received authoritative chunks and + rendered a playable world. +- **Browser smoke follow-up (2026-06-29):** the HAR/log capture for the "empty world" report showed the Unity + payload and every `StreamingAssets/data` JSON loading successfully; the failure was gameplay entering the native + local-server/UDP path in a browser. Singleplayer/Host remain intentionally unavailable in WebGL, but Join now + uses the hosted WebSocket path. +- **Hosted realm prep (2026-06-29):** server persistence now keeps SQLite as the default but adds opt-in + PostgreSQL (`databaseProvider=postgresql`, `BBS_POSTGRES_CONNECTION_STRING`) through `WorldRepositoryFactory`. + The admin API/tools report/use the selected backend, and WebGL builds can embed a generated-only default + hosted server host/port without changing ordinary desktop/native builds. + Verified against Docker `postgres:16-alpine` reporting PostgreSQL 16.14 via `BBS_POSTGRES_TEST_CONNECTION_STRING`. +- **Hosted usability follow-up (2026-06-29):** the full-screen Codex/DataQubes menu screens now close reliably + with **Esc** or **Tab** before the app shell can open the leave-game prompt. Free space flight is enabled by + default and can be forced for hosted/container worlds with `BBS_FREE_FLIGHT=true`; existing worlds that saved + the old disabled value are upgraded only for that rule. +- **PR review follow-up (2026-06-29):** committed the WebGL `.jslib` bridge, fixed the `link.xml` client + assembly preserve entry, added JSON/frame-size guards plus browser-payload drop logging, and moved + provider-specific deployment scripts/README links out of this merge PR for a later platform-owned deploy PR. + +--- + ### ★ Server portal serves the native Linux + macOS client downloads (2026-06-28) — ✅ MERGED to main (#83, #107) The dedicated server's `/portal` + `/download` pages now offer the **native Linux client (AppImage)** (#83) and the **macOS client (.app zip)** (#107) alongside the Windows installer, picking the right asset per the visitor's platform. @@ -97,7 +127,7 @@ Opt-in automatic exception reporting to the website (reuses the existing Wix `po **Server:** the Tick loop is hardened (per-system `Guard` + a top-level backstop so one uncaught exception no longer takes the whole server down) + `CrashReportWriter`/`CrashReportUploader` + AppDomain/unobserved-Task handlers + startup/periodic flush. **Client:** a `CrashReporter` hooks `Application.logMessageReceivedThreaded` → dedup/spool → `FeedbackUploader`, silent, with a -startup retry. Payloads run through a shared `CrashPiiScrubber`. 779 server + 96 client tests. (Gave the user updated +startup retry. Payloads run through a shared `CrashPiiScrubber`. 783 server + 96 client tests. (Gave the user updated `http-functions.js` with triage/size-guard/rate-limit; OPTIONAL Wix CMS fields category/source/kind remain.) ### ★ Playtest report issue template (2026-06-28) — ✅ MERGED to main (#85) @@ -1527,6 +1557,17 @@ not user-moddable. Full design: [docs/developer/MINIGAMES_AND_WIKI.md](docs/deve synced + bundled into the server build). Side fix: the server now resolves `/minigames/catalog.json` (was `/../minigames/...`, which was missing on a dedicated server). Dropped the now-empty `web JS` lint job + the `javascript-typescript` CodeQL language (no JS left in the repo). +- **✅ WebGL content startup fixed locally (2026-06-29):** the first hosted WebGL smoke booted Unity/Aegis but crashed + because WebGL exposes `Application.streamingAssetsPath` as an HTTPS URL, while the shared `ContentLoader` + expects a local directory. `BuildScript.BuildWebGL()` now writes a `StreamingAssets/data/manifest.json`, the + Unity shell downloads/caches that data through `UnityWebRequest` before loading content, Wiki/Arcade read the + corrected `data/...` paths, shared content reflection metadata is preserved for IL2CPP, and the generated + WebGL `index.html` no longer auto-requests fullscreen on load. Follow-up browser smoke testing also preserved + `SphereCollider` so startup components requested by name are not stripped from WebGL. +- **✅ WebGL hosted-world rendering fixed locally (2026-06-29):** production HAR/log review confirmed content + assets loaded; the screenshot came from attempting native singleplayer/UDP gameplay in WebGL. The shell now + logs remote content/cache counts, blocks only browser-incompatible Singleplayer/Host, and routes Join through + the browser WebSocket transport with the WebGL JSON envelope. ## ▶ Open backlog — priority order (updated 2026-06-07) At-a-glance order of everything still open (new items added 2026-06-07 interleaved with the remaining diff --git a/client/Assets/BlocksBeyondTheStars/BlocksBeyondTheStars.Client.asmdef b/client/Assets/BlocksBeyondTheStars/BlocksBeyondTheStars.Client.asmdef index 16cef4a6..38c6d760 100644 --- a/client/Assets/BlocksBeyondTheStars/BlocksBeyondTheStars.Client.asmdef +++ b/client/Assets/BlocksBeyondTheStars/BlocksBeyondTheStars.Client.asmdef @@ -3,7 +3,8 @@ "rootNamespace": "BlocksBeyondTheStars.Client", "references": [ "Unity.RenderPipelines.Universal.Runtime", - "Unity.RenderPipelines.Core.Runtime" + "Unity.RenderPipelines.Core.Runtime", + "UnityEngine.UnityWebRequestModule" ], "includePlatforms": [], "excludePlatforms": [], diff --git a/client/Assets/BlocksBeyondTheStars/Editor/BuildScript.cs b/client/Assets/BlocksBeyondTheStars/Editor/BuildScript.cs index 9a9e9072..d0bbcb33 100644 --- a/client/Assets/BlocksBeyondTheStars/Editor/BuildScript.cs +++ b/client/Assets/BlocksBeyondTheStars/Editor/BuildScript.cs @@ -2,7 +2,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text. #if UNITY_EDITOR +using System; +using System.Collections.Generic; using System.IO; +using System.Reflection; +using System.Text; using BlocksBeyondTheStars.Client; using UnityEditor; using UnityEditor.Build; @@ -69,12 +73,23 @@ public static void BuildLinux() public static void BuildMacOS() => BuildPlayer(BuildTarget.StandaloneOSX, "BlocksBeyondTheStars.app", "Build/macOS"); + /// Builds the WebGL player folder for browser deployment. The browser path uses WebSockets + /// against a hosted authoritative server, so this target keeps browser-client packaging repeatable. + [MenuItem("BlocksBeyondTheStars/Build WebGL Player")] + public static void BuildWebGL() + => BuildPlayer(BuildTarget.WebGL, string.Empty, "Build/WebGL"); + private static void BuildPlayer(BuildTarget target, string exeName, string defaultOutDir) { EnsureLauncherScene(); EnsureShadersIncluded(); EnsureRendererFeatures(); EnsureAppIcon(); + if (target == BuildTarget.WebGL) + { + ConfigureWebGLPlayer(); + EnsureStreamingAssetsManifest(); + } string version = GetArg("-buildVersion"); if (!string.IsNullOrEmpty(version)) @@ -96,11 +111,12 @@ private static void BuildPlayer(BuildTarget target, string exeName, string defau string outDir = GetArg("-buildOut") ?? defaultOutDir; Directory.CreateDirectory(outDir); + string locationPathName = target == BuildTarget.WebGL ? outDir : Path.Combine(outDir, exeName); var options = new BuildPlayerOptions { scenes = new[] { ScenePath }, - locationPathName = Path.Combine(outDir, exeName), + locationPathName = locationPathName, target = target, options = BuildOptions.None, }; @@ -116,9 +132,145 @@ private static void BuildPlayer(BuildTarget target, string exeName, string defau EditorApplication.Exit(1); } + if (target == BuildTarget.WebGL) + { + RemoveAutoFullscreen(outDir); + } + File.WriteAllText(Path.Combine(outDir, "version.txt"), PlayerSettings.bundleVersion); } + /// Best-effort WebGL production defaults. Reflection keeps this resilient across Unity 6 patch + /// API shuffles while still applying Brotli + 512 MB heap when those properties are available. + public static void ConfigureWebGLPlayer() + { + bool fastLocal = string.Equals(Environment.GetEnvironmentVariable("BBS_WEBGL_FAST_LOCAL"), "1", StringComparison.OrdinalIgnoreCase) + || string.Equals(Environment.GetEnvironmentVariable("BBS_WEBGL_FAST_LOCAL"), "true", StringComparison.OrdinalIgnoreCase); + + EditorUserBuildSettings.development = false; + PlayerSettings.SetManagedStrippingLevel(NamedBuildTarget.WebGL, ManagedStrippingLevel.Low); + SetWebGLProperty("memorySize", 512); + SetWebGLProperty("compressionFormat", fastLocal ? "Disabled" : "Brotli"); + SetWebGLProperty("decompressionFallback", !fastLocal); + SetWebGLProperty("dataCaching", true); + if (fastLocal) + { + Debug.Log("BBS_WEBGL_FAST_LOCAL enabled: WebGL build compression disabled for local browser verification."); + } + } + + private static void EnsureStreamingAssetsManifest() + { + string dataRoot = Path.Combine(Application.dataPath, "StreamingAssets", "data"); + if (!Directory.Exists(dataRoot)) + { + Debug.LogWarning($"StreamingAssets data folder not found at {dataRoot}; WebGL content manifest skipped."); + return; + } + + var files = new List(); + foreach (string file in Directory.GetFiles(dataRoot, "*", SearchOption.AllDirectories)) + { + if (file.EndsWith(".meta", StringComparison.OrdinalIgnoreCase) + || string.Equals(Path.GetFileName(file), "manifest.json", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string relative = file.Substring(dataRoot.Length + 1) + .Replace(Path.DirectorySeparatorChar, '/') + .Replace(Path.AltDirectorySeparatorChar, '/'); + files.Add(relative); + } + + files.Sort(StringComparer.Ordinal); + + var json = new StringBuilder(); + json.AppendLine("{"); + json.AppendLine(" \"files\": ["); + for (int i = 0; i < files.Count; i++) + { + json.Append(" \"").Append(EscapeJson(files[i])).Append('"'); + if (i < files.Count - 1) + { + json.Append(','); + } + + json.AppendLine(); + } + + json.AppendLine(" ]"); + json.AppendLine("}"); + + string manifestPath = Path.Combine(dataRoot, "manifest.json"); + File.WriteAllText(manifestPath, json.ToString()); + AssetDatabase.ImportAsset("Assets/StreamingAssets/data/manifest.json", ImportAssetOptions.ForceUpdate); + Debug.Log($"WebGL StreamingAssets manifest written with {files.Count} files."); + } + + private static void RemoveAutoFullscreen(string outDir) + { + string indexPath = Path.Combine(outDir, "index.html"); + if (!File.Exists(indexPath)) + { + Debug.LogWarning($"WebGL index.html not found at {indexPath}; fullscreen patch skipped."); + return; + } + + string[] lines = File.ReadAllLines(indexPath); + var kept = new List(lines.Length); + bool changed = false; + foreach (string line in lines) + { + if (line.Trim() == "unityInstance.SetFullscreen(1);") + { + changed = true; + continue; + } + + kept.Add(line); + } + + if (changed) + { + File.WriteAllLines(indexPath, kept); + Debug.Log("Removed generated WebGL auto-fullscreen call; fullscreen remains available from the button."); + } + } + + private static string EscapeJson(string value) + => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static void SetWebGLProperty(string propertyName, object value) + { + Type webGlType = typeof(PlayerSettings).GetNestedType("WebGL", BindingFlags.Public | BindingFlags.NonPublic); + var property = webGlType?.GetProperty(propertyName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + if (property == null || !property.CanWrite) + { + Debug.Log($"WebGL PlayerSettings.{propertyName} is not available in this Unity version; leaving default."); + return; + } + + try + { + object converted = value; + if (property.PropertyType.IsEnum) + { + converted = Enum.Parse(property.PropertyType, value.ToString()); + } + else if (property.PropertyType != value.GetType()) + { + converted = Convert.ChangeType(value, property.PropertyType); + } + + property.SetValue(null, converted, null); + } + catch (Exception ex) + { + Debug.LogWarning($"Could not set WebGL PlayerSettings.{propertyName}: {ex.Message}"); + } + } + /// /// Adds the runtime shaders to GraphicsSettings' "Always Included /// Shaders" so the player build keeps them (otherwise Shader.Find returns null at runtime and @@ -196,7 +348,7 @@ public static void EnsureRendererFeatures() if (f != null) { AssetDatabase.RemoveObjectFromAsset(f); - Object.DestroyImmediate(f, true); + UnityEngine.Object.DestroyImmediate(f, true); } changed = true; diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/AppShell.cs b/client/Assets/BlocksBeyondTheStars/Scripts/AppShell.cs index 12f4b60e..313673ee 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/AppShell.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/AppShell.cs @@ -1,6 +1,7 @@ // Blocks Beyond the Stars — Copyright (c) 2026 Justus Dütscher & Marcel Dütscher (JuMaVe Games) // SPDX-License-Identifier: AGPL-3.0-or-later // This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text. +using System.Collections; using System.IO; using BlocksBeyondTheStars.Shared.Content; using BlocksBeyondTheStars.Shared.Localization; @@ -47,6 +48,19 @@ public sealed class AppShell : MonoBehaviour /// One-shot notice shown on the main menu (e.g. why the last join was refused). public string MenuNotice = ""; + /// Browser builds cannot launch the bundled native server, but they can join a hosted WebSocket server. + public static bool BrowserLocalServerBlocked + { + get + { +#if UNITY_WEBGL && !UNITY_EDITOR + return true; +#else + return false; +#endif + } + } + private SplashScreen _splash; private StudioSplash _studio; private LoadingScreen _loading; @@ -57,7 +71,10 @@ public sealed class AppShell : MonoBehaviour private System.Threading.Tasks.Task _serverLaunch; // the off-thread spawn (so Process.Start can't freeze us) private GameObject _gameRoot; + public bool ContentReady { get; private set; } + private bool _splashSoundDone; + private bool _autoJoinWhenReady; private void Awake() { @@ -71,25 +88,86 @@ private void Awake() crashGo.AddComponent().Settings = Settings; InputMap.Use(Settings); // route remappable controls through the loaded bindings (Stream C) Settings.Apply(); - LoadLocalizer(); + if (StreamingAssetsCache.UsesRemoteStreamingAssets) + { + StartCoroutine(LoadContentForStartup()); + } + else + { + StreamingAssetsCache.EnsureLocalReady(); + LoadLocalizer(); + } + if (!string.IsNullOrWhiteSpace(Settings.PlayerName)) { PlayerName = Settings.PlayerName.Trim(); } + ApplyGlitchServerDefaults(); + ConfigureOptionalWebAutoJoin(); + // The 3D renders at native resolution (crisp on 4K); the IMGUI UI keeps a readable // physical size via UiScale (virtual 1080p layout) instead of a blunt resolution cap. _splash = new SplashScreen(this); _studio = new StudioSplash(this); _loading = new LoadingScreen(this); - EnsureMenuBackground(); + GlitchIntegration.InstallIfConfigured(); + if (ContentReady) + { + EnsureMenuBackground(); + } // Persistent background-music director: spans splash → menu → loading → in-game so the shell // screens get music too, and cross-fades context tracks (synth or the AI track library). gameObject.AddComponent().Shell = this; } + private void ConfigureOptionalWebAutoJoin() + { +#if UNITY_WEBGL && !UNITY_EDITOR + _autoJoinWhenReady = GlitchIntegration.AutoJoinRequested; + string playerName = GlitchIntegration.AutoJoinPlayerName; + if (!string.IsNullOrWhiteSpace(playerName)) + { + PlayerName = playerName.Trim(); + } +#endif + } + + private void ApplyGlitchServerDefaults() + { +#if UNITY_WEBGL && !UNITY_EDITOR + if (!GlitchIntegration.TryGetConfiguredServer(out string host, out string port, out string password)) + { + return; + } + + Host = host; + if (!string.IsNullOrWhiteSpace(port)) + { + Port = port; + } + + Password = password ?? string.Empty; + Debug.Log($"[Glitch] Applied WebGL server defaults: {Host}:{Port}."); +#endif + } + + private IEnumerator LoadContentForStartup() + { + System.Exception failure = null; + yield return StreamingAssetsCache.EnsureReady(ex => failure = ex); + if (failure != null) + { + Debug.LogError($"Content startup failed: {failure.Message}"); + yield break; + } + + LoadLocalizer(); + EnsureMenuBackground(); + } + /// /// One-time migration for the game rename: the old install used "Spacecraft" as the Unity /// productName changed, which moved to a new @@ -147,6 +225,11 @@ private static void MigrateRenamedPersistentData() /// Spawns the animated space-scene backdrop shown behind the shell screens. private void EnsureMenuBackground() { + if (!ContentReady) + { + return; + } + if (_menuBackground == null) { _menuBackground = new GameObject("MenuBackground"); @@ -208,10 +291,23 @@ public void PlayStudioSting() /// (Re)loads content and the localizer for the currently selected language. public void LoadLocalizer() { - string dataDir = Path.Combine(Application.streamingAssetsPath, "data"); + if (!StreamingAssetsCache.IsReady) + { + if (StreamingAssetsCache.UsesRemoteStreamingAssets) + { + Debug.LogWarning("Remote StreamingAssets content is not ready yet."); + return; + } + + StreamingAssetsCache.EnsureLocalReady(); + } + + string dataDir = StreamingAssetsCache.DataDir; Content = ContentLoader.LoadFromDirectory(dataDir); + Debug.Log($"Content loaded from '{dataDir}' ({Content.Blocks.Count} blocks, {Content.Items.Count} items, {Content.Recipes.Count} recipes, {Content.Planets.Count} planet types)."); var locale = Settings.Language == "de" ? GameLocale.German : GameLocale.English; Localizer = Content.CreateLocalizer(locale); + ContentReady = true; } /// Localize, falling back to the key before content is loaded. @@ -250,6 +346,11 @@ public void CloseSettings() /// Opens the singleplayer world picker (choose an existing save or start a new one). public void StartSingleplayer() { + if (ShowBrowserLocalServerBlockedNotice()) + { + return; + } + HostMode = false; Phase = ShellPhase.SaveSelect; } @@ -257,6 +358,11 @@ public void StartSingleplayer() /// Opens the world picker in host mode (any singleplayer save can be hosted, "open to LAN" style). public void StartHost() { + if (ShowBrowserLocalServerBlockedNotice()) + { + return; + } + HostMode = true; Phase = ShellPhase.SaveSelect; } @@ -283,6 +389,11 @@ private void StartLocalWorld(string worldName, long seed, bool creativeUnlockAll, bool creativeAllShips, bool creativeKit, WorldCreationOptions worldOptions, int maxPlayers, string password) { + if (ShowBrowserLocalServerBlockedNotice()) + { + return; + } + // Singleplayer AND in-game hosting run the bundled dedicated server as a child process // (Option A), then connect to it like any other server; hosting just opens the player cap. bool hosting = maxPlayers > 1; @@ -357,6 +468,26 @@ public void StartJoin() Phase = ShellPhase.Loading; } + public bool ShowBrowserLocalServerBlockedNotice() + { + if (!BrowserLocalServerBlocked) + { + return false; + } + + MenuNotice = L("ui.webgl.gameplay_blocked"); + HostInfo = ""; + _serverPending = false; + Phase = ShellPhase.MainMenu; + if (_uiMenu != null) + { + Destroy(_uiMenu); + _uiMenu = null; + } + + return true; + } + public void Quit() { StopLocalServer(); @@ -388,6 +519,12 @@ private void StopLocalServer() /// Builds the in-game rig (player + camera + world + HUD) and enters play. public void LaunchGame() { + if (!ContentReady) + { + Debug.LogWarning("Game launch delayed until bundled content is ready."); + return; + } + DestroyMenuBackground(); _gameRoot = WorldRig.Build(this); CurrentBoot = Boot(); // hand the live world to the music director @@ -585,6 +722,12 @@ private void Update() UiSound.Volume = Mathf.Clamp01(Settings.MasterVolume * Settings.SfxVolume) * 0.6f; } + if (_autoJoinWhenReady && ContentReady && Phase == ShellPhase.MainMenu) + { + _autoJoinWhenReady = false; + StartJoin(); + } + // The main menu + loading are uGUI (M27): spawn each for its phase, tear it down otherwise. if (Phase == ShellPhase.MainMenu && _uiMenu == null) { @@ -693,6 +836,10 @@ private void Update() { CancelQuit(); // Esc again dismisses the confirmation } + else if (boot != null && (boot.MenuOpen || boot.MenuInputHandledThisFrame)) + { + // The in-game menu or one of its browser screens owns this Esc press. + } else { // Ask before leaving the game (rather than quitting instantly). diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/BrowserWebSocketClientTransport.cs b/client/Assets/BlocksBeyondTheStars/Scripts/BrowserWebSocketClientTransport.cs new file mode 100644 index 00000000..5885b0f5 --- /dev/null +++ b/client/Assets/BlocksBeyondTheStars/Scripts/BrowserWebSocketClientTransport.cs @@ -0,0 +1,247 @@ +// Blocks Beyond the Stars — Copyright (c) 2026 Justus Dütscher & Marcel Dütscher (JuMaVe Games) +// SPDX-License-Identifier: AGPL-3.0-or-later +// This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text. +#if UNITY_WEBGL && !UNITY_EDITOR +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using BlocksBeyondTheStars.Networking; +using BlocksBeyondTheStars.Networking.Transport; +using UnityEngine; + +namespace BlocksBeyondTheStars.Client +{ + /// + /// WebGL client transport. Browsers cannot use the native LiteNetLib UDP socket, so the + /// WebGL player talks to the authoritative .NET server through a browser WebSocket while + /// still exchanging the same NetCodec payloads as native clients. + /// + public sealed class BrowserWebSocketClientTransport : IClientTransport + { + private readonly ConcurrentQueue _events = new(); + private int _socketId; + private bool _disposed; + + public event Action Connected; + public event Action Disconnected; + public event Action PayloadReceived; + + [DllImport("__Internal")] + private static extern void BbsWsConnect(int id, string url); + + [DllImport("__Internal")] + private static extern void BbsWsSendBase64(int id, string payload); + + [DllImport("__Internal")] + private static extern void BbsWsDisconnect(int id); + + public void Connect(string host, int port) + { + if (_disposed) + { + return; + } + + NetCodec.UseJsonEncoding = true; + Disconnect(); + _socketId = BrowserWebSocketBridge.Register(this); + string url = BuildWebSocketUrl(host, port); + Debug.Log($"Connecting to browser WebSocket game server: {url}"); + BbsWsConnect(_socketId, url); + } + + public void Send(byte[] payload, DeliveryMode mode) + { + if (_disposed || _socketId == 0 || payload == null || payload.Length == 0) + { + return; + } + + // The JavaScript bridge sends binary WebSocket frames. Base64 is only the + // WebGL interop envelope between managed C# and the browser runtime. + BbsWsSendBase64(_socketId, Convert.ToBase64String(payload)); + } + + public void Poll() + { + while (_events.TryDequeue(out var action)) + { + action(); + } + } + + public void Disconnect() + { + if (_socketId == 0) + { + return; + } + + int id = _socketId; + _socketId = 0; + BrowserWebSocketBridge.Unregister(id); + BbsWsDisconnect(id); + } + + public void Dispose() + { + _disposed = true; + Disconnect(); + } + + internal void QueueConnected() + => _events.Enqueue(() => Connected?.Invoke()); + + internal void QueueDisconnected() + => _events.Enqueue(() => Disconnected?.Invoke()); + + internal void QueuePayload(string base64) + { + if (string.IsNullOrEmpty(base64)) + { + return; + } + + byte[] payload; + try + { + payload = Convert.FromBase64String(base64); + } + catch (FormatException ex) + { + Debug.LogWarning($"Dropped malformed WebSocket payload: {ex.Message}"); + return; + } + + _events.Enqueue(() => PayloadReceived?.Invoke(payload)); + } + + internal void QueueError(string message) + => _events.Enqueue(() => Debug.LogWarning("Browser WebSocket error: " + message)); + + private static string BuildWebSocketUrl(string host, int port) + { + host = string.IsNullOrWhiteSpace(host) ? "127.0.0.1" : host.Trim(); + if (host.StartsWith("wss://", StringComparison.OrdinalIgnoreCase) + || host.StartsWith("ws://", StringComparison.OrdinalIgnoreCase)) + { + return host; + } + + if (host.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return "wss://" + host.Substring("https://".Length).TrimEnd('/'); + } + + if (host.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + { + return "ws://" + host.Substring("http://".Length).TrimEnd('/'); + } + + string scheme = Application.absoluteURL.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + ? "wss" + : "ws"; + + bool omitPort = port <= 0 + || (scheme == "wss" && port == 443) + || (scheme == "ws" && port == 80); + return omitPort ? $"{scheme}://{host}" : $"{scheme}://{host}:{port}"; + } + + private sealed class BrowserWebSocketBridge : MonoBehaviour + { + private const string GameObjectName = "BbsWebSocketBridge"; + private static readonly Dictionary Transports = new(); + private static BrowserWebSocketBridge _instance; + private static int _nextId; + + public static int Register(BrowserWebSocketClientTransport transport) + { + EnsureInstance(); + int id = ++_nextId; + Transports[id] = transport; + return id; + } + + public static void Unregister(int id) + { + if (id != 0) + { + Transports.Remove(id); + } + } + + public void HandleOpen(string idText) + { + if (TryGet(idText, out var transport)) + { + transport.QueueConnected(); + } + } + + public void HandleClose(string idText) + { + if (TryGet(idText, out var transport)) + { + transport.QueueDisconnected(); + } + } + + public void HandleError(string payload) + { + Split(payload, out int id, out string message); + if (Transports.TryGetValue(id, out var transport)) + { + transport.QueueError(message); + } + } + + public void HandleMessage(string payload) + { + Split(payload, out int id, out string message); + if (Transports.TryGetValue(id, out var transport)) + { + transport.QueuePayload(message); + } + } + + private static void EnsureInstance() + { + if (_instance != null) + { + return; + } + + var go = new GameObject(GameObjectName); + DontDestroyOnLoad(go); + _instance = go.AddComponent(); + } + + private static bool TryGet(string idText, out BrowserWebSocketClientTransport transport) + { + if (int.TryParse(idText, out int id) && Transports.TryGetValue(id, out transport)) + { + return true; + } + + transport = null; + return false; + } + + private static void Split(string payload, out int id, out string message) + { + int separator = string.IsNullOrEmpty(payload) ? -1 : payload.IndexOf('|'); + if (separator < 0 || !int.TryParse(payload.Substring(0, separator), out id)) + { + id = 0; + message = string.Empty; + return; + } + + message = payload.Substring(separator + 1); + } + } + } +} +#endif diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/BrowserWebSocketClientTransport.cs.meta b/client/Assets/BlocksBeyondTheStars/Scripts/BrowserWebSocketClientTransport.cs.meta new file mode 100644 index 00000000..9f46633c --- /dev/null +++ b/client/Assets/BlocksBeyondTheStars/Scripts/BrowserWebSocketClientTransport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 26e1e78165534b11b7ff2acce744c9e3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/GameBootstrap.cs b/client/Assets/BlocksBeyondTheStars/Scripts/GameBootstrap.cs index 0eef0626..247ae52d 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/GameBootstrap.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/GameBootstrap.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.IO; using BlocksBeyondTheStars.Networking.Messages; +using BlocksBeyondTheStars.Networking.Transport; using BlocksBeyondTheStars.Shared.Content; using BlocksBeyondTheStars.Shared.Geometry; using BlocksBeyondTheStars.Shared.Localization; @@ -263,6 +264,12 @@ public bool CanUseBeam(NetBeam b) public MissionList Missions { get; private set; } public ServerRules Rules { get; private set; } + /// Frame marker used when the in-game menu consumes Esc/Tab, so the app shell + /// does not also open the quit prompt on the same key press. + public int MenuInputHandledFrame { get; private set; } = -1; + public bool MenuInputHandledThisFrame => MenuInputHandledFrame == Time.frameCount; + public void MarkMenuInputHandled() => MenuInputHandledFrame = Time.frameCount; + /// This player's alliance roster (mutual allies + pending requests), for the Alliances menu tab. public AllianceList Alliances { get; private set; } = new AllianceList(); @@ -822,12 +829,25 @@ private sealed class ChunkView public int WorldEpoch { get; private set; } private bool _joinSent; + private bool _joinSendFailureLogged; private float _retryTimer; private int _retries; private void Start() { - string dataDir = Path.Combine(Application.streamingAssetsPath, "data"); + if (!StreamingAssetsCache.IsReady && !StreamingAssetsCache.UsesRemoteStreamingAssets) + { + StreamingAssetsCache.EnsureLocalReady(); + } + + if (!StreamingAssetsCache.IsReady) + { + Debug.LogError("Bundled content is not ready; aborting game bootstrap."); + enabled = false; + return; + } + + string dataDir = StreamingAssetsCache.DataDir; Content = ContentLoader.LoadFromDirectory(dataDir); Localizer = Content.CreateLocalizer(German ? GameLocale.German : GameLocale.English); World = new ClientWorld(); @@ -862,7 +882,11 @@ private void Start() ? (Atlas.Texture, Atlas.TileUv(b.NumericId.Value)) : null; +#if UNITY_WEBGL && !UNITY_EDITOR + Network = new NetworkClient(new BrowserWebSocketClientTransport()); +#else Network = new NetworkClient(); +#endif Network.JoinAccepted += m => { LocalPlayerId = m.PlayerId; @@ -1160,15 +1184,28 @@ private void Update() { if (Network.Connected) { - Network.Join(PlayerName, string.IsNullOrEmpty(Password) ? null : Password, German ? "de" : "en", - string.IsNullOrEmpty(Token) ? null : Token, ViewDistanceChunks); - Network.SendAppearance(SkinRgb, TorsoRgb, ArmRgb, LegRgb, HullRgb); - if (!string.IsNullOrEmpty(FacePixels)) + try { - Network.SendFace(FacePixels); // tell others our custom face (server persists + relays) - } + Debug.Log($"Sending join request to {Host}:{Port} as {PlayerName}."); + Network.Join(PlayerName, string.IsNullOrEmpty(Password) ? null : Password, German ? "de" : "en", + string.IsNullOrEmpty(Token) ? null : Token, ViewDistanceChunks); + Network.SendAppearance(SkinRgb, TorsoRgb, ArmRgb, LegRgb, HullRgb); + if (!string.IsNullOrEmpty(FacePixels)) + { + Network.SendFace(FacePixels); // tell others our custom face (server persists + relays) + } - _joinSent = true; + _joinSent = true; + _joinSendFailureLogged = false; + } + catch (System.Exception ex) + { + if (!_joinSendFailureLogged) + { + Debug.LogError($"Join request could not be encoded or sent: {ex}"); + _joinSendFailureLogged = true; + } + } } else { diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/GameMenu.cs b/client/Assets/BlocksBeyondTheStars/Scripts/GameMenu.cs index aefbdd88..cb719c2c 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/GameMenu.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/GameMenu.cs @@ -50,11 +50,22 @@ private void Update() _wasInSpaceView = Game.SpaceViewActive; + // Full-screen menu/browser panes must be escapable before the app shell sees Esc + // as "leave game", otherwise the player can get trapped behind overlapping modals. + if (_open && Input.GetKeyDown(KeyCode.Escape) && !Game.ChatTyping) + { + Game.MarkMenuInputHandled(); + SetOpen(false); + return; + } + // Don't let Tab open the menu while the death / ship-destruction prompt is up — only its // "Weiter" button proceeds. if (Input.GetKeyDown(KeyCode.Tab) && !Game.AwaitingRespawnConfirm && !Game.ChatTyping) { + Game.MarkMenuInputHandled(); SetOpen(!_open); + return; } } diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/GlitchIntegration.cs b/client/Assets/BlocksBeyondTheStars/Scripts/GlitchIntegration.cs new file mode 100644 index 00000000..c4618276 --- /dev/null +++ b/client/Assets/BlocksBeyondTheStars/Scripts/GlitchIntegration.cs @@ -0,0 +1,759 @@ +// Blocks Beyond the Stars — Copyright (c) 2026 Justus Dütscher & Marcel Dütscher (JuMaVe Games) +// SPDX-License-Identifier: AGPL-3.0-or-later +// This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text. +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using BlocksBeyondTheStars.Build; +using UnityEngine; +using UnityEngine.Networking; + +namespace BlocksBeyondTheStars.Client +{ + /// + /// Optional Glitch Aegis/deployment API bridge. It is dormant in normal open-source builds and only starts + /// when a generated secret partial or local environment variables explicitly enable it. + /// + public sealed class GlitchIntegration : MonoBehaviour + { + private const float HeartbeatSeconds = 60f; + private static GlitchIntegration _instance; + + private string _apiBaseUrl; + private string _titleId; + private string _titleToken; + private string _installId; + private string _sessionId; + private bool _paused; + private bool _online; + + public static bool IsConfigured => TryReadConfig(out _, out _, out _, out _, out _); + public static bool IsOnline => _instance != null && _instance._online; + public static string InstallId => _instance == null ? string.Empty : _instance._installId; + + public static bool AutoJoinRequested + => IsTruthy(FirstNonEmpty( + ReadQueryValue(Application.absoluteURL, "bbs_auto_join"), + ReadQueryValue(Application.absoluteURL, "auto_join"), + GetEnv("BBS_AUTO_JOIN"))); + + public static string AutoJoinPlayerName + => FirstNonEmpty( + ReadQueryValue(Application.absoluteURL, "player_name"), + ReadQueryValue(Application.absoluteURL, "bbs_player_name"), + GetEnv("BBS_PLAYER_NAME")); + + public static bool TryGetConfiguredServer(out string host, out string port, out string password) + { + host = FirstNonEmpty( + ReadQueryValue(Application.absoluteURL, "server_host"), + ReadQueryValue(Application.absoluteURL, "game_server_host"), + GlitchIntegrationSecrets.ServerHost, + GetEnv("GLITCH_SERVER_HOST")); + port = FirstNonEmpty( + ReadQueryValue(Application.absoluteURL, "server_port"), + ReadQueryValue(Application.absoluteURL, "game_server_port"), + GlitchIntegrationSecrets.ServerPort, + GetEnv("GLITCH_SERVER_PORT")); + password = FirstNonEmpty( + ReadQueryValue(Application.absoluteURL, "server_password"), + ReadQueryValue(Application.absoluteURL, "game_server_password"), + GlitchIntegrationSecrets.ServerPassword, + GetEnv("GLITCH_SERVER_PASSWORD")); + + return IsConfigured && !string.IsNullOrWhiteSpace(host); + } + + public static void InstallIfConfigured() + { + if (_instance != null || !IsConfigured) + { + return; + } + + var go = new GameObject("GlitchIntegration"); + DontDestroyOnLoad(go); + _instance = go.AddComponent(); + } + + public static bool TrySubmitScore(string boardKey, double score, IDictionary metadata = null, Action onComplete = null) + { + if (!CanUseApi(onComplete)) + { + return false; + } + + var scores = new Dictionary { [boardKey] = score }; + _instance.StartCoroutine(_instance.SubmitProgress(scores, null, metadata, onComplete)); + return true; + } + + public static bool TrySubmitStats(IDictionary stats, IDictionary metadata = null, Action onComplete = null) + { + if (!CanUseApi(onComplete) || stats == null || stats.Count == 0) + { + onComplete?.Invoke(false, "No stats were supplied."); + return false; + } + + _instance.StartCoroutine(_instance.SubmitProgress(null, stats, metadata, onComplete)); + return true; + } + + public static bool TryGetLeaderboard(string boardKey, bool aroundMe, Action onComplete, string seasonId = null) + { + if (!CanUseApi(onComplete)) + { + return false; + } + + _instance.StartCoroutine(_instance.GetLeaderboard(boardKey, aroundMe, seasonId, onComplete)); + return true; + } + + public static bool TryGetAchievements(Action onComplete) + { + if (!CanUseApi(onComplete)) + { + return false; + } + + string url = _instance.InstallUrl("achievements"); + _instance.StartCoroutine(_instance.SendGet(url, onComplete)); + return true; + } + + public static bool TryListCloudSaves(Action onComplete) + { + if (!CanUseApi(onComplete)) + { + return false; + } + + string url = _instance.InstallUrl("saves"); + _instance.StartCoroutine(_instance.SendGet(url, onComplete)); + return true; + } + + public static bool TryUploadCloudSave( + int slotIndex, + byte[] rawPayload, + int baseVersion, + string saveType, + string slotName, + string metadataJson, + long playDurationSeconds, + Action onComplete = null) + { + if (!CanUseApi(onComplete)) + { + return false; + } + + if (slotIndex < 0 || slotIndex > 99 || rawPayload == null || rawPayload.Length == 0) + { + onComplete?.Invoke(false, "Invalid cloud-save payload."); + return false; + } + + _instance.StartCoroutine(_instance.UploadCloudSave( + slotIndex, + rawPayload, + baseVersion, + saveType, + slotName, + metadataJson, + playDurationSeconds, + onComplete)); + return true; + } + + private void Awake() + { + if (_instance != null && _instance != this) + { + Destroy(gameObject); + return; + } + + _instance = this; + DontDestroyOnLoad(gameObject); + + if (!TryReadConfig(out _apiBaseUrl, out _titleId, out _titleToken, out string testInstallId, out _sessionId)) + { + enabled = false; + return; + } + + _installId = ResolveInstallId(testInstallId); + if (string.IsNullOrWhiteSpace(_installId)) + { + Debug.Log("[Glitch] Optional integration is configured, but no install_id was provided."); + enabled = false; + } + } + + private void Start() + { + if (enabled) + { + StartCoroutine(HeartbeatLoop()); + } + } + + private void OnApplicationPause(bool pauseStatus) => _paused = pauseStatus; + private void OnApplicationFocus(bool hasFocus) => _paused = !hasFocus; + + private static bool CanUseApi(Action onComplete) + { + if (_instance != null && _instance.enabled && !string.IsNullOrWhiteSpace(_instance._installId)) + { + return true; + } + + onComplete?.Invoke(false, "Glitch integration is not configured for this build/session."); + return false; + } + + private IEnumerator HeartbeatLoop() + { + yield return SendHeartbeat(); + yield return ValidateInstall(); + + while (enabled) + { + float elapsed = 0f; + while (elapsed < HeartbeatSeconds) + { + elapsed += Time.unscaledDeltaTime; + yield return null; + } + + if (!_paused) + { + yield return SendHeartbeat(); + } + } + } + + private IEnumerator SendHeartbeat() + { + string body = BuildHeartbeatJson(); + using (var request = CreateJsonRequest(TitleUrl("installs"), "POST", body)) + { + yield return request.SendWebRequest(); + _online = IsSuccess(request); + if (!_online) + { + Debug.LogWarning("[Glitch] Install heartbeat failed: " + DescribeFailure(request)); + } + } + } + + private IEnumerator ValidateInstall() + { + using (var request = CreateJsonRequest(InstallUrl("validate"), "POST", "{}")) + { + yield return request.SendWebRequest(); + _online = IsSuccess(request); + if (!_online) + { + Debug.LogWarning("[Glitch] Install validation failed: " + DescribeFailure(request)); + } + } + } + + private IEnumerator SubmitProgress(IDictionary scores, IDictionary stats, IDictionary metadata, Action onComplete) + { + string json = BuildSubmitJson(scores, stats, metadata); + using (var request = CreateJsonRequest(InstallUrl("submit"), "POST", json)) + { + yield return request.SendWebRequest(); + Complete(request, onComplete); + } + } + + private IEnumerator GetLeaderboard(string boardKey, bool aroundMe, string seasonId, Action onComplete) + { + var url = new StringBuilder(TitleUrl("leaderboards/" + UrlEncode(boardKey))); + bool hasQuery = false; + if (aroundMe) + { + url.Append("?around_me=true&install_id=").Append(UrlEncode(_installId)); + hasQuery = true; + } + + if (!string.IsNullOrWhiteSpace(seasonId)) + { + url.Append(hasQuery ? '&' : '?').Append("season_id=").Append(UrlEncode(seasonId)); + } + + yield return SendGet(url.ToString(), onComplete); + } + + private IEnumerator SendGet(string url, Action onComplete) + { + using (var request = CreateRequest(url, "GET")) + { + yield return request.SendWebRequest(); + Complete(request, onComplete); + } + } + + private IEnumerator UploadCloudSave( + int slotIndex, + byte[] rawPayload, + int baseVersion, + string saveType, + string slotName, + string metadataJson, + long playDurationSeconds, + Action onComplete) + { + string json = BuildCloudSaveJson(slotIndex, rawPayload, baseVersion, saveType, slotName, metadataJson, playDurationSeconds); + using (var request = CreateJsonRequest(InstallUrl("saves"), "POST", json)) + { + yield return request.SendWebRequest(); + Complete(request, onComplete); + } + } + + private UnityWebRequest CreateJsonRequest(string url, string method, string body) + { + var request = CreateRequest(url, method); + byte[] bytes = Encoding.UTF8.GetBytes(body); + request.uploadHandler = new UploadHandlerRaw(bytes); + request.SetRequestHeader("Content-Type", "application/json"); + return request; + } + + private UnityWebRequest CreateRequest(string url, string method) + { + var request = new UnityWebRequest(url, method) + { + downloadHandler = new DownloadHandlerBuffer(), + timeout = 15, + }; + request.SetRequestHeader("Accept", "application/json"); + request.SetRequestHeader("Authorization", "Bearer " + _titleToken); + return request; + } + + private void Complete(UnityWebRequest request, Action onComplete) + { + bool ok = IsSuccess(request); + string response = request.downloadHandler == null ? string.Empty : request.downloadHandler.text; + if (!ok && string.IsNullOrWhiteSpace(response)) + { + response = DescribeFailure(request); + } + + onComplete?.Invoke(ok, response); + } + + private static bool TryReadConfig(out string apiBaseUrl, out string titleId, out string titleToken, out string testInstallId, out string sessionId) + { + apiBaseUrl = FirstNonEmpty(GlitchIntegrationSecrets.ApiBaseUrl, GetEnv("GLITCH_API_URL")); + titleId = FirstNonEmpty(GlitchIntegrationSecrets.TitleId, GetEnv("GLITCH_TITLE_ID")); + titleToken = FirstNonEmpty(GlitchIntegrationSecrets.TitleToken, GetEnv("GLITCH_TITLE_TOKEN")); + testInstallId = FirstNonEmpty(GlitchIntegrationSecrets.DeveloperTestInstallId, GetEnv("GLITCH_TEST_INSTALL_ID")); + sessionId = FirstNonEmpty(ReadQueryValue(Application.absoluteURL, "session_id"), GetEnv("GLITCH_SESSION_ID")); + + bool enabled = GlitchIntegrationSecrets.Enabled || IsTruthy(GetEnv("GLITCH_ENABLE_AEGIS")) || IsTruthy(GetEnv("GLITCH_ENABLE_GLITCH")); + return enabled + && !string.IsNullOrWhiteSpace(apiBaseUrl) + && !string.IsNullOrWhiteSpace(titleId) + && !string.IsNullOrWhiteSpace(titleToken); + } + + private static string ResolveInstallId(string testInstallId) + { + string fromUrl = FirstNonEmpty( + ReadQueryValue(Application.absoluteURL, "install_id"), + ReadQueryValue(Application.absoluteURL, "glitch_install_id")); + if (!string.IsNullOrWhiteSpace(fromUrl)) + { + return fromUrl; + } + + string fromArgs = FirstNonEmpty( + ReadCommandLineValue("--install_id"), + ReadCommandLineValue("-install_id"), + ReadCommandLineAssignment("install_id"), + GetEnv("GLITCH_INSTALL_ID")); + if (!string.IsNullOrWhiteSpace(fromArgs)) + { + return fromArgs; + } + +#if UNITY_EDITOR || DEVELOPMENT_BUILD + return testInstallId; +#else + return string.Empty; +#endif + } + + private string BuildHeartbeatJson() + { + var sb = new StringBuilder(); + sb.Append('{'); + AppendStringProperty(sb, "user_install_id", _installId); + sb.Append(','); + AppendStringProperty(sb, "platform", PlatformName()); + sb.Append(','); + AppendStringProperty(sb, "game_version", AppShell.Version); + if (!string.IsNullOrWhiteSpace(_sessionId)) + { + sb.Append(','); + AppendStringProperty(sb, "session_id", _sessionId); + } + + sb.Append('}'); + return sb.ToString(); + } + + private static string BuildSubmitJson(IDictionary scores, IDictionary stats, IDictionary metadata) + { + var sb = new StringBuilder(); + sb.Append("{\"idempotency_key\":\"").Append(Guid.NewGuid().ToString("N")).Append("\",\"payload\":{"); + sb.Append("\"scores\":"); + AppendNumberMap(sb, scores); + sb.Append(",\"stats\":"); + AppendNumberMap(sb, stats); + sb.Append(",\"metadata\":"); + AppendStringMap(sb, metadata); + sb.Append("}}"); + return sb.ToString(); + } + + private static string BuildCloudSaveJson( + int slotIndex, + byte[] rawPayload, + int baseVersion, + string saveType, + string slotName, + string metadataJson, + long playDurationSeconds) + { + string encoded = Convert.ToBase64String(rawPayload); + string checksum = Sha256Hex(rawPayload); + var sb = new StringBuilder(); + sb.Append('{'); + AppendNumberProperty(sb, "slot_index", slotIndex); + sb.Append(','); + AppendStringProperty(sb, "payload", encoded); + sb.Append(','); + AppendStringProperty(sb, "checksum", checksum); + sb.Append(','); + AppendStringProperty(sb, "save_type", string.IsNullOrWhiteSpace(saveType) ? "manual" : saveType); + sb.Append(','); + AppendStringProperty(sb, "client_timestamp", DateTimeOffset.UtcNow.ToString("o", CultureInfo.InvariantCulture)); + sb.Append(','); + AppendNumberProperty(sb, "base_version", baseVersion); + sb.Append(','); + AppendStringProperty(sb, "slot_name", slotName ?? string.Empty); + sb.Append(",\"metadata\":").Append(IsJsonObject(metadataJson) ? metadataJson.Trim() : "{}"); + sb.Append(','); + AppendStringProperty(sb, "device_id", SystemInfo.deviceUniqueIdentifier ?? string.Empty); + sb.Append(','); + AppendStringProperty(sb, "platform", PlatformName()); + sb.Append(','); + AppendStringProperty(sb, "game_version", AppShell.Version); + sb.Append(','); + AppendStringProperty(sb, "last_played_at", DateTimeOffset.UtcNow.ToString("o", CultureInfo.InvariantCulture)); + sb.Append(','); + AppendNumberProperty(sb, "play_duration_seconds", Math.Max(0, playDurationSeconds)); + sb.Append('}'); + return sb.ToString(); + } + + private static void AppendStringProperty(StringBuilder sb, string name, string value) + { + sb.Append('"').Append(JsonEscape(name)).Append("\":\"").Append(JsonEscape(value ?? string.Empty)).Append('"'); + } + + private static void AppendNumberProperty(StringBuilder sb, string name, long value) + { + sb.Append('"').Append(JsonEscape(name)).Append("\":").Append(value.ToString(CultureInfo.InvariantCulture)); + } + + private static void AppendNumberMap(StringBuilder sb, IDictionary values) + { + sb.Append('{'); + bool first = true; + if (values != null) + { + foreach (var kv in values) + { + if (string.IsNullOrWhiteSpace(kv.Key)) + { + continue; + } + + if (!first) + { + sb.Append(','); + } + + sb.Append('"').Append(JsonEscape(kv.Key)).Append("\":").Append(kv.Value.ToString("R", CultureInfo.InvariantCulture)); + first = false; + } + } + + sb.Append('}'); + } + + private static void AppendStringMap(StringBuilder sb, IDictionary values) + { + sb.Append('{'); + bool first = true; + if (values != null) + { + foreach (var kv in values) + { + if (string.IsNullOrWhiteSpace(kv.Key)) + { + continue; + } + + if (!first) + { + sb.Append(','); + } + + sb.Append('"').Append(JsonEscape(kv.Key)).Append("\":\"").Append(JsonEscape(kv.Value ?? string.Empty)).Append('"'); + first = false; + } + } + + sb.Append('}'); + } + + private static string JsonEscape(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + var sb = new StringBuilder(value.Length + 8); + foreach (char c in value) + { + switch (c) + { + case '\\': + sb.Append("\\\\"); + break; + case '"': + sb.Append("\\\""); + break; + case '\n': + sb.Append("\\n"); + break; + case '\r': + sb.Append("\\r"); + break; + case '\t': + sb.Append("\\t"); + break; + default: + if (c < 32) + { + sb.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); + } + else + { + sb.Append(c); + } + + break; + } + } + + return sb.ToString(); + } + + private static string Sha256Hex(byte[] bytes) + { + using (var sha = SHA256.Create()) + { + byte[] hash = sha.ComputeHash(bytes); + var sb = new StringBuilder(hash.Length * 2); + foreach (byte b in hash) + { + sb.Append(b.ToString("x2", CultureInfo.InvariantCulture)); + } + + return sb.ToString(); + } + } + + private static bool IsJsonObject(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + string trimmed = value.Trim(); + return trimmed.StartsWith("{", StringComparison.Ordinal) && trimmed.EndsWith("}", StringComparison.Ordinal); + } + + private string TitleUrl(string suffix) => _apiBaseUrl.TrimEnd('/') + "/api/titles/" + UrlEncode(_titleId) + "/" + suffix; + private string InstallUrl(string suffix) => _apiBaseUrl.TrimEnd('/') + "/api/titles/" + UrlEncode(_titleId) + "/installs/" + UrlEncode(_installId) + "/" + suffix; + + private static bool IsSuccess(UnityWebRequest request) + { + return request.result == UnityWebRequest.Result.Success + && request.responseCode >= 200 + && request.responseCode < 300; + } + + private static string DescribeFailure(UnityWebRequest request) + { + string status = request.responseCode > 0 ? request.responseCode.ToString(CultureInfo.InvariantCulture) : "network"; + return string.IsNullOrWhiteSpace(request.error) ? status : status + " " + request.error; + } + + private static string ReadQueryValue(string url, string key) + { + if (string.IsNullOrEmpty(url)) + { + return string.Empty; + } + + int question = url.IndexOf('?'); + if (question < 0 || question == url.Length - 1) + { + return string.Empty; + } + + string query = url.Substring(question + 1); + int hash = query.IndexOf('#'); + if (hash >= 0) + { + query = query.Substring(0, hash); + } + + foreach (string part in query.Split('&')) + { + if (string.IsNullOrEmpty(part)) + { + continue; + } + + int equals = part.IndexOf('='); + string candidate = equals < 0 ? part : part.Substring(0, equals); + if (!string.Equals(UrlDecode(candidate), key, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + return equals < 0 ? string.Empty : UrlDecode(part.Substring(equals + 1)); + } + + return string.Empty; + } + + private static string ReadCommandLineValue(string key) + { + try + { + string[] args = Environment.GetCommandLineArgs(); + for (int i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], key, StringComparison.OrdinalIgnoreCase)) + { + return args[i + 1]; + } + } + } + catch (Exception) + { + return string.Empty; + } + + return string.Empty; + } + + private static string ReadCommandLineAssignment(string key) + { + try + { + string prefix = key + "="; + foreach (string arg in Environment.GetCommandLineArgs()) + { + if (arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return arg.Substring(prefix.Length); + } + } + } + catch (Exception) + { + return string.Empty; + } + + return string.Empty; + } + + private static string GetEnv(string name) + { + try + { + return Environment.GetEnvironmentVariable(name) ?? string.Empty; + } + catch (Exception) + { + return string.Empty; + } + } + + private static string FirstNonEmpty(params string[] values) + { + foreach (string value in values) + { + if (!string.IsNullOrWhiteSpace(value)) + { + return value.Trim(); + } + } + + return string.Empty; + } + + private static bool IsTruthy(string value) + { + return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "on", StringComparison.OrdinalIgnoreCase); + } + + private static string UrlEncode(string value) => UnityWebRequest.EscapeURL(value ?? string.Empty).Replace("+", "%20"); + private static string UrlDecode(string value) => UnityWebRequest.UnEscapeURL((value ?? string.Empty).Replace("+", " ")); + + private static string PlatformName() + { + switch (Application.platform) + { + case RuntimePlatform.WebGLPlayer: + return "webgl"; + case RuntimePlatform.WindowsPlayer: + case RuntimePlatform.WindowsEditor: + return "windows"; + case RuntimePlatform.LinuxPlayer: + case RuntimePlatform.LinuxEditor: + return "linux"; + case RuntimePlatform.OSXPlayer: + case RuntimePlatform.OSXEditor: + return "macos"; + default: + return Application.platform.ToString().ToLowerInvariant(); + } + } + } +} diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/GlitchIntegration.cs.meta b/client/Assets/BlocksBeyondTheStars/Scripts/GlitchIntegration.cs.meta new file mode 100644 index 00000000..bab5d042 --- /dev/null +++ b/client/Assets/BlocksBeyondTheStars/Scripts/GlitchIntegration.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f57dc47c80824fccaa7cc20813842528 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/GlitchIntegrationSecrets.cs b/client/Assets/BlocksBeyondTheStars/Scripts/GlitchIntegrationSecrets.cs new file mode 100644 index 00000000..b9ef2fe4 --- /dev/null +++ b/client/Assets/BlocksBeyondTheStars/Scripts/GlitchIntegrationSecrets.cs @@ -0,0 +1,105 @@ +// Blocks Beyond the Stars — Copyright (c) 2026 Justus Dütscher & Marcel Dütscher (JuMaVe Games) +// SPDX-License-Identifier: AGPL-3.0-or-later +// This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text. +namespace BlocksBeyondTheStars.Build +{ + /// + /// Optional Glitch title credentials, injected only for local/deployment builds. The committed values are + /// empty and disabled so public builds never talk to Glitch accidentally. + /// + /// A local or CI build may provide the sibling file GlitchIntegrationSecrets.Generated.cs + /// (git-ignored). The title token ships inside a WebGL player when enabled, so treat it as a + /// deploy-scoped credential and never commit or log it. + /// + public static partial class GlitchIntegrationSecrets + { + public static bool Enabled + { + get + { + bool enabled = false; + ApplyEnabled(ref enabled); + return enabled; + } + } + + public static string TitleId + { + get + { + string value = string.Empty; + ApplyTitleId(ref value); + return value; + } + } + + public static string TitleToken + { + get + { + string value = string.Empty; + ApplyTitleToken(ref value); + return value; + } + } + + public static string DeveloperTestInstallId + { + get + { + string value = string.Empty; + ApplyDeveloperTestInstallId(ref value); + return value; + } + } + + public static string ApiBaseUrl + { + get + { + string value = string.Empty; + ApplyApiBaseUrl(ref value); + return value; + } + } + + public static string ServerHost + { + get + { + string value = string.Empty; + ApplyServerHost(ref value); + return value; + } + } + + public static string ServerPort + { + get + { + string value = string.Empty; + ApplyServerPort(ref value); + return value; + } + } + + public static string ServerPassword + { + get + { + string value = string.Empty; + ApplyServerPassword(ref value); + return value; + } + } + + static partial void ApplyEnabled(ref bool enabled); + static partial void ApplyTitleId(ref string value); + static partial void ApplyTitleToken(ref string value); + static partial void ApplyDeveloperTestInstallId(ref string value); + static partial void ApplyApiBaseUrl(ref string value); + static partial void ApplyServerHost(ref string value); + static partial void ApplyServerPort(ref string value); + static partial void ApplyServerPassword(ref string value); + } +} diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/GlitchIntegrationSecrets.cs.meta b/client/Assets/BlocksBeyondTheStars/Scripts/GlitchIntegrationSecrets.cs.meta new file mode 100644 index 00000000..8ca5ae48 --- /dev/null +++ b/client/Assets/BlocksBeyondTheStars/Scripts/GlitchIntegrationSecrets.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: af3384e378834c1498b9304b642045b0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/LoadingScreen.cs b/client/Assets/BlocksBeyondTheStars/Scripts/LoadingScreen.cs index 6543a30c..7032f7fb 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/LoadingScreen.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/LoadingScreen.cs @@ -31,6 +31,11 @@ public void Update() return; } + if (!_shell.ContentReady) + { + return; + } + _elapsed += Time.deltaTime; if (_elapsed >= MinShow) { diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/LocalServerLauncher.cs b/client/Assets/BlocksBeyondTheStars/Scripts/LocalServerLauncher.cs index 0fe7f4b1..5fd382c2 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/LocalServerLauncher.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/LocalServerLauncher.cs @@ -127,6 +127,10 @@ public bool Prepare(int port = DefaultPort, int viewDistanceChunks = 0, string w string worldOptionArgs = null, int maxPlayers = 1, string password = null, string serverName = "Singleplayer", string adminName = null) { +#if UNITY_WEBGL && !UNITY_EDITOR + Debug.LogWarning("Local world hosting is unavailable in WebGL; browsers cannot launch the bundled server process."); + return false; +#else if (IsRunning) { return true; @@ -202,6 +206,7 @@ public bool Prepare(int port = DefaultPort, int viewDistanceChunks = 0, string w RedirectStandardError = true, }; return true; +#endif } /// Spawns the prepared server process. **Thread-safe** (no Unity APIs) so it can run on a diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/MinigameCatalog.cs b/client/Assets/BlocksBeyondTheStars/Scripts/MinigameCatalog.cs index b4137535..2313e2a6 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/MinigameCatalog.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/MinigameCatalog.cs @@ -10,7 +10,7 @@ namespace BlocksBeyondTheStars.Client { /// /// The client-side catalogue of bundled arcade minigames, loaded from - /// StreamingAssets/minigames/catalog.json. It is the single source of truth for which games exist, + /// StreamingAssets/data/minigames/catalog.json. It is the single source of truth for which games exist, /// their bilingual titles/descriptions, and — crucially — how a data cube's opaque seed /// maps to a concrete game (). The server never reads this; it only places cubes /// with seeds, so the same build resolves every cube to the same game on every client. @@ -43,7 +43,7 @@ private static string Pick(Loc l, bool german) public static MinigameCatalog Instance { get; private set; } - /// Loads (once) the catalogue from StreamingAssets. Returns the cached instance on repeat calls. + /// Loads (once) the catalogue from bundled content. Returns the cached instance on repeat calls. public static MinigameCatalog Load() { if (Instance != null) @@ -54,7 +54,7 @@ public static MinigameCatalog Load() var cat = new MinigameCatalog(); try { - string path = Path.Combine(Application.streamingAssetsPath, "minigames", "catalog.json"); + string path = Path.Combine(StreamingAssetsCache.DataDir, "minigames", "catalog.json"); if (File.Exists(path)) { var parsed = JsonUtility.FromJson(File.ReadAllText(path)); diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/StreamingAssetsCache.cs b/client/Assets/BlocksBeyondTheStars/Scripts/StreamingAssetsCache.cs new file mode 100644 index 00000000..7ae96702 --- /dev/null +++ b/client/Assets/BlocksBeyondTheStars/Scripts/StreamingAssetsCache.cs @@ -0,0 +1,271 @@ +// Blocks Beyond the Stars — Copyright (c) 2026 Justus Dütscher & Marcel Dütscher (JuMaVe Games) +// SPDX-License-Identifier: AGPL-3.0-or-later +// This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text. +using System; +using System.Collections; +using System.IO; +using UnityEngine; +using UnityEngine.Networking; + +namespace BlocksBeyondTheStars.Client +{ + /// + /// Resolves bundled StreamingAssets content for native and WebGL builds. + /// WebGL exposes StreamingAssets through HTTP, while the shared content loader expects a filesystem tree. + /// + public static class StreamingAssetsCache + { + private const string DataFolder = "data"; + private const string CacheFolder = "streaming-assets-cache"; + + private static readonly string[] FallbackManifest = + { + "blocks.json", + "blueprints.json", + "items.json", + "locales/de.json", + "locales/en.json", + "minigames/catalog.json", + "missions.json", + "planets.json", + "recipes.json", + "settlement_templates.json", + "ship_layouts/ship_corvette.json", + "ship_layouts/ship_hauler.json", + "ship_layouts/ship_scout.json", + "ship_modules.json", + "ships.json", + "station_templates.json", + "stories/vega_protocol/locales/de.json", + "stories/vega_protocol/locales/en.json", + "stories/vega_protocol/story.json", + "wiki/articles.json", + }; + + private static bool _ready; + private static bool _loading; + private static string _dataDir; + private static int _remoteFileCount; + + [Serializable] + private sealed class Manifest + { + public string[] files; + } + + public static bool UsesRemoteStreamingAssets => IsHttpUrl(Application.streamingAssetsPath); + public static bool IsReady => _ready; + public static int RemoteFileCount => _remoteFileCount; + + public static string DataDir + { + get + { + if (!string.IsNullOrEmpty(_dataDir)) + { + return _dataDir; + } + + return Path.Combine(Application.streamingAssetsPath, DataFolder); + } + } + + public static void EnsureLocalReady() + { + if (_ready) + { + return; + } + + if (UsesRemoteStreamingAssets) + { + throw new InvalidOperationException("Remote StreamingAssets must be prepared with EnsureReady()."); + } + + _dataDir = Path.Combine(Application.streamingAssetsPath, DataFolder); + _ready = true; + } + + public static IEnumerator EnsureReady(Action onError = null) + { + if (_ready) + { + yield break; + } + + if (!UsesRemoteStreamingAssets) + { + EnsureLocalReady(); + yield break; + } + + if (_loading) + { + while (_loading) + { + yield return null; + } + + if (!_ready) + { + onError?.Invoke(new InvalidOperationException("Remote StreamingAssets cache did not complete.")); + } + + yield break; + } + + _loading = true; + Exception failure = null; + string cacheDir = Path.Combine(Application.persistentDataPath, CacheFolder, DataFolder); + yield return DownloadRemoteData(cacheDir, ex => failure = ex); + + if (failure == null) + { + _dataDir = cacheDir; + _ready = true; + Debug.Log($"StreamingAssets data cached at '{_dataDir}' ({_remoteFileCount} files)."); + } + else + { + Debug.LogError($"StreamingAssets data cache failed: {failure.Message}"); + onError?.Invoke(failure); + } + + _loading = false; + } + + private static IEnumerator DownloadRemoteData(string cacheDir, Action onFailure) + { + string[] files = null; + string manifestUrl = JoinUrl(Application.streamingAssetsPath, DataFolder + "/manifest.json"); + using (var request = UnityWebRequest.Get(manifestUrl)) + { + yield return request.SendWebRequest(); + if (RequestSucceeded(request)) + { + files = ParseManifest(request.downloadHandler.text); + } + else + { + Debug.LogWarning($"StreamingAssets manifest not found at '{manifestUrl}'; using built-in fallback list."); + } + } + + if (files == null || files.Length == 0) + { + files = FallbackManifest; + } + + try + { + if (Directory.Exists(cacheDir)) + { + Directory.Delete(cacheDir, true); + } + + Directory.CreateDirectory(cacheDir); + } + catch (Exception ex) + { + onFailure(ex); + yield break; + } + + _remoteFileCount = 0; + foreach (string rawFile in files) + { + string file = NormalizeRelativePath(rawFile); + if (string.IsNullOrEmpty(file)) + { + continue; + } + + string sourceUrl = JoinUrl(Application.streamingAssetsPath, DataFolder + "/" + file); + string targetPath = Path.Combine(cacheDir, file.Replace('/', Path.DirectorySeparatorChar)); + using (var request = UnityWebRequest.Get(sourceUrl)) + { + yield return request.SendWebRequest(); + if (!RequestSucceeded(request)) + { + onFailure(new IOException($"Could not download '{sourceUrl}': {RequestError(request)}")); + yield break; + } + + try + { + string parent = Path.GetDirectoryName(targetPath); + if (!string.IsNullOrEmpty(parent)) + { + Directory.CreateDirectory(parent); + } + + File.WriteAllBytes(targetPath, request.downloadHandler.data); + _remoteFileCount++; + } + catch (Exception ex) + { + onFailure(ex); + yield break; + } + } + } + } + + private static string[] ParseManifest(string json) + { + try + { + var manifest = JsonUtility.FromJson(json); + return manifest?.files ?? Array.Empty(); + } + catch (Exception ex) + { + Debug.LogWarning($"StreamingAssets manifest parse failed: {ex.Message}"); + return Array.Empty(); + } + } + + private static string NormalizeRelativePath(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + string normalized = value.Replace('\\', '/').Trim().TrimStart('/'); + if (normalized.Contains("..")) + { + Debug.LogWarning($"Skipping unsafe StreamingAssets path '{value}'."); + return string.Empty; + } + + return normalized; + } + + private static string JoinUrl(string baseUrl, string relative) + => baseUrl.TrimEnd('/') + "/" + relative.Replace('\\', '/').TrimStart('/'); + + private static bool IsHttpUrl(string value) + => value != null + && (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase)); + + private static bool RequestSucceeded(UnityWebRequest request) + { +#if UNITY_2020_2_OR_NEWER + return request.result == UnityWebRequest.Result.Success; +#else + return !request.isNetworkError && !request.isHttpError; +#endif + } + + private static string RequestError(UnityWebRequest request) + { +#if UNITY_2020_2_OR_NEWER + return string.IsNullOrEmpty(request.error) ? request.result.ToString() : request.error; +#else + return string.IsNullOrEmpty(request.error) ? "request failed" : request.error; +#endif + } + } +} diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/StreamingAssetsCache.cs.meta b/client/Assets/BlocksBeyondTheStars/Scripts/StreamingAssetsCache.cs.meta new file mode 100644 index 00000000..3e2973db --- /dev/null +++ b/client/Assets/BlocksBeyondTheStars/Scripts/StreamingAssetsCache.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 68e4e90e29a94b608ae0f77f6292ec4b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/UiMainMenu.cs b/client/Assets/BlocksBeyondTheStars/Scripts/UiMainMenu.cs index c9247c3c..67877772 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/UiMainMenu.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/UiMainMenu.cs @@ -52,7 +52,13 @@ public static GameObject Build(AppShell shell) float by = 322f; UiKit.AddButton(root, bx, by, bw, bh, shell.L("ui.menu.singleplayer"), shell.StartSingleplayer, "btn_singleplayer"); UiKit.AddButton(root, bx, by + gap, bw, bh, shell.L("ui.menu.host"), shell.StartHost, "btn_join"); - UiKit.AddButton(root, bx, by + gap * 2f, bw, bh, shell.L("ui.menu.join"), () => { if (connect != null) connect.SetActive(true); }, "btn_join"); + UiKit.AddButton(root, bx, by + gap * 2f, bw, bh, shell.L("ui.menu.join"), () => + { + if (connect != null) + { + connect.SetActive(true); + } + }, "btn_join"); UiKit.AddButton(root, bx, by + gap * 3f, bw, bh, shell.L("ui.menu.editors"), () => shell.GoTo(ShellPhase.Editors), "btn_singleplayer"); UiKit.AddButton(root, bx, by + gap * 4f, bw, bh, shell.L("ui.menu.settings"), shell.OpenSettings, "btn_settings"); UiKit.AddButton(root, bx, by + gap * 5f, bw, bh, shell.L("ui.menu.credits"), () => shell.GoTo(ShellPhase.Credits), "btn_credits"); diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/WikiUI.cs b/client/Assets/BlocksBeyondTheStars/Scripts/WikiUI.cs index 4e58c68b..e2c1aed5 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/WikiUI.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/WikiUI.cs @@ -37,7 +37,7 @@ public sealed class WikiUI : MonoBehaviour private bool _builtCanvas; private string _chapter = "guide"; - // Guide articles, loaded once from the bundled wiki content (StreamingAssets/wiki/articles.json). + // Guide articles, loaded once from the bundled wiki content (StreamingAssets/data/wiki/articles.json). [Serializable] private sealed class LocText { public string en = ""; public string de = ""; } [Serializable] private sealed class Article { public string id = ""; public LocText title; public LocText body; } [Serializable] private sealed class ArticleList { public Article[] items; } @@ -252,7 +252,7 @@ private void LoadArticles() try { - string path = Path.Combine(Application.streamingAssetsPath, "wiki", "articles.json"); + string path = Path.Combine(StreamingAssetsCache.DataDir, "wiki", "articles.json"); if (File.Exists(path)) { // JsonUtility can't parse a top-level array, so wrap it as { "items": [...] }. diff --git a/client/Assets/Plugins/BbsWebSocket.jslib b/client/Assets/Plugins/BbsWebSocket.jslib new file mode 100644 index 00000000..90688bc9 --- /dev/null +++ b/client/Assets/Plugins/BbsWebSocket.jslib @@ -0,0 +1,122 @@ +// Blocks Beyond the Stars — Copyright (c) 2026 Justus Dütscher & Marcel Dütscher (JuMaVe Games) +// SPDX-License-Identifier: AGPL-3.0-or-later +// WebGL browser WebSocket bridge for BrowserWebSocketClientTransport. +mergeInto(LibraryManager.library, { + $BbsWsSendUnityMessage: function (objectName, methodName, payload) { + if (typeof SendMessage === "function") { + SendMessage(objectName, methodName, payload); + return; + } + + if (Module && typeof Module.SendMessage === "function") { + Module.SendMessage(objectName, methodName, payload); + return; + } + + console.error("Unity SendMessage bridge is not available for " + objectName + "." + methodName); + }, + + $BbsWsSendBytesToUnity__deps: ["$BbsWsSendUnityMessage"], + $BbsWsSendBytesToUnity: function (id, bytes) { + var binary = ""; + var chunkSize = 0x8000; + for (var offset = 0; offset < bytes.length; offset += chunkSize) { + var chunk = bytes.subarray(offset, offset + chunkSize); + binary += String.fromCharCode.apply(null, chunk); + } + + BbsWsSendUnityMessage("BbsWebSocketBridge", "HandleMessage", String(id) + "|" + btoa(binary)); + }, + + BbsWsConnect__deps: ["$BbsWsSendUnityMessage", "$BbsWsSendBytesToUnity"], + BbsWsConnect: function (id, urlPtr) { + var url = UTF8ToString(urlPtr); + Module.BbsSockets = Module.BbsSockets || {}; + + try { + var socket = new WebSocket(url); + socket.binaryType = "arraybuffer"; + Module.BbsSockets[id] = socket; + + socket.onopen = function () { + BbsWsSendUnityMessage("BbsWebSocketBridge", "HandleOpen", String(id)); + }; + + socket.onclose = function () { + BbsWsSendUnityMessage("BbsWebSocketBridge", "HandleClose", String(id)); + delete Module.BbsSockets[id]; + }; + + socket.onerror = function () { + BbsWsSendUnityMessage("BbsWebSocketBridge", "HandleError", String(id) + "|socket error"); + }; + + socket.onmessage = function (event) { + if (event.data instanceof ArrayBuffer) { + BbsWsSendBytesToUnity(id, new Uint8Array(event.data)); + return; + } + + if (ArrayBuffer.isView(event.data)) { + BbsWsSendBytesToUnity(id, new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength)); + return; + } + + if (typeof event.data === "string") { + var bytes = new TextEncoder().encode(event.data); + BbsWsSendBytesToUnity(id, bytes); + return; + } + + if (!(event.data instanceof Blob)) { + BbsWsSendUnityMessage("BbsWebSocketBridge", "HandleError", String(id) + "|unsupported message type"); + return; + } + + var reader = new FileReader(); + reader.onload = function () { + BbsWsSendBytesToUnity(id, new Uint8Array(reader.result)); + }; + reader.onerror = function () { + BbsWsSendUnityMessage("BbsWebSocketBridge", "HandleError", String(id) + "|message decode failed"); + }; + reader.readAsArrayBuffer(event.data); + }; + } catch (e) { + BbsWsSendUnityMessage("BbsWebSocketBridge", "HandleError", String(id) + "|" + String(e)); + BbsWsSendUnityMessage("BbsWebSocketBridge", "HandleClose", String(id)); + } + }, + + BbsWsSendBase64__deps: ["$BbsWsSendUnityMessage"], + BbsWsSendBase64: function (id, payloadPtr) { + var socket = Module.BbsSockets && Module.BbsSockets[id]; + if (!socket || socket.readyState !== WebSocket.OPEN) { + return; + } + + var payload = UTF8ToString(payloadPtr); + var binary = atob(payload); + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + socket.send(bytes); + }, + + BbsWsDisconnect__deps: ["$BbsWsSendUnityMessage"], + BbsWsDisconnect: function (id) { + var socket = Module.BbsSockets && Module.BbsSockets[id]; + if (!socket) { + return; + } + + try { + socket.close(); + } catch (e) { + } + + delete Module.BbsSockets[id]; + } +}); diff --git a/client/Assets/Plugins/BbsWebSocket.jslib.meta b/client/Assets/Plugins/BbsWebSocket.jslib.meta new file mode 100644 index 00000000..76e3dcac --- /dev/null +++ b/client/Assets/Plugins/BbsWebSocket.jslib.meta @@ -0,0 +1,26 @@ +fileFormatVersion: 2 +guid: 5b6e9b1626584a1d975c8e37c2ac17ca +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + WebGL: WebGL + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/link.xml b/client/Assets/link.xml new file mode 100644 index 00000000..23524070 --- /dev/null +++ b/client/Assets/link.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/client/Assets/link.xml.meta b/client/Assets/link.xml.meta new file mode 100644 index 00000000..3f797602 --- /dev/null +++ b/client/Assets/link.xml.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9ecf6693eb974d49b5264cc82e86f273 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Packages/manifest.json b/client/Packages/manifest.json index b99e90e0..e6d1f2bc 100644 --- a/client/Packages/manifest.json +++ b/client/Packages/manifest.json @@ -20,6 +20,7 @@ "com.unity.modules.particlesystem": "1.0.0", "com.unity.modules.physics": "1.0.0", "com.unity.modules.screencapture": "1.0.0", + "com.unity.modules.unitywebrequest": "1.0.0", "com.unity.modules.uielements": "1.0.0" } } diff --git a/client/Packages/packages-lock.json b/client/Packages/packages-lock.json index 92e8ada5..9b9ca416 100644 --- a/client/Packages/packages-lock.json +++ b/client/Packages/packages-lock.json @@ -198,6 +198,12 @@ "com.unity.modules.hierarchycore": "1.0.0", "com.unity.modules.physics": "1.0.0" } + }, + "com.unity.modules.unitywebrequest": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} } } } diff --git a/client/ProjectSettings/ProjectSettings.asset b/client/ProjectSettings/ProjectSettings.asset index d4c04e7e..c31ea16f 100644 --- a/client/ProjectSettings/ProjectSettings.asset +++ b/client/ProjectSettings/ProjectSettings.asset @@ -339,6 +339,9 @@ PlayerSettings: - m_BuildTarget: Standalone m_StaticBatching: 1 m_DynamicBatching: 0 + - m_BuildTarget: WebGL + m_StaticBatching: 0 + m_DynamicBatching: 0 m_BuildTargetShaderSettings: [] m_BuildTargetGraphicsJobs: [] m_BuildTargetGraphicsJobMode: [] @@ -598,7 +601,7 @@ PlayerSettings: splashScreenBackgroundSourcePortrait: {fileID: 0} blurSplashScreenBackground: 1 spritePackerPolicy: - webGLMemorySize: 32 + webGLMemorySize: 512 webGLExceptionSupport: 1 webGLNameFilesAsHashes: 0 webGLShowDiagnostics: 0 @@ -609,11 +612,11 @@ PlayerSettings: webGLTemplate: APPLICATION:Default webGLAnalyzeBuildSize: 0 webGLUseEmbeddedResources: 0 - webGLCompressionFormat: 1 + webGLCompressionFormat: 0 webGLWasmArithmeticExceptions: 0 webGLLinkerTarget: 1 webGLThreadsSupport: 0 - webGLDecompressionFallback: 0 + webGLDecompressionFallback: 1 webGLInitialMemorySize: 32 webGLMaximumMemorySize: 2048 webGLMemoryGrowthMode: 2 @@ -634,7 +637,8 @@ PlayerSettings: il2cppCompilerConfiguration: {} il2cppCodeGeneration: {} il2cppStacktraceInformation: {} - managedStrippingLevel: {} + managedStrippingLevel: + WebGL: 1 incrementalIl2cppBuild: {} suppressCommonWarnings: 1 allowUnsafeCode: 0 diff --git a/data/locales/de.json b/data/locales/de.json index 7878396b..061bdb4b 100644 --- a/data/locales/de.json +++ b/data/locales/de.json @@ -844,6 +844,7 @@ "ui.menu.connect_port": "Port", "ui.menu.connect_password": "Server-Passwort (leer lassen, wenn keines)", "ui.menu.connect": "Verbinden", + "ui.webgl.gameplay_blocked": "Browser-Builds koennen der Online-Welt beitreten, aber auf diesem Geraet keine lokale Welt starten.", "ui.host.title": "WELT HOSTEN", "ui.host.subtitle": "Eine Welt für Freunde hosten: eine gespeicherte Welt wählen oder neu erstellen, dann die im Spiel angezeigte Adresse teilen.", "ui.host.max_players": "Max. Spieler", diff --git a/data/locales/en.json b/data/locales/en.json index 6ee44091..1f94f4dc 100644 --- a/data/locales/en.json +++ b/data/locales/en.json @@ -843,6 +843,7 @@ "ui.menu.connect_port": "Port", "ui.menu.connect_password": "Server password (leave empty if none)", "ui.menu.connect": "Connect", + "ui.webgl.gameplay_blocked": "Browser builds can join the online realm, but cannot start a local world on this device.", "ui.host.title": "HOST A WORLD", "ui.host.subtitle": "Host a world for friends: pick any saved world or create a new one, then share the address shown in-game.", "ui.host.max_players": "Max players", diff --git a/docs/developer/ARCHITECTURE.md b/docs/developer/ARCHITECTURE.md index 50aa6bd2..9a99c974 100644 --- a/docs/developer/ARCHITECTURE.md +++ b/docs/developer/ARCHITECTURE.md @@ -32,8 +32,8 @@ both inside Unity and on the server; everything else targets **`net8.0`** (the L |---|---|---| | `BlocksBeyondTheStars.Shared` | `netstandard2.1` | Data models, data-driven definitions, geometry, localization, protocol DTOs, game rules, story engine. Depends on nothing in the solution. | | `BlocksBeyondTheStars.WorldGeneration` | `netstandard2.1` | Seed-deterministic chunk/galaxy/flora/settlement/creature generation. | -| `BlocksBeyondTheStars.Networking` | `netstandard2.1` | Transport abstraction + concrete transports, message classes, `NetCodec` registry. Refs LiteNetLib + MessagePack. | -| `BlocksBeyondTheStars.Persistence` | `net8.0` | `IWorldRepository` + SQLite implementation, savegame paths/snapshots. Refs Microsoft.Data.Sqlite. | +| `BlocksBeyondTheStars.Networking` | `netstandard2.1` | Transport abstraction + concrete transports, message classes, `NetCodec` registry. Refs LiteNetLib + MessagePack; WebGL uses a JSON envelope at the WebSocket edge. | +| `BlocksBeyondTheStars.Persistence` | `net8.0` | `IWorldRepository` + SQLite/PostgreSQL implementations, savegame paths/snapshots. Refs Microsoft.Data.Sqlite + Npgsql. | | `BlocksBeyondTheStars.GameServer` | `net8.0` (Exe) | Authoritative tick loop + console host. | | `BlocksBeyondTheStars.Api` | `net8.0` (Web) | ASP.NET Core minimal-API admin/portal/distribution. | | `BlocksBeyondTheStars.Tools` | `net8.0` (Exe) | Backup/export/debug CLI. | @@ -71,7 +71,7 @@ feature files. It is **single-threaded and tick-driven** by design (`GameServer. - **Host** — `GameServer/Program.cs` loads `config/server.json` (with CLI overrides such as the client's `--port/--saves/--data/--usercontent`), loads data-driven content, opens the - SQLite repository, builds the transport, then `Start()` + `Run()`. `Ctrl-C` only *requests* + configured repository, builds the transport, then `Start()` + `Run()`. `Ctrl-C` only *requests* a stop; the run loop drains and saves on the tick thread so a save never races a live tick. - **Tick loop** — `Run()` sleeps to a configured `TickRate`. Each `Tick(dt)` polls the transport, ticks each *occupied* world with an "active world cursor" set (environment, fauna, @@ -80,18 +80,21 @@ feature files. It is **single-threaded and tick-driven** by design (`GameServer. through forwarding properties. - **Networking** — `Networking/Transport/` defines `IServerTransport`/`IClientTransport` carrying raw `NetCodec`-encoded payloads; events fire during `Poll()` so the server stays - single-threaded. Concrete transports: `LiteNetLibTransport` (UDP, the Windows client), - `WebSocketServerTransport` (browser clients, same protocol/port), `LoopbackTransport` + single-threaded. Concrete transports: `LiteNetLibTransport` (UDP, native clients), + `WebSocketServerTransport` (browser clients, same gameplay port), `LoopbackTransport` (in-process), and `CompositeServerTransport` (runs UDP + WebSocket together). UDP is the - default; WebSocket is opt-in (`EnableWebSocket`). -- **Persistence** — `Persistence/SqliteWorldRepository.cs` behind `IWorldRepository`. SQLite - in **WAL** mode (`synchronous=NORMAL`), portable. Stores world + default; WebSocket is opt-in (`EnableWebSocket`). Browser clients use the JSON `NetCodec` + envelope at this edge; native clients keep MessagePack. +- **Persistence** — `IWorldRepository` behind `WorldRepositoryFactory`. SQLite + (`SqliteWorldRepository`, WAL mode, `synchronous=NORMAL`) is the portable default for local, + singleplayer and self-hosted worlds; PostgreSQL (`PostgreSqlWorldRepository`) is an opt-in hosted + backend selected by `databaseProvider` / `BBS_DATABASE_PROVIDER=postgresql` plus a connection string. + Both backends store world metadata, **only player block-edit deltas** (`block_edit`, keyed by planet+xyz with tint/glow/shape), player/ship JSON blobs, containers, doors, beacons, beams, bases, alliances, story state, space structures + their per-cell edits, location statuses and player/admin missions. `RunInTransaction` batches bursty writes into one commit; autosave + - atomic backups via `CreateBackup`. A PostgreSQL impl can be added without touching the - server. See [SELF_HOSTING.md](SELF_HOSTING.md). + backups via `CreateBackup`. See [SELF_HOSTING.md](SELF_HOSTING.md). - **WorldGen** — `WorldGeneration/WorldGenerator.cs` is **seed-deterministic**: given a seed, `PlanetType` and `ChunkCoord` it always yields the same blocks, so the procedural baseline is never stored. `UniverseGenerator` builds the galaxy of systems/bodies from the seed; flora, @@ -157,10 +160,11 @@ comments are English; player-facing text is bilingual. ## Networking protocol (intents → state) -`Networking/NetCodec.cs` is a stable **tag↔type registry**: each payload is a one-byte +`Networking/NetCodec.cs` is a stable **tag↔type registry**. Native payloads are a one-byte message-type tag followed by a MessagePack *contractless* body (no serialization attributes, -compact wire format). Ids are append-only and never reused; a message class that isn't -`Register()`'d silently fails to send. +compact wire format). Browser WebSocket payloads use a reserved JSON envelope tag whose body +contains the same registered message tag plus JSON. Ids are append-only and never reused; a +message class that isn't `Register()`'d silently fails to send. - **Client → server = intents** (tags 1+): `JoinRequest`, `MoveIntent`, `MineBlockIntent`, `PlaceBlockIntent`, `CraftIntent`, `DockRequestIntent`, `TravelIntent`, `FireWeaponIntent`, … diff --git a/docs/developer/MINIGAMES_AND_WIKI.md b/docs/developer/MINIGAMES_AND_WIKI.md index 2f54e6c6..f6764984 100644 --- a/docs/developer/MINIGAMES_AND_WIKI.md +++ b/docs/developer/MINIGAMES_AND_WIKI.md @@ -59,9 +59,9 @@ Menu "Codex"/"DataQubes" button drawing into a `Canvas2D`. - `MinigameHostUI.cs` (Unity) — gives the host a uGUI body: a point-filtered `RawImage` fed from the game's `Canvas2D` each frame. Needs no UWB/CEF, so it builds on every platform. -- `MinigameCatalog.cs` (Unity) — loads `StreamingAssets/minigames/catalog.json` for UI metadata - (icon/title/desc + cube seed → game). The `entry` field is legacy and unused by the native host. -- `WikiUI.cs` + `WikiMarkup` (`Client.Core`) — load `StreamingAssets/wiki/articles.json` and render the +- `MinigameCatalog.cs` (Unity) — loads `StreamingAssets/data/minigames/catalog.json` for UI metadata + (icon/title/desc + cube seed → game). On WebGL this comes from the cached StreamingAssets data folder. +- `WikiUI.cs` + `WikiMarkup` (`Client.Core`) — load `StreamingAssets/data/wiki/articles.json` and render the articles natively (discovery-gated Systems & Worlds chapters from the player's visited set). - `DataCubeView.cs` — renders the glowing cube (texture `Resources/props/data_cube`, point light, pulse), proximity hum (`data_cube_hum`), and the E label. `PlayerController` E-interact sends the download + diff --git a/docs/developer/README.md b/docs/developer/README.md index e4dcb63e..c09a7548 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -74,7 +74,8 @@ belongs in TODO.md). Each doc states its own status near the top. Last reorganis ## Forward-looking - [AI_MISSION_BACKEND.md](AI_MISSION_BACKEND.md) — the optional, offline-safe LLM service (design + contract). -- [WEBCLIENT_FEASIBILITY.md](WEBCLIENT_FEASIBILITY.md) — browser/WebGL client feasibility (decided, not built). +- [WEBCLIENT_FEASIBILITY.md](WEBCLIENT_FEASIBILITY.md) — browser/WebGL client feasibility and the hosted + Lite lane (local WebSocket/PostgreSQL play works; production hardening/deployment remains). ## Architecture Decision Records diff --git a/docs/developer/SELF_HOSTING.md b/docs/developer/SELF_HOSTING.md index 639610c5..77035fee 100644 --- a/docs/developer/SELF_HOSTING.md +++ b/docs/developer/SELF_HOSTING.md @@ -45,7 +45,7 @@ Created on first run; editable directly or through the admin UI. |---|---|---| | `serverName` | Display name | `Blocks Beyond the Stars Server` | | `worldName` | Save folder under `saves/` | `world_001` | -| `gameplayPort` | UDP port for the game (open/forward this) | `31415` | +| `gameplayPort` | UDP gameplay port for native clients; also the HTTP/WebSocket port when WebSocket is enabled | `31415` | | `adminPort` | HTTP port for the admin UI | `31416` | | `maxPlayers` | Connection cap | `12` | | `serverPassword` | Required to join (empty = none) | `""` | @@ -59,6 +59,10 @@ Created on first run; editable directly or through the admin UI. | `seed` | World seed (0 = derive from world name) | `0` | | `startPlanet` | Starting planet type | `rocky` | | `adminBindAddress` | Admin UI bind address | `127.0.0.1` | +| `enableWebSocket` | Enable browser/WebGL WebSocket gameplay transport | `false` | +| `webSocketBindAddress` | WebSocket HTTP bind host (`+` for all interfaces/reverse proxies) | `localhost` | +| `databaseProvider` | Save backend: `sqlite` or `postgresql` | `sqlite` | +| `postgresConnectionString` | PostgreSQL connection string (prefer env/secret) | `""` | | `aiLevel` | Optional AI text backend: `Off`, `Suggest` (AI missions land as drafts), `Auto` (published) — see §8 | `Off` | | `aiBackendUrl` | Base URL of the optional AI backend | `http://127.0.0.1:8077` | @@ -79,8 +83,15 @@ command line**, so env vars override the file but the in-game host's CLI flags s | `BBS_ADMINS` | `adminPlayers` (comma-separated) | `BBS_USERCONTENT` | `userContentDir` | | `BBS_SEED` | `seed` | `BBS_TICK_RATE` | `tickRate` | | `BBS_START_PLANET` | `startPlanet` | `BBS_VIEW_DISTANCE` | `viewDistanceChunks` | +| `BBS_FREE_FLIGHT` | `rules.freeSpaceFlight` | `BBS_SPACE_COMBAT` | `rules.spaceCombat` | +| `BBS_SHIP_WEAPONS` | `rules.shipWeapons` | `BBS_SPACE_NPCS` | `rules.spaceNpcEnemies` | +| `BBS_DATABASE_PROVIDER` (`BBS_DATABASE`) | `databaseProvider` | `BBS_POSTGRES_CONNECTION_STRING` (`DATABASE_URL`) | `postgresConnectionString` | | `BBS_AI_LEVEL` | `aiLevel` | `BBS_AI_BACKEND_URL` | `aiBackendUrl` | +`BBS_FREE_FLIGHT=true` is useful for hosted WebGL realms where every player should be allowed to launch and fly +manually right away. It also upgrades older world metadata that was saved before free flight became the default, +while leaving the rest of that world's saved rules intact. + ### Player names & name verification A player's name keys their server-side state (inventory, position, role). Two protections @@ -96,8 +107,13 @@ secret is stored in the save. The very first player ever to join a fresh world b ## 4. Ports & networking -- **Gameplay**: UDP `gameplayPort` (default 31415). Forward this on your router for - internet play. +- **Native gameplay**: UDP `gameplayPort` (default 31415). Forward this on your router for + desktop native clients. +- **Browser/WebGL gameplay**: when `enableWebSocket=true`, the server also listens for HTTP/WebSocket + upgrades on `gameplayPort`. Browsers connect with `ws://` or `wss://`; reverse proxies and managed + hosts must allow WebSocket upgrade traffic to the game server. Azure Container Apps should use HTTP/auto + ingress to target port 31415 for WebGL, then clients use the app's `wss://...` URL. Native UDP clients + still need a UDP-capable host. - **Admin UI**: HTTP `adminPort` (default 31416), bound to `127.0.0.1` by default so it is **not** reachable from outside. Only change `adminBindAddress` if you understand the risk, and always set an `adminPassword` first. @@ -154,10 +170,19 @@ See §9 for distributing the client this way. ## 6. Backups & saves -- A world lives in `saves//world.db` (SQLite) with `backups/` and `logs/` - alongside — fully portable; copy the folder to move or back up a world. -- Backups are transactionally consistent copies (`VACUUM INTO`). Create them from the - admin UI, the Tools CLI (`BlocksBeyondTheStars.Tools backup saves `), or on a schedule. +- SQLite remains the default. A world lives in `saves//world.db` with `backups/` and + `logs/` alongside — fully portable; copy the folder to move or back up a local/self-hosted world. +- PostgreSQL is opt-in for hosted dedicated servers: set `databaseProvider` to `postgresql` (or + `BBS_DATABASE_PROVIDER=postgresql`) and provide `postgresConnectionString` through an environment + secret such as `BBS_POSTGRES_CONNECTION_STRING`. Each world is isolated into its own schema named + from the world name, while logs, bug reports and JSON backup exports still use `saves//`. +- SQLite backups are transactionally consistent `.db` copies (`VACUUM INTO`). PostgreSQL backups are + JSON table-export snapshots (`*.postgresql.json`) for inspection/operator recovery workflows; the game does + not yet include an importer that restores those snapshots. For production PostgreSQL operations, also use the + provider's built-in point-in-time backups. +- Create backups from the admin UI, the Tools CLI (`BlocksBeyondTheStars.Tools backup saves `), + or on a schedule. The Tools CLI also honors `BBS_DATABASE_PROVIDER=postgresql` + + `BBS_POSTGRES_CONNECTION_STRING`. - The world is `seed + parameters + player edits`: the procedural terrain is regenerated, only your changes are stored, keeping saves small. @@ -307,6 +332,17 @@ A quick end-to-end test on your own machine (Docker Desktop on Windows/macOS/Lin Docker pulls the image automatically. Add `-e BBS_FETCH_CLIENT=0` to skip the GitHub client-installer download for a pure offline server test. + To test a WebGL/browser client against the container, also publish the TCP/WebSocket gameplay port and + enable the WebSocket listener: + + ```bash + docker run -d --name bbts-web \ + -p 31415:31415/udp -p 31415:31415/tcp -p 31416:31416/tcp \ + -e BBS_ENABLE_WEBSOCKET=true -e BBS_WEBSOCKET_BIND=+ \ + -e BBS_ADMIN_PASSWORD=test123 -e BBS_SERVER_NAME="Local WebGL Test" \ + ghcr.io/marceld23/blocks-beyond-the-stars-server:latest + ``` + 2. **Check it's up** — open the admin dashboard at and the public portal at . `BBS_ADMIN_PASSWORD` gates the `/api/*` calls (the dashboard prompts for it); the dashboard/portal pages themselves are public. diff --git a/docs/developer/WEBCLIENT_FEASIBILITY.md b/docs/developer/WEBCLIENT_FEASIBILITY.md index 48910c82..9580f0a1 100644 --- a/docs/developer/WEBCLIENT_FEASIBILITY.md +++ b/docs/developer/WEBCLIENT_FEASIBILITY.md @@ -1,40 +1,70 @@ # Web client (Unity WebGL) — feasibility decision -Status: feasibility decided — not built · 2026-06-19 (re-verified 2026-06-26: the Wiki/Arcade blocker -is gone after the "Stream D" native-UI refactor — see Constraints/Blockers below) +Status: feasible and locally proven for hosted WebGL · 2026-06-29 +(2026-06-29 update: the WebGL client now joins a hosted authoritative server through browser WebSockets, uses a +JSON `NetCodec` envelope for WebGL/AOT, loads `StreamingAssets/data` through the generated manifest cache, and +renders authoritative chunks locally against a Docker PostgreSQL-backed server.) ## Decision -A browser client is **feasible with constraints** and would ship later as a reduced-quality "Lite" -profile. The **server side is ready now**; the actual WebGL build and the Lite client are deferred -because they require the Unity Editor and a meaningful client-side effort. This document records the -decision, the architecture that already supports it, and the remaining blockers — it is not a to-do -list. +A browser client is **feasible with constraints** and should ship as a reduced-quality hosted "Lite" profile. +The basic hosted gameplay lane now works locally: the WebGL player uses a browser WebSocket client transport to +talk to the same authoritative .NET server that desktop clients use. The remaining work is production hardening: +asset size, memory, longer browser soak tests, and official release-channel deployment. ## Why the architecture already supports it - **Transport abstraction.** `IServerTransport` / `IClientTransport` decouple game logic from the - wire. Native clients use `LiteNetLibTransport` (UDP); browsers would use `WebSocketServerTransport`. - Both carry identical `NetCodec` payloads, so the **same authoritative server** serves both. + wire. Native clients use `LiteNetLibTransport` (UDP); browsers use `BrowserWebSocketClientTransport` against + `WebSocketServerTransport`. +- **Protocol edge adaptation.** Native clients keep the MessagePack `NetCodec` payloads. Browser clients use a + tagged JSON `NetCodec` envelope at the WebSocket edge so Unity WebGL/IL2CPP does not depend on MessagePack + contractless runtime formatter generation. - **Composite transport.** `CompositeServerTransport` runs UDP + WebSocket together on the gameplay port, giving one connection space for mixed native/browser play. - **Server is authoritative.** The browser client decides nothing, so no client-type-specific trust rules are needed. -## What is implemented now (server side) +## What is implemented now - `WebSocketServerTransport` (browser-compatible, same protocol) + tests. - `CompositeServerTransport` (UDP + WS). - Server config `EnableWebSocket` / `WebSocketBindAddress`. +- `BrowserWebSocketClientTransport` for Unity WebGL, backed by `client/Assets/Plugins/BbsWebSocket.jslib`. +- WebSocket bridge support for `ArrayBuffer`, typed-array views, strings, and `Blob` frames, fixing the + `FileReader.readAsArrayBuffer` crash when the browser receives a non-Blob frame. +- JSON `NetCodec` envelope for browser WebSocket clients, with server-side conversion from the native + MessagePack send path. - Server web portal in the API: `/portal` landing page, `/play` browser-client placeholder. - **Native client distribution from the server** (the "download the client from the host" goal): a Velopack installer + auto-update feed (`scripts/publish-client-installer.ps1`), served at `/download` (Setup.exe) and `/updates` (feed), with an in-app `ClientUpdater`. See `SELF_HOSTING.md` §9. +- **WebGL build lane:** `BuildScript.BuildWebGL()` can produce a browser player folder. Startup handles + WebGL's HTTP-backed StreamingAssets by caching the JSON content locally before the shared content loader runs, + and preserves the shared/networking/client metadata that reflection-based JSON loading needs under IL2CPP. The + WebGL shell logs the cached `StreamingAssets/data` file count and loaded content counts. +- **Hosted server/database lane:** SQLite remains the default for local/native worlds; PostgreSQL is opt-in for + hosted realms and has a real Docker-backed smoke test path. + +## Browser smoke diagnosis (2026-06-29) + +The first hosted-browser smoke loaded the Unity payload and all `StreamingAssets/data` JSON successfully: the +captured HAR had no failed requests, and the manifest plus every listed content file returned 200/304. The +"empty world" screenshot was not an asset-load failure. It came from the old native gameplay path running inside +a browser: Singleplayer/Host tried to find a bundled native server under the HTTP-backed +`Application.streamingAssetsPath`, and Join/boot still constructed the default LiteNetLib UDP client. + +The local fix is now verified: WebGL joins through `BrowserWebSocketClientTransport`, the server speaks +WebSockets with a browser JSON envelope, and authoritative chunks render in the browser against a real .NET +server using PostgreSQL. Provider-specific deployment wiring is intentionally left for a separate follow-up. ## Key files - `src/BlocksBeyondTheStars.Networking/Transport/WebSocketServerTransport.cs` - `src/BlocksBeyondTheStars.Networking/Transport/CompositeServerTransport.cs` +- `client/Assets/BlocksBeyondTheStars/Scripts/BrowserWebSocketClientTransport.cs` +- `client/Assets/Plugins/BbsWebSocket.jslib` +- `src/BlocksBeyondTheStars.Networking/NetCodec.cs` - Server API portal (`/portal`, `/play`, `/download`, `/updates`). ## Constraints (browser vs native) @@ -48,25 +78,27 @@ list. | Input | Mouse+keyboard on desktop browsers (Chrome/Edge first); pointer-lock needed. | | Mobile | Out of scope initially. | -## Open blockers (Unity-side, need the Editor) +## Remaining hardening -- A `WebSocketClientTransport` for Unity (browser WebSocket via jslib) implementing `IClientTransport`. -- A Unity WebGL build profile (Lite quality) + asset bundling. -- MessagePack `ContractlessResolver` does not survive IL2CPP AOT — the wire serialization would need an - AOT-safe path for the WebGL build. +- A validated Unity WebGL Lite profile + asset bundling/shrinking. The build method exists and local play works, + but the full asset/runtime profile is still too large for a polished public browser launch. - ~~The in-game UWB wiki/arcade browser content is lost under WebGL.~~ **Resolved (2026-06-26):** the "Stream D" refactor replaced the embedded browser. The Wiki is now native uGUI (`WikiUI.cs`) and the Arcade runs an engine-free C# `MinigameHost` (`Client.Core/Minigames`) rendering Canvas2D→Texture2D→ RawImage (`MinigameHostUI.cs`). No UWB/CEF plugin remains and `BBS_UWB` is undefined, so both screens are already WebGL-compatible — this is no longer a blocker (and it also unblocked the Linux build). +- ~~A `WebSocketClientTransport` for Unity WebGL.~~ **Resolved (2026-06-29):** browser builds now use + `BrowserWebSocketClientTransport` + `BbsWebSocket.jslib`. +- ~~MessagePack `ContractlessResolver` does not survive IL2CPP AOT.~~ **Resolved for WebGL transport + (2026-06-29):** browser WebSocket clients use the JSON `NetCodec` envelope; native UDP remains MessagePack. - ~177 MB of `Resources` would have to be downloaded/streamed — shrink + bundle first. - Serving the built WebGL files from `/play` + version negotiation so the served client matches the server. +- Longer production browser soak on the official hosting target. ## Bottom line -Treat the browser client as a **~3–5 week** Lite-only sub-project taken on after the native client is -solid (down from 4–6 now that the Wiki/Arcade no longer need re-integration). Nothing on the server -needs to change to start it; the work is entirely the Unity WebGL build and the constraints/blockers -above. The two remaining hard risks are MessagePack-Contractless under IL2CPP/AOT and the absence of -in-browser singleplayer (Lite is multiplayer-only) — validate both as spikes before investing. +Treat the browser client as a **hosted Lite** path, not a replacement for the native desktop build. The largest +unknowns are no longer basic networking or IL2CPP serialization; they are production browser polish: download +size, memory limits, longer play sessions, deployment versioning, and accepting that WebGL is multiplayer/server +hosted rather than in-browser singleplayer. diff --git a/docs/user/USER_MANUAL.md b/docs/user/USER_MANUAL.md index 2cde6935..050aed51 100644 --- a/docs/user/USER_MANUAL.md +++ b/docs/user/USER_MANUAL.md @@ -8,7 +8,7 @@ chat/admin commands. This is a living document. > truth for player-facing operation. (Written in English per project doc policy; in-game text itself is > bilingual DE/EN.) -Last updated: 2026-06-27. +Last updated: 2026-06-29. --- @@ -68,11 +68,11 @@ Last updated: 2026-06-27. | **U** | Undock from a player / leave a boarded space station | | **V** | Toggle first / third-person camera | | **N** | Advance the current **VEGA** dialogue line (also fast-completes the typewriter) | -| **Tab** | Open / close the gameplay menu (Inventory, Crafting, Tech, Ship, Map, Missions, Character) | +| **Tab** | Open / close the gameplay menu (Inventory, Crafting, Tech, Ship, Map, Missions, Character); also closes full-screen menu screens such as the Codex | | **M** | Toggle the world map (top-down planet view; click to set a waypoint) | | **Enter** | Open the chat box (Esc cancels) | | **V** (hold) | Push-to-talk voice (if the server enabled voice; needs a radio; key is configurable) | -| **Esc** | Pause / close the current screen | +| **Esc** | Close the current screen; if no game screen is open, show the leave-game confirmation | Interaction reach is ~6 m (extended by reach equipment). @@ -95,6 +95,8 @@ Enter space by launching the ship; on foot you board/leave via the cockpit. Whil Ship classes differ in **speed** and **handling** (`data/ships.json`): e.g. the scout is fast and agile, the hauler slow and heavy. Hull + shield are shown on the HUD; shields recharge, hull does not. +Hosted/default servers allow free manual space flight so players can fly through a system without needing a +separate unlock; admins can still disable it through server world rules. --- @@ -104,6 +106,8 @@ the hauler slow and heavy. Hull + shield are shown on the HUD; shields recharge, Character (appearance), plus **Story**, **Companions** (tamed creatures, see §5) and **Alliances** (see §5), with **Settings** pinned far right. Crafting/Tech/Ship are **location-bound** (workshop / lab / ship console); the UI tells you when you must go to the right station. +- **Codex and DataQubes screens** — use the top-right **Close** button, **Esc**, or **Tab** to return to play. + **< Menu** returns from the full-screen screen to the normal Tab menu. - **Tab availability dimming** — tabs whose context isn't met are **greyed out** (but still clickable to peek): **Map** needs you aboard, **Crafting** a workshop, **Tech** a lab, **Ship** the ship console. While not aboard, the Map's travel buttons are also disabled (the world is shown but you can't quick-travel from on foot), and diff --git a/src/BlocksBeyondTheStars.Api/AdminDashboard.cs b/src/BlocksBeyondTheStars.Api/AdminDashboard.cs index feeea10a..ad97d0f0 100644 --- a/src/BlocksBeyondTheStars.Api/AdminDashboard.cs +++ b/src/BlocksBeyondTheStars.Api/AdminDashboard.cs @@ -86,6 +86,7 @@ async function loadStatus() { let html = ''; const rows = [['Server', s.serverName], ['World', s.worldName], ['Gameplay port', s.gameplayPort], ['Admin port', s.adminPort], ['Max players', s.maxPlayers], ['World exists', s.worldExists], + ['Persistence', s.persistenceBackend], ['World size', (s.worldSizeBytes/1024).toFixed(1) + ' KiB'], ['Last modified', s.lastModifiedUtc || '—'], ['Registered players', s.registeredPlayers], ['Backups', s.backupCount], ['Admin password set', s.adminPasswordSet]]; for (const [k,v] of rows) html += ``; diff --git a/src/BlocksBeyondTheStars.Api/AdminService.cs b/src/BlocksBeyondTheStars.Api/AdminService.cs index 7df76292..be83d8f8 100644 --- a/src/BlocksBeyondTheStars.Api/AdminService.cs +++ b/src/BlocksBeyondTheStars.Api/AdminService.cs @@ -31,6 +31,7 @@ public sealed class AdminStatus public int RegisteredPlayers { get; set; } public int BackupCount { get; set; } public bool AdminPasswordSet { get; set; } + public string PersistenceBackend { get; set; } = string.Empty; public string? Warning { get; set; } } @@ -91,28 +92,40 @@ public AdminStatus GetStatus() AdminPort = config.AdminPort, MaxPlayers = config.MaxPlayers, AdminPasswordSet = !string.IsNullOrEmpty(config.AdminPassword), - WorldExists = File.Exists(paths.DatabaseFile), + PersistenceBackend = WorldRepositoryFactory.DisplayName(config), + WorldExists = WorldRepositoryFactory.IsPostgreSql(config) || File.Exists(paths.DatabaseFile), }; if (status.WorldExists) { - var info = new FileInfo(paths.DatabaseFile); - status.WorldSizeBytes = info.Length; - status.LastModifiedUtc = info.LastWriteTimeUtc.ToString("yyyy-MM-dd HH:mm:ss"); + if (!WorldRepositoryFactory.IsPostgreSql(config)) + { + var info = new FileInfo(paths.DatabaseFile); + status.WorldSizeBytes = info.Length; + status.LastModifiedUtc = info.LastWriteTimeUtc.ToString("yyyy-MM-dd HH:mm:ss"); + } - using var repo = new SqliteWorldRepository(paths); - repo.Initialize(); - status.RegisteredPlayers = repo.ListPlayerIds().Count; + try + { + using var repo = WorldRepositoryFactory.Create(config, paths); + repo.Initialize(); + status.RegisteredPlayers = repo.ListPlayerIds().Count; + } + catch (Exception ex) + { + status.Warning = "Could not open the configured world database: " + ex.Message; + } } if (Directory.Exists(paths.BackupsDirectory)) { - status.BackupCount = Directory.GetFiles(paths.BackupsDirectory, "*.db").Length; + status.BackupCount = Directory.GetFiles(paths.BackupsDirectory, WorldRepositoryFactory.BackupSearchPattern(config)).Length; } if (!status.AdminPasswordSet) { - status.Warning = "No admin password is set. Keep the admin port bound to localhost/LAN only."; + string adminWarning = "No admin password is set. Keep the admin port bound to localhost/LAN only."; + status.Warning = string.IsNullOrEmpty(status.Warning) ? adminWarning : status.Warning + " " + adminWarning; } return status; @@ -120,13 +133,14 @@ public AdminStatus GetStatus() public IReadOnlyList ListBackups() { - var paths = PathsFor(LoadConfig()); + var config = LoadConfig(); + var paths = PathsFor(config); if (!Directory.Exists(paths.BackupsDirectory)) { return Array.Empty(); } - return Directory.GetFiles(paths.BackupsDirectory, "*.db") + return Directory.GetFiles(paths.BackupsDirectory, WorldRepositoryFactory.BackupSearchPattern(config)) .Select(f => new FileInfo(f)) .OrderByDescending(f => f.LastWriteTimeUtc) .Select(f => new BackupInfo @@ -140,13 +154,14 @@ public IReadOnlyList ListBackups() public string CreateBackup() { - var paths = PathsFor(LoadConfig()); - if (!File.Exists(paths.DatabaseFile)) + var config = LoadConfig(); + var paths = PathsFor(config); + if (!WorldRepositoryFactory.IsPostgreSql(config) && !File.Exists(paths.DatabaseFile)) { throw new InvalidOperationException("No world database exists to back up."); } - using var repo = new SqliteWorldRepository(paths); + using var repo = WorldRepositoryFactory.Create(config, paths); repo.Initialize(); var label = "backup_" + DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); return Path.GetFileName(repo.CreateBackup(label)); @@ -186,9 +201,10 @@ private GameContent Content() return _content; } - private SqliteWorldRepository OpenRepo() + private IWorldRepository OpenRepo() { - var repo = new SqliteWorldRepository(PathsFor(LoadConfig())); + var config = LoadConfig(); + var repo = WorldRepositoryFactory.Create(config, PathsFor(config)); repo.Initialize(); return repo; } diff --git a/src/BlocksBeyondTheStars.GameServer/GameServer.cs b/src/BlocksBeyondTheStars.GameServer/GameServer.cs index 96b4a9db..a6a1ca5c 100644 --- a/src/BlocksBeyondTheStars.GameServer/GameServer.cs +++ b/src/BlocksBeyondTheStars.GameServer/GameServer.cs @@ -192,6 +192,7 @@ public void Start() { _repo.Initialize(); + var launchRules = _config.Rules.Clone(); _meta = _repo.LoadMetadata() ?? CreateInitialMetadata(); // World options: once created, the WORLD owns its rules — the save's override replaces the launch @@ -202,6 +203,15 @@ public void Start() _config.Rules = _meta.RulesOverride; } + // Hosted servers can now opt everyone into free space flight from launch config/env. If an + // older world baked the previous default (off), preserve other saved world rules but lift this one. + if (launchRules.FreeSpaceFlight && !_config.Rules.FreeSpaceFlight) + { + _config.Rules.FreeSpaceFlight = true; + _meta.RulesOverride = _config.Rules.Clone(); + _log.Info("Free space flight enabled for this world by server launch rules."); + } + _repo.SaveMetadata(_meta); _generator = new WorldGenerator(_meta.Seed, _content); diff --git a/src/BlocksBeyondTheStars.GameServer/Program.cs b/src/BlocksBeyondTheStars.GameServer/Program.cs index 8180ab6f..a53d4eec 100644 --- a/src/BlocksBeyondTheStars.GameServer/Program.cs +++ b/src/BlocksBeyondTheStars.GameServer/Program.cs @@ -57,7 +57,8 @@ } var paths = new SaveGamePaths(savesRoot, config.WorldName); -using var repo = new SqliteWorldRepository(paths); +using var repo = WorldRepositoryFactory.Create(config, paths); +logger.Info($"Persistence backend: {WorldRepositoryFactory.DisplayName(config)}."); // Native UDP for the Windows client; optionally also WebSocket for browser clients // (same protocol, same authoritative server). Both share the gameplay port number. diff --git a/src/BlocksBeyondTheStars.Networking/NetCodec.cs b/src/BlocksBeyondTheStars.Networking/NetCodec.cs index 25732e1c..1413569f 100644 --- a/src/BlocksBeyondTheStars.Networking/NetCodec.cs +++ b/src/BlocksBeyondTheStars.Networking/NetCodec.cs @@ -1,6 +1,8 @@ // Blocks Beyond the Stars — Copyright (c) 2026 Justus Dütscher & Marcel Dütscher (JuMaVe Games) // SPDX-License-Identifier: AGPL-3.0-or-later // This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text. +using System.Text; +using System.Text.Json; using BlocksBeyondTheStars.Networking.Messages; using MessagePack; using MessagePack.Resolvers; @@ -14,9 +16,31 @@ namespace BlocksBeyondTheStars.Networking; /// public static class NetCodec { + private const byte JsonEnvelopeTag = 255; + public const int MaxJsonPayloadBytes = 1024 * 1024; + private const int MaxJsonDepth = 64; + private static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard.WithResolver(ContractlessStandardResolver.Instance); + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + MaxDepth = MaxJsonDepth, + }; + + private static readonly JsonDocumentOptions JsonDocumentOptions = new() + { + MaxDepth = MaxJsonDepth, + }; + + /// + /// WebGL/IL2CPP cannot rely on MessagePack's contractless runtime formatter path. + /// Browser transports set this flag so their outbound payloads use the JSON envelope below; native + /// clients and the server keep the compact MessagePack binary protocol. + /// + public static bool UseJsonEncoding { get; set; } + // Stable tag <-> type registry. Append new messages with new ids; never reuse ids. private static readonly Dictionary TagToType = new(); private static readonly Dictionary TypeToTag = new(); @@ -294,6 +318,9 @@ private static void Register(byte tag, Type type) } public static byte[] Encode(object message) + => UseJsonEncoding ? EncodeJson(message) : EncodeMessagePack(message); + + private static byte[] EncodeMessagePack(object message) { var type = message.GetType(); if (!TypeToTag.TryGetValue(type, out var tag)) @@ -308,12 +335,52 @@ public static byte[] Encode(object message) return payload; } + /// Encodes a tagged JSON payload for browser WebSocket clients. + public static byte[] EncodeJson(object message) + { + var type = message.GetType(); + if (!TypeToTag.TryGetValue(type, out var tag)) + { + throw new InvalidOperationException($"Message type '{type.Name}' is not registered with NetCodec."); + } + + string body = JsonSerializer.Serialize(message, type, JsonOptions); + string envelope = "{\"tag\":" + tag + ",\"body\":" + body + "}"; + byte[] json = Encoding.UTF8.GetBytes(envelope); + var payload = new byte[json.Length + 1]; + payload[0] = JsonEnvelopeTag; + Buffer.BlockCopy(json, 0, payload, 1, json.Length); + return payload; + } + + /// + /// Converts an already-encoded NetCodec payload to the browser JSON envelope. Used by the WebSocket server + /// transport so the authoritative server can keep its normal MessagePack send path internally. + /// + public static bool TryConvertToJsonPayload(byte[] payload, out byte[] jsonPayload) + { + var message = Decode(payload); + if (message == null) + { + jsonPayload = Array.Empty(); + return false; + } + + jsonPayload = EncodeJson(message); + return true; + } + /// Decodes a payload into a message object, or null if the tag is unknown/empty or the body is /// malformed. A corrupt/truncated/maliciously-shaped body must never throw out to the caller — a single /// bad packet would otherwise crash the single-threaded server tick (DoS); we swallow it and return null /// so the caller can drop the message. public static object? Decode(byte[] payload) { + if (payload.Length > 0 && payload[0] == JsonEnvelopeTag) + { + return DecodeJson(payload); + } + if (payload.Length == 0 || !TagToType.TryGetValue(payload[0], out var type)) { return null; @@ -329,4 +396,46 @@ public static byte[] Encode(object message) return null; // corrupt/truncated body for this tag — drop it } } + + private static object? DecodeJson(byte[] payload) + { + if (payload.Length <= 1 || payload.Length > MaxJsonPayloadBytes) + { + return null; + } + + try + { + string json = Encoding.UTF8.GetString(payload, 1, payload.Length - 1); + using var doc = JsonDocument.Parse(json, JsonDocumentOptions); + var root = doc.RootElement; + if (!root.TryGetProperty("tag", out var tagElement) + || !tagElement.TryGetInt32(out int tag) + || tag < 0 + || tag > byte.MaxValue) + { + return null; + } + + if (!TagToType.TryGetValue((byte)tag, out var type) + || !root.TryGetProperty("body", out var bodyElement)) + { + return null; + } + + return JsonSerializer.Deserialize(bodyElement.GetRawText(), type, JsonOptions); + } + catch (JsonException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + catch (NotSupportedException) + { + return null; + } + } } diff --git a/src/BlocksBeyondTheStars.Networking/Transport/WebSocketServerTransport.cs b/src/BlocksBeyondTheStars.Networking/Transport/WebSocketServerTransport.cs index 206f80f9..0f754c09 100644 --- a/src/BlocksBeyondTheStars.Networking/Transport/WebSocketServerTransport.cs +++ b/src/BlocksBeyondTheStars.Networking/Transport/WebSocketServerTransport.cs @@ -12,12 +12,14 @@ namespace BlocksBeyondTheStars.Networking.Transport; /// /// WebSocket server transport for browser clients (technical requirements / /// `anf_webclient.md` §8): browsers cannot open native UDP sockets, so the web client -/// connects over WebSocket and exchanges the exact same payloads as -/// native clients. Network events are queued on background threads and surfaced during +/// connects over WebSocket. Browser clients use 's JSON envelope to avoid +/// WebGL/IL2CPP contractless formatter generation, while native clients keep MessagePack. Network events are queued on background threads and surfaced during /// , matching the single-threaded, tick-driven server model. /// public sealed class WebSocketServerTransport : IServerTransport { + private const int MaxReceiveFrameBytes = NetCodec.MaxJsonPayloadBytes; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA1001:Types that own disposable fields should be disposable", Justification = "Per-connection holder; the socket is torn down when the receive loop ends or the listener stops, and SendLock is used only for WaitAsync/Release (no WaitHandle is ever allocated), so there is nothing requiring deterministic disposal.")] private sealed class Client @@ -67,8 +69,22 @@ private async Task AcceptLoopAsync() if (!ctx.Request.IsWebSocketRequest) { - ctx.Response.StatusCode = 400; - ctx.Response.Close(); + if (ctx.Request.HttpMethod == "GET" + && (ctx.Request.Url?.AbsolutePath is "/" or "/healthz")) + { + byte[] body = System.Text.Encoding.UTF8.GetBytes("Blocks Beyond the Stars WebSocket gateway\n"); + ctx.Response.StatusCode = 200; + ctx.Response.ContentType = "text/plain; charset=utf-8"; + ctx.Response.ContentLength64 = body.Length; + await ctx.Response.OutputStream.WriteAsync(body, 0, body.Length).ConfigureAwait(false); + ctx.Response.Close(); + } + else + { + ctx.Response.StatusCode = 400; + ctx.Response.Close(); + } + continue; } @@ -112,6 +128,14 @@ private async Task HandleClientAsync(HttpListenerContext ctx) } #pragma warning disable VSTHRD103 // MemoryStream.Write is an in-memory copy with nothing to await. + if (ms.Length + result.Count > MaxReceiveFrameBytes) + { + await client.Socket.CloseAsync(WebSocketCloseStatus.MessageTooBig, "Frame too large", CancellationToken.None) + .ConfigureAwait(false); + LogWarning($"Dropped oversized browser WebSocket frame from connection {id}."); + return; + } + ms.Write(buffer, 0, result.Count); #pragma warning restore VSTHRD103 } @@ -138,17 +162,31 @@ private async Task HandleClientAsync(HttpListenerContext ctx) public void Send(int connectionId, byte[] payload, DeliveryMode mode) { - if (_clients.TryGetValue(connectionId, out var client)) + if (!_clients.TryGetValue(connectionId, out var client)) + { + return; + } + + if (!NetCodec.TryConvertToJsonPayload(payload, out var browserPayload)) { - _ = SendAsync(client, payload); + LogWarning($"Dropped server payload for browser connection {connectionId}: could not convert NetCodec payload to JSON."); + return; } + + _ = SendAsync(client, browserPayload); } public void Broadcast(byte[] payload, DeliveryMode mode) { + if (!NetCodec.TryConvertToJsonPayload(payload, out var browserPayload)) + { + LogWarning("Dropped broadcast payload for browser clients: could not convert NetCodec payload to JSON."); + return; + } + foreach (var client in _clients.Values) { - _ = SendAsync(client, payload); + _ = SendAsync(client, browserPayload); } } @@ -173,6 +211,9 @@ await client.Socket.SendAsync(new ArraySegment(payload), WebSocketMessageT } } + private static void LogWarning(string message) + => System.Console.Error.WriteLine("[WARN] " + message); + public void Poll() { while (_events.TryDequeue(out var e)) diff --git a/src/BlocksBeyondTheStars.Persistence/BlocksBeyondTheStars.Persistence.csproj b/src/BlocksBeyondTheStars.Persistence/BlocksBeyondTheStars.Persistence.csproj index d8e1e6de..253d1c1b 100644 --- a/src/BlocksBeyondTheStars.Persistence/BlocksBeyondTheStars.Persistence.csproj +++ b/src/BlocksBeyondTheStars.Persistence/BlocksBeyondTheStars.Persistence.csproj @@ -6,6 +6,7 @@ + diff --git a/src/BlocksBeyondTheStars.Persistence/IWorldRepository.cs b/src/BlocksBeyondTheStars.Persistence/IWorldRepository.cs index dd7280ad..ef73e91c 100644 --- a/src/BlocksBeyondTheStars.Persistence/IWorldRepository.cs +++ b/src/BlocksBeyondTheStars.Persistence/IWorldRepository.cs @@ -152,9 +152,8 @@ public StoredFloraRegrow(Vector3i worldPosition, ushort block, double timer) } /// -/// Abstraction over savegame persistence. The default implementation is SQLite-backed -/// (portable); a PostgreSQL implementation can be added later -/// without touching the game server (technical requirements §10.2, §23.3). +/// Abstraction over savegame persistence. SQLite remains the portable default; PostgreSQL is available +/// for hosted dedicated servers that need managed storage and easier cloud operations. /// public interface IWorldRepository : IDisposable { diff --git a/src/BlocksBeyondTheStars.Persistence/PostgreSqlWorldRepository.cs b/src/BlocksBeyondTheStars.Persistence/PostgreSqlWorldRepository.cs new file mode 100644 index 00000000..43a65021 --- /dev/null +++ b/src/BlocksBeyondTheStars.Persistence/PostgreSqlWorldRepository.cs @@ -0,0 +1,1097 @@ +// Blocks Beyond the Stars — Copyright (c) 2026 Justus Dütscher & Marcel Dütscher (JuMaVe Games) +// SPDX-License-Identifier: AGPL-3.0-or-later +// This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text. +using System.Text.Json; +using BlocksBeyondTheStars.Shared.Geometry; +using BlocksBeyondTheStars.Shared.Missions; +using BlocksBeyondTheStars.Shared.State; +using BlocksBeyondTheStars.Shared.World; +using Npgsql; + +namespace BlocksBeyondTheStars.Persistence; + +/// +/// PostgreSQL-backed savegame repository. Stores world metadata, per-block player edits and +/// player/ship snapshots in a hosted database for long-running dedicated/MMO-style servers. +/// Each world is isolated in its own schema, while sidecar logs/backups still live on disk. +/// +public sealed class PostgreSqlWorldRepository : IWorldRepository +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false }; + + private readonly SaveGamePaths _paths; + private readonly object _gate = new(); + private NpgsqlConnection? _connection; + private readonly string _connectionString; + private readonly string _schemaName; + // True while a RunInTransaction batch is open (manual BEGIN/COMMIT via raw SQL — all the per-row write + // commands then run inside it at the PostgreSQL level without each needing an explicit transaction object). + // Guards against an illegal nested BEGIN so RunInTransaction can be called reentrantly. + private bool _inTransaction; + + public string WorldDirectory => _paths.WorldDirectory; + + public PostgreSqlWorldRepository(SaveGamePaths paths, string connectionString) + { + _paths = paths; + _connectionString = string.IsNullOrWhiteSpace(connectionString) + ? throw new ArgumentException("A PostgreSQL connection string is required.", nameof(connectionString)) + : connectionString; + _schemaName = "bbs_" + NormalizeSchemaSegment(Path.GetFileName(paths.WorldDirectory)); + } + + private NpgsqlConnection Connection => + _connection ?? throw new InvalidOperationException("Repository is not initialized. Call Initialize() first."); + + public void Initialize() + { + _paths.EnsureDirectories(); + + _connection = new NpgsqlConnection(_connectionString); + _connection.Open(); + + Execute($"CREATE SCHEMA IF NOT EXISTS {QuoteIdentifier(_schemaName)};"); + Execute($"SET search_path TO {QuoteIdentifier(_schemaName)};"); + + Execute(@" + CREATE TABLE IF NOT EXISTS world_meta (id INTEGER PRIMARY KEY CHECK (id = 0), json TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS block_edit ( + planet TEXT NOT NULL, x INTEGER NOT NULL, y INTEGER NOT NULL, z INTEGER NOT NULL, + block INTEGER NOT NULL, tint INTEGER NOT NULL DEFAULT 0, glow INTEGER NOT NULL DEFAULT 0, + shape INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (planet, x, y, z)); + CREATE TABLE IF NOT EXISTS player (id TEXT PRIMARY KEY, json TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS ship (id TEXT PRIMARY KEY, json TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS container ( + id TEXT PRIMARY KEY, planet TEXT NOT NULL, kind TEXT NOT NULL, + x INTEGER NOT NULL, y INTEGER NOT NULL, z INTEGER NOT NULL, json TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS door ( + planet TEXT NOT NULL, x INTEGER NOT NULL, y INTEGER NOT NULL, z INTEGER NOT NULL, + kind TEXT NOT NULL, axisx INTEGER NOT NULL, PRIMARY KEY (planet, x, y, z)); + CREATE TABLE IF NOT EXISTS beacon ( + planet TEXT NOT NULL, x INTEGER NOT NULL, y INTEGER NOT NULL, z INTEGER NOT NULL, + label TEXT NOT NULL, owner TEXT NOT NULL, PRIMARY KEY (planet, x, y, z)); + CREATE TABLE IF NOT EXISTS beam ( + planet TEXT NOT NULL, x INTEGER NOT NULL, y INTEGER NOT NULL, z INTEGER NOT NULL, + name TEXT NOT NULL, owner TEXT NOT NULL, PRIMARY KEY (planet, x, y, z)); + CREATE TABLE IF NOT EXISTS base_claim ( + planet TEXT NOT NULL, x INTEGER NOT NULL, y INTEGER NOT NULL, z INTEGER NOT NULL, + name TEXT NOT NULL, owner TEXT NOT NULL, PRIMARY KEY (planet, x, y, z)); + CREATE TABLE IF NOT EXISTS alliance ( + a TEXT NOT NULL, b TEXT NOT NULL, formed TEXT NOT NULL, PRIMARY KEY (a, b)); + CREATE TABLE IF NOT EXISTS story_state (story_id TEXT PRIMARY KEY, json TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS location_status (id TEXT PRIMARY KEY, status TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS mission (id TEXT PRIMARY KEY, json TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS space_structure ( + id TEXT PRIMARY KEY, owner TEXT NOT NULL, name TEXT NOT NULL, location TEXT NOT NULL, + px DOUBLE PRECISION NOT NULL, py DOUBLE PRECISION NOT NULL, pz DOUBLE PRECISION NOT NULL, boardable INTEGER NOT NULL, blocks TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS structure_edit ( + structure TEXT NOT NULL, x INTEGER NOT NULL, y INTEGER NOT NULL, z INTEGER NOT NULL, + block INTEGER NOT NULL, PRIMARY KEY (structure, x, y, z)); + CREATE TABLE IF NOT EXISTS flora_regrow ( + planet TEXT NOT NULL, x INTEGER NOT NULL, y INTEGER NOT NULL, z INTEGER NOT NULL, + block INTEGER NOT NULL, timer DOUBLE PRECISION NOT NULL, PRIMARY KEY (planet, x, y, z));"); + // (Landing pads are deterministic + live-occupancy now — no per-player landing_zone table; item 38.) + + // Migrate older saves to carry per-voxel colour modifiers (dyed blocks / coloured lights). The + // columns are added if absent; on a fresh DB they already exist from the CREATE above, so the + // ALTERs throw "duplicate column" and are harmlessly ignored. + TryExecute("ALTER TABLE block_edit ADD COLUMN IF NOT EXISTS tint INTEGER NOT NULL DEFAULT 0;"); + TryExecute("ALTER TABLE block_edit ADD COLUMN IF NOT EXISTS glow INTEGER NOT NULL DEFAULT 0;"); + // Migrate older saves to carry the per-voxel shape descriptor (non-cube building forms). Same pattern: + // harmlessly ignored on a fresh DB where the CREATE already added the column. + TryExecute("ALTER TABLE block_edit ADD COLUMN IF NOT EXISTS shape INTEGER NOT NULL DEFAULT 0;"); + } + + // --- Metadata --- + + public WorldMetadata? LoadMetadata() + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT json FROM world_meta WHERE id = 0;"; + var json = cmd.ExecuteScalar() as string; + return json is null ? null : JsonSerializer.Deserialize(json, JsonOptions); + } + } + + public void SaveMetadata(WorldMetadata metadata) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "INSERT INTO world_meta (id, json) VALUES (0, @json) " + + "ON CONFLICT(id) DO UPDATE SET json = excluded.json;"; + cmd.Parameters.AddWithValue("@json", JsonSerializer.Serialize(metadata, JsonOptions)); + cmd.ExecuteNonQuery(); + } + + WriteMetaSidecar(metadata); + } + + /// Mirrors a few headline stats into world.meta.json so the client world-picker can show + /// them without opening this PostgreSQL DB. Best-effort: a sidecar write failure never blocks the real (DB) + /// save — the picker simply falls back to showing the bare world name. + private void WriteMetaSidecar(WorldMetadata metadata) + { + try + { + var summary = new WorldSaveSummary + { + WorldName = metadata.WorldName, + PlaytimeSeconds = metadata.CumulativePlaytimeSeconds, + LastPlayedUtc = DateTime.UtcNow.ToString("o"), + }; + File.WriteAllText(_paths.MetaSidecarFile, JsonSerializer.Serialize(summary, JsonOptions)); + } + catch + { + // Non-fatal: the DB is the source of truth; the sidecar is a convenience for the menu. + } + } + + // --- Block edits --- + + public void SetBlock(string planet, Vector3i worldPosition, ushort block, int tint = 0, int glow = 0, int shape = 0) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "INSERT INTO block_edit (planet, x, y, z, block, tint, glow, shape) VALUES (@p, @x, @y, @z, @b, @t, @g, @s) " + + "ON CONFLICT(planet, x, y, z) DO UPDATE SET block = excluded.block, tint = excluded.tint, glow = excluded.glow, shape = excluded.shape;"; + cmd.Parameters.AddWithValue("@p", planet); + cmd.Parameters.AddWithValue("@x", worldPosition.X); + cmd.Parameters.AddWithValue("@y", worldPosition.Y); + cmd.Parameters.AddWithValue("@z", worldPosition.Z); + cmd.Parameters.AddWithValue("@b", (int)block); + cmd.Parameters.AddWithValue("@t", tint); + cmd.Parameters.AddWithValue("@g", glow); + cmd.Parameters.AddWithValue("@s", shape); + cmd.ExecuteNonQuery(); + } + } + + public void DeleteBlockEdits(string planet, Vector3i min, Vector3i max) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "DELETE FROM block_edit WHERE planet = @p " + + "AND x BETWEEN @minx AND @maxx AND y BETWEEN @miny AND @maxy AND z BETWEEN @minz AND @maxz;"; + cmd.Parameters.AddWithValue("@p", planet); + cmd.Parameters.AddWithValue("@minx", min.X); + cmd.Parameters.AddWithValue("@maxx", max.X); + cmd.Parameters.AddWithValue("@miny", min.Y); + cmd.Parameters.AddWithValue("@maxy", max.Y); + cmd.Parameters.AddWithValue("@minz", min.Z); + cmd.Parameters.AddWithValue("@maxz", max.Z); + cmd.ExecuteNonQuery(); + } + } + + public IReadOnlyList LoadChunkEdits(string planet, ChunkCoord chunk) + { + var origin = WorldConstants.ChunkOrigin(chunk); + int maxX = origin.X + WorldConstants.ChunkSize - 1; + int maxY = origin.Y + WorldConstants.ChunkSize - 1; + int maxZ = origin.Z + WorldConstants.ChunkSize - 1; + + var result = new List(); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT x, y, z, block, tint, glow, shape FROM block_edit WHERE planet = @p " + + "AND x BETWEEN @minx AND @maxx AND y BETWEEN @miny AND @maxy AND z BETWEEN @minz AND @maxz;"; + cmd.Parameters.AddWithValue("@p", planet); + cmd.Parameters.AddWithValue("@minx", origin.X); + cmd.Parameters.AddWithValue("@maxx", maxX); + cmd.Parameters.AddWithValue("@miny", origin.Y); + cmd.Parameters.AddWithValue("@maxy", maxY); + cmd.Parameters.AddWithValue("@minz", origin.Z); + cmd.Parameters.AddWithValue("@maxz", maxZ); + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var pos = new Vector3i(reader.GetInt32(0), reader.GetInt32(1), reader.GetInt32(2)); + result.Add(new BlockEdit(pos, (ushort)reader.GetInt32(3), reader.GetInt32(4), reader.GetInt32(5), reader.GetInt32(6))); + } + } + + return result; + } + + // --- Players --- + + public PlayerState? LoadPlayer(string playerId) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT json FROM player WHERE id = @id;"; + cmd.Parameters.AddWithValue("@id", playerId); + var json = cmd.ExecuteScalar() as string; + if (json is null) + { + return null; + } + + var snapshot = JsonSerializer.Deserialize(json, JsonOptions)!; + return StateMapper.FromSnapshot(snapshot); + } + } + + public void SavePlayer(PlayerState player) + { + var json = JsonSerializer.Serialize(StateMapper.ToSnapshot(player), JsonOptions); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "INSERT INTO player (id, json) VALUES (@id, @json) " + + "ON CONFLICT(id) DO UPDATE SET json = excluded.json;"; + cmd.Parameters.AddWithValue("@id", player.PlayerId); + cmd.Parameters.AddWithValue("@json", json); + cmd.ExecuteNonQuery(); + } + } + + public IReadOnlyList ListPlayerIds() + { + var ids = new List(); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT id FROM player;"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + ids.Add(reader.GetString(0)); + } + } + + return ids; + } + + // --- Ship --- + + public ShipState? LoadShip(string shipId) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT json FROM ship WHERE id = @id;"; + cmd.Parameters.AddWithValue("@id", shipId); + var json = cmd.ExecuteScalar() as string; + if (json is null) + { + return null; + } + + var snapshot = JsonSerializer.Deserialize(json, JsonOptions)!; + return StateMapper.FromSnapshot(snapshot); + } + } + + public void SaveShip(string shipId, ShipState ship) + { + var json = JsonSerializer.Serialize(StateMapper.ToSnapshot(ship), JsonOptions); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "INSERT INTO ship (id, json) VALUES (@id, @json) " + + "ON CONFLICT(id) DO UPDATE SET json = excluded.json;"; + cmd.Parameters.AddWithValue("@id", shipId); + cmd.Parameters.AddWithValue("@json", json); + cmd.ExecuteNonQuery(); + } + } + + // --- Containers --- + + public void SaveContainer(StoredContainer container) + { + var json = JsonSerializer.Serialize(container.Items, JsonOptions); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "INSERT INTO container (id, planet, kind, x, y, z, json) " + + "VALUES (@id, @p, @k, @x, @y, @z, @json) " + + "ON CONFLICT(id) DO UPDATE SET planet=excluded.planet, kind=excluded.kind, " + + "x=excluded.x, y=excluded.y, z=excluded.z, json=excluded.json;"; + cmd.Parameters.AddWithValue("@id", container.Id); + cmd.Parameters.AddWithValue("@p", container.Planet); + cmd.Parameters.AddWithValue("@k", container.Kind); + cmd.Parameters.AddWithValue("@x", container.Position.X); + cmd.Parameters.AddWithValue("@y", container.Position.Y); + cmd.Parameters.AddWithValue("@z", container.Position.Z); + cmd.Parameters.AddWithValue("@json", json); + cmd.ExecuteNonQuery(); + } + } + + public IReadOnlyList ListContainers(string planet) + { + var result = new List(); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT id, kind, x, y, z, json FROM container WHERE planet = @p;"; + cmd.Parameters.AddWithValue("@p", planet); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + result.Add(new StoredContainer + { + Id = reader.GetString(0), + Planet = planet, + Kind = reader.GetString(1), + Position = new Vector3i(reader.GetInt32(2), reader.GetInt32(3), reader.GetInt32(4)), + Items = JsonSerializer.Deserialize>(reader.GetString(5), JsonOptions) ?? new List(), + }); + } + } + + return result; + } + + public void DeleteContainer(string id) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "DELETE FROM container WHERE id = @id;"; + cmd.Parameters.AddWithValue("@id", id); + cmd.ExecuteNonQuery(); + } + } + + // --- Doors (player-built) --- + + public void SaveDoor(StoredDoor door) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "INSERT INTO door (planet, x, y, z, kind, axisx) " + + "VALUES (@p, @x, @y, @z, @k, @a) " + + "ON CONFLICT(planet, x, y, z) DO UPDATE SET kind=excluded.kind, axisx=excluded.axisx;"; + cmd.Parameters.AddWithValue("@p", door.Planet); + cmd.Parameters.AddWithValue("@x", door.X); + cmd.Parameters.AddWithValue("@y", door.Y); + cmd.Parameters.AddWithValue("@z", door.Z); + cmd.Parameters.AddWithValue("@k", door.Kind); + cmd.Parameters.AddWithValue("@a", door.AxisX ? 1 : 0); + cmd.ExecuteNonQuery(); + } + } + + public IReadOnlyList ListDoors(string planet) + { + var result = new List(); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT x, y, z, kind, axisx FROM door WHERE planet = @p;"; + cmd.Parameters.AddWithValue("@p", planet); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + result.Add(new StoredDoor + { + Planet = planet, + X = reader.GetInt32(0), + Y = reader.GetInt32(1), + Z = reader.GetInt32(2), + Kind = reader.GetString(3), + AxisX = reader.GetInt32(4) != 0, + }); + } + } + + return result; + } + + public void DeleteDoor(string planet, int x, int y, int z) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "DELETE FROM door WHERE planet = @p AND x = @x AND y = @y AND z = @z;"; + cmd.Parameters.AddWithValue("@p", planet); + cmd.Parameters.AddWithValue("@x", x); + cmd.Parameters.AddWithValue("@y", y); + cmd.Parameters.AddWithValue("@z", z); + cmd.ExecuteNonQuery(); + } + } + + // --- Flora regrowth (harvested plants returning on their cell) --- + + public void SaveFloraRegrow(string planet, Vector3i worldPosition, ushort block, double timer) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "INSERT INTO flora_regrow (planet, x, y, z, block, timer) " + + "VALUES (@p, @x, @y, @z, @b, @t) " + + "ON CONFLICT(planet, x, y, z) DO UPDATE SET block=excluded.block, timer=excluded.timer;"; + cmd.Parameters.AddWithValue("@p", planet); + cmd.Parameters.AddWithValue("@x", worldPosition.X); + cmd.Parameters.AddWithValue("@y", worldPosition.Y); + cmd.Parameters.AddWithValue("@z", worldPosition.Z); + cmd.Parameters.AddWithValue("@b", (int)block); + cmd.Parameters.AddWithValue("@t", timer); + cmd.ExecuteNonQuery(); + } + } + + public IReadOnlyList ListFloraRegrow(string planet) + { + var result = new List(); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT x, y, z, block, timer FROM flora_regrow WHERE planet = @p;"; + cmd.Parameters.AddWithValue("@p", planet); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + result.Add(new StoredFloraRegrow( + new Vector3i(reader.GetInt32(0), reader.GetInt32(1), reader.GetInt32(2)), + (ushort)reader.GetInt32(3), + reader.GetDouble(4))); + } + } + + return result; + } + + public void DeleteFloraRegrow(string planet, Vector3i worldPosition) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "DELETE FROM flora_regrow WHERE planet = @p AND x = @x AND y = @y AND z = @z;"; + cmd.Parameters.AddWithValue("@p", planet); + cmd.Parameters.AddWithValue("@x", worldPosition.X); + cmd.Parameters.AddWithValue("@y", worldPosition.Y); + cmd.Parameters.AddWithValue("@z", worldPosition.Z); + cmd.ExecuteNonQuery(); + } + } + + // --- Player-built space stations (item 20 S4) --- + + public void SaveSpaceStructure(StoredSpaceStructure s) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "INSERT INTO space_structure (id, owner, name, location, px, py, pz, boardable, blocks) " + + "VALUES (@id, @o, @n, @loc, @px, @py, @pz, @b, @blk) " + + "ON CONFLICT(id) DO UPDATE SET owner=excluded.owner, name=excluded.name, location=excluded.location, " + + "px=excluded.px, py=excluded.py, pz=excluded.pz, boardable=excluded.boardable, blocks=excluded.blocks;"; + cmd.Parameters.AddWithValue("@id", s.Id); + cmd.Parameters.AddWithValue("@o", s.OwnerId); + cmd.Parameters.AddWithValue("@n", s.Name); + cmd.Parameters.AddWithValue("@loc", s.Location); + cmd.Parameters.AddWithValue("@px", s.PosX); + cmd.Parameters.AddWithValue("@py", s.PosY); + cmd.Parameters.AddWithValue("@pz", s.PosZ); + cmd.Parameters.AddWithValue("@b", s.Boardable ? 1 : 0); + cmd.Parameters.AddWithValue("@blk", s.Blocks); + cmd.ExecuteNonQuery(); + } + } + + public IReadOnlyList ListSpaceStructures() + { + var result = new List(); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT id, owner, name, location, px, py, pz, boardable, blocks FROM space_structure;"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + result.Add(new StoredSpaceStructure + { + Id = reader.GetString(0), + OwnerId = reader.GetString(1), + Name = reader.GetString(2), + Location = reader.GetString(3), + PosX = (float)reader.GetDouble(4), + PosY = (float)reader.GetDouble(5), + PosZ = (float)reader.GetDouble(6), + Boardable = reader.GetInt32(7) != 0, + Blocks = reader.GetString(8), + }); + } + } + + return result; + } + + public void DeleteSpaceStructure(string id) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "DELETE FROM space_structure WHERE id = @id;"; + cmd.Parameters.AddWithValue("@id", id); + cmd.ExecuteNonQuery(); + } + } + + // --- In-space voxel structure edits (own-ship hull deltas, item 20) --- + + public void SetStructureBlock(string structureId, Vector3i position, ushort block) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "INSERT INTO structure_edit (structure, x, y, z, block) VALUES (@s, @x, @y, @z, @b) " + + "ON CONFLICT(structure, x, y, z) DO UPDATE SET block = excluded.block;"; + cmd.Parameters.AddWithValue("@s", structureId); + cmd.Parameters.AddWithValue("@x", position.X); + cmd.Parameters.AddWithValue("@y", position.Y); + cmd.Parameters.AddWithValue("@z", position.Z); + cmd.Parameters.AddWithValue("@b", (int)block); + cmd.ExecuteNonQuery(); + } + } + + public IReadOnlyList LoadStructureEdits(string structureId) + { + var result = new List(); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT x, y, z, block FROM structure_edit WHERE structure = @s;"; + cmd.Parameters.AddWithValue("@s", structureId); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var pos = new Vector3i(reader.GetInt32(0), reader.GetInt32(1), reader.GetInt32(2)); + result.Add(new BlockEdit(pos, (ushort)reader.GetInt32(3))); + } + } + + return result; + } + + public void DeleteStructureEdits(string structureId) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "DELETE FROM structure_edit WHERE structure = @s;"; + cmd.Parameters.AddWithValue("@s", structureId); + cmd.ExecuteNonQuery(); + } + } + + // --- Beacons (placed radio beacons, item 37) --- + + public void SaveBeacon(StoredBeacon beacon) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "INSERT INTO beacon (planet, x, y, z, label, owner) " + + "VALUES (@p, @x, @y, @z, @l, @o) " + + "ON CONFLICT(planet, x, y, z) DO UPDATE SET label=excluded.label, owner=excluded.owner;"; + cmd.Parameters.AddWithValue("@p", beacon.Planet); + cmd.Parameters.AddWithValue("@x", beacon.X); + cmd.Parameters.AddWithValue("@y", beacon.Y); + cmd.Parameters.AddWithValue("@z", beacon.Z); + cmd.Parameters.AddWithValue("@l", beacon.Label); + cmd.Parameters.AddWithValue("@o", beacon.OwnerId); + cmd.ExecuteNonQuery(); + } + } + + public IReadOnlyList ListBeacons(string planet) + { + var result = new List(); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT x, y, z, label, owner FROM beacon WHERE planet = @p;"; + cmd.Parameters.AddWithValue("@p", planet); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + result.Add(new StoredBeacon + { + Planet = planet, + X = reader.GetInt32(0), + Y = reader.GetInt32(1), + Z = reader.GetInt32(2), + Label = reader.GetString(3), + OwnerId = reader.GetString(4), + }); + } + } + + return result; + } + + public void DeleteBeacon(string planet, int x, int y, int z) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "DELETE FROM beacon WHERE planet = @p AND x = @x AND y = @y AND z = @z;"; + cmd.Parameters.AddWithValue("@p", planet); + cmd.Parameters.AddWithValue("@x", x); + cmd.Parameters.AddWithValue("@y", y); + cmd.Parameters.AddWithValue("@z", z); + cmd.ExecuteNonQuery(); + } + } + + // --- Beam blocks (placed teleporter pads) --- + + public void SaveBeam(StoredBeam beam) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "INSERT INTO beam (planet, x, y, z, name, owner) " + + "VALUES (@p, @x, @y, @z, @n, @o) " + + "ON CONFLICT(planet, x, y, z) DO UPDATE SET name=excluded.name, owner=excluded.owner;"; + cmd.Parameters.AddWithValue("@p", beam.Planet); + cmd.Parameters.AddWithValue("@x", beam.X); + cmd.Parameters.AddWithValue("@y", beam.Y); + cmd.Parameters.AddWithValue("@z", beam.Z); + cmd.Parameters.AddWithValue("@n", beam.Name); + cmd.Parameters.AddWithValue("@o", beam.OwnerId); + cmd.ExecuteNonQuery(); + } + } + + public IReadOnlyList ListBeams(string planet) + { + var result = new List(); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT x, y, z, name, owner FROM beam WHERE planet = @p;"; + cmd.Parameters.AddWithValue("@p", planet); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + result.Add(new StoredBeam + { + Planet = planet, + X = reader.GetInt32(0), + Y = reader.GetInt32(1), + Z = reader.GetInt32(2), + Name = reader.GetString(3), + OwnerId = reader.GetString(4), + }); + } + } + + return result; + } + + public void DeleteBeam(string planet, int x, int y, int z) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "DELETE FROM beam WHERE planet = @p AND x = @x AND y = @y AND z = @z;"; + cmd.Parameters.AddWithValue("@p", planet); + cmd.Parameters.AddWithValue("@x", x); + cmd.Parameters.AddWithValue("@y", y); + cmd.Parameters.AddWithValue("@z", z); + cmd.ExecuteNonQuery(); + } + } + + // --- Planet bases (player-founded "Grundstein" claims) --- + + public void SaveBase(StoredBase basePoint) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "INSERT INTO base_claim (planet, x, y, z, name, owner) " + + "VALUES (@p, @x, @y, @z, @n, @o) " + + "ON CONFLICT(planet, x, y, z) DO UPDATE SET name=excluded.name, owner=excluded.owner;"; + cmd.Parameters.AddWithValue("@p", basePoint.Planet); + cmd.Parameters.AddWithValue("@x", basePoint.X); + cmd.Parameters.AddWithValue("@y", basePoint.Y); + cmd.Parameters.AddWithValue("@z", basePoint.Z); + cmd.Parameters.AddWithValue("@n", basePoint.Name); + cmd.Parameters.AddWithValue("@o", basePoint.OwnerId); + cmd.ExecuteNonQuery(); + } + } + + public IReadOnlyList ListAllBases() + { + var result = new List(); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT planet, x, y, z, name, owner FROM base_claim;"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + result.Add(new StoredBase + { + Planet = reader.GetString(0), + X = reader.GetInt32(1), + Y = reader.GetInt32(2), + Z = reader.GetInt32(3), + Name = reader.GetString(4), + OwnerId = reader.GetString(5), + }); + } + } + + return result; + } + + public void DeleteBase(string planet, int x, int y, int z) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "DELETE FROM base_claim WHERE planet = @p AND x = @x AND y = @y AND z = @z;"; + cmd.Parameters.AddWithValue("@p", planet); + cmd.Parameters.AddWithValue("@x", x); + cmd.Parameters.AddWithValue("@y", y); + cmd.Parameters.AddWithValue("@z", z); + cmd.ExecuteNonQuery(); + } + } + + // --- Alliances (player-to-player, server-wide) --- + + public void SaveAlliance(StoredAlliance alliance) + { + var (a, b) = NormalizePair(alliance.PlayerA, alliance.PlayerB); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "INSERT INTO alliance (a, b, formed) VALUES (@a, @b, @f) " + + "ON CONFLICT(a, b) DO UPDATE SET formed = excluded.formed;"; + cmd.Parameters.AddWithValue("@a", a); + cmd.Parameters.AddWithValue("@b", b); + cmd.Parameters.AddWithValue("@f", alliance.FormedUtc); + cmd.ExecuteNonQuery(); + } + } + + public IReadOnlyList ListAlliances() + { + var result = new List(); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT a, b, formed FROM alliance;"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + result.Add(new StoredAlliance + { + PlayerA = reader.GetString(0), + PlayerB = reader.GetString(1), + FormedUtc = reader.GetString(2), + }); + } + } + + return result; + } + + public void DeleteAlliance(string playerA, string playerB) + { + var (a, b) = NormalizePair(playerA, playerB); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "DELETE FROM alliance WHERE a = @a AND b = @b;"; + cmd.Parameters.AddWithValue("@a", a); + cmd.Parameters.AddWithValue("@b", b); + cmd.ExecuteNonQuery(); + } + } + + /// Orders a player-id pair so each alliance is stored under exactly one (a, b) key. + private static (string A, string B) NormalizePair(string x, string y) + => string.CompareOrdinal(x, y) <= 0 ? (x, y) : (y, x); + + // --- Story state (per active story pack, server-wide) --- + + public void SaveStoryState(StoredStoryState state) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "INSERT INTO story_state (story_id, json) VALUES (@id, @json) " + + "ON CONFLICT(story_id) DO UPDATE SET json = excluded.json;"; + cmd.Parameters.AddWithValue("@id", state.StoryId); + cmd.Parameters.AddWithValue("@json", JsonSerializer.Serialize(state, JsonOptions)); + cmd.ExecuteNonQuery(); + } + } + + public IReadOnlyList ListStoryStates() + { + var result = new List(); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT json FROM story_state;"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + if (JsonSerializer.Deserialize(reader.GetString(0), JsonOptions) is { } s) + { + result.Add(s); + } + } + } + + return result; + } + + // --- Location status --- + + public void SetLocationStatus(string locationId, string status) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "INSERT INTO location_status (id, status) VALUES (@id, @s) " + + "ON CONFLICT(id) DO UPDATE SET status = excluded.status;"; + cmd.Parameters.AddWithValue("@id", locationId); + cmd.Parameters.AddWithValue("@s", status); + cmd.ExecuteNonQuery(); + } + } + + public IReadOnlyDictionary LoadLocationStatuses() + { + var map = new Dictionary(); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT id, status FROM location_status;"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + map[reader.GetString(0)] = reader.GetString(1); + } + } + + return map; + } + + // --- Missions (player/admin-created) --- + + public void SaveMission(MissionDefinition mission) + { + var json = JsonSerializer.Serialize(mission, JsonOptions); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "INSERT INTO mission (id, json) VALUES (@id, @json) " + + "ON CONFLICT(id) DO UPDATE SET json = excluded.json;"; + cmd.Parameters.AddWithValue("@id", mission.Id); + cmd.Parameters.AddWithValue("@json", json); + cmd.ExecuteNonQuery(); + } + } + + public IReadOnlyList ListMissions() + { + var result = new List(); + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT json FROM mission;"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var m = JsonSerializer.Deserialize(reader.GetString(0), JsonOptions); + if (m is not null) + { + result.Add(m); + } + } + } + + return result; + } + + public void DeleteMission(string id) + { + lock (_gate) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "DELETE FROM mission WHERE id = @id;"; + cmd.Parameters.AddWithValue("@id", id); + cmd.ExecuteNonQuery(); + } + } + + // --- Maintenance --- + + public void RunInTransaction(Action body) + { + // The _gate is a Monitor (reentrant on the same thread), so the per-row write methods called inside + // body() can re-acquire it freely. Holding it for the whole batch also keeps the transaction atomic + // against any other thread that might write through the same connection. + lock (_gate) + { + if (_inTransaction) + { + body(); // already inside a batch — PostgreSQL forbids a nested BEGIN, so just join it + return; + } + + _inTransaction = true; + Execute("BEGIN;"); + try + { + body(); + Execute("COMMIT;"); + } + catch + { + Execute("ROLLBACK;"); + throw; + } + finally + { + _inTransaction = false; + } + } + } + + public void Flush() + { + lock (_gate) + { + // PostgreSQL commits each statement/transaction durably; no client-side WAL checkpoint is required. + } + } + + public string CreateBackup(string label) + { + lock (_gate) + { + Flush(); + foreach (var c in Path.GetInvalidFileNameChars()) + { + label = label.Replace(c, '_'); + } + + var target = Path.Combine(_paths.BackupsDirectory, label + ".postgresql.json"); + if (File.Exists(target)) + { + File.Delete(target); + } + + var dump = new Dictionary>>(); + foreach (string table in BackupTables) + { + dump[table] = ReadTableForBackup(table); + } + + File.WriteAllText(target, JsonSerializer.Serialize(dump, JsonOptions)); + return target; + } + } + + private static readonly string[] BackupTables = + { + "world_meta", "block_edit", "player", "ship", "container", "door", "beacon", "beam", + "base_claim", "alliance", "story_state", "location_status", "mission", + "space_structure", "structure_edit", "flora_regrow", + }; + + private List> ReadTableForBackup(string table) + { + var rows = new List>(); + using var cmd = Connection.CreateCommand(); + cmd.CommandText = $"SELECT * FROM {QuoteIdentifier(table)};"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var row = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < reader.FieldCount; i++) + { + row[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i); + } + + rows.Add(row); + } + + return rows; + } + + private static string NormalizeSchemaSegment(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "world"; + } + + var chars = value.ToLowerInvariant().ToCharArray(); + for (int i = 0; i < chars.Length; i++) + { + bool ok = (chars[i] >= 'a' && chars[i] <= 'z') || (chars[i] >= '0' && chars[i] <= '9'); + chars[i] = ok ? chars[i] : '_'; + } + + var normalized = new string(chars).Trim('_'); + return string.IsNullOrEmpty(normalized) ? "world" : normalized; + } + + private static string QuoteIdentifier(string value) + => "\"" + (value ?? string.Empty).Replace("\"", "\"\"") + "\""; + + private void Execute(string sql) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + + /// Runs DDL that may legitimately fail on an up-to-date schema (e.g. an idempotent + /// ADD COLUMN migration that the CREATE already satisfied); swallows the error. + private void TryExecute(string sql) + { + try + { + Execute(sql); + } + catch (PostgresException) + { + // Column already exists / nothing to migrate. + } + } + + public void Dispose() + { + lock (_gate) + { + if (_connection is not null) + { + _connection.Dispose(); + _connection = null; + } + + // Release the pooled native connection handles so tests and short-lived tools do not keep sockets open. + NpgsqlConnection.ClearAllPools(); + } + } +} diff --git a/src/BlocksBeyondTheStars.Persistence/WorldRepositoryFactory.cs b/src/BlocksBeyondTheStars.Persistence/WorldRepositoryFactory.cs new file mode 100644 index 00000000..53b841bf --- /dev/null +++ b/src/BlocksBeyondTheStars.Persistence/WorldRepositoryFactory.cs @@ -0,0 +1,42 @@ +// Blocks Beyond the Stars — Copyright (c) 2026 Justus Dütscher & Marcel Dütscher (JuMaVe Games) +// SPDX-License-Identifier: AGPL-3.0-or-later +// This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text. +using BlocksBeyondTheStars.Shared.Configuration; + +namespace BlocksBeyondTheStars.Persistence; + +/// +/// Central persistence selector used by the dedicated server and admin API. SQLite stays the zero-config +/// local default; PostgreSQL is opt-in through config/environment so hosted deployments never require +/// code changes or committed credentials. +/// +public static class WorldRepositoryFactory +{ + public const string ProviderSqlite = "sqlite"; + public const string ProviderPostgreSql = "postgresql"; + + public static IWorldRepository Create(ServerConfig config, SaveGamePaths paths) + { + if (IsPostgreSql(config.DatabaseProvider)) + { + return new PostgreSqlWorldRepository(paths, config.PostgresConnectionString); + } + + return new SqliteWorldRepository(paths); + } + + public static bool IsPostgreSql(ServerConfig config) + => IsPostgreSql(config.DatabaseProvider); + + public static string DisplayName(ServerConfig config) + => IsPostgreSql(config) ? "PostgreSQL" : "SQLite"; + + public static string BackupSearchPattern(ServerConfig config) + => IsPostgreSql(config) ? "*.postgresql.json" : "*.db"; + + private static bool IsPostgreSql(string? provider) + { + string value = (provider ?? string.Empty).Trim().ToLowerInvariant(); + return value is "postgres" or "postgresql" or "pg"; + } +} diff --git a/src/BlocksBeyondTheStars.Shared/Configuration/GameRules.cs b/src/BlocksBeyondTheStars.Shared/Configuration/GameRules.cs index cc80f7d2..716f1122 100644 --- a/src/BlocksBeyondTheStars.Shared/Configuration/GameRules.cs +++ b/src/BlocksBeyondTheStars.Shared/Configuration/GameRules.cs @@ -135,7 +135,7 @@ public sealed class GameRules // --- Space flight / combat / enemies / docking / landing zones --- - public bool FreeSpaceFlight { get; set; } + public bool FreeSpaceFlight { get; set; } = true; public SpaceCombatMode SpaceCombat { get; set; } = SpaceCombatMode.Off; public ShipWeaponMode ShipWeapons { get; set; } = ShipWeaponMode.Off; public AlienActivity SpaceNpcEnemies { get; set; } = AlienActivity.Off; diff --git a/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs b/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs index 81c2e16a..5da5e163 100644 --- a/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs +++ b/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs @@ -97,6 +97,16 @@ public sealed class ServerConfig public string SavesRoot { get; set; } = "saves"; public string DataDir { get; set; } = "data"; + /// + /// Persistence backend for authoritative world state. "sqlite" is the portable default; "postgresql" + /// uses and is intended for hosted dedicated/MMO-style servers. + /// + public string DatabaseProvider { get; set; } = "sqlite"; + + /// PostgreSQL connection string. Prefer supplying this through BBS_POSTGRES_CONNECTION_STRING + /// or DATABASE_URL in hosted deployments instead of committing it to server.json. + public string PostgresConnectionString { get; set; } = string.Empty; + /// Optional writable folder holding in-game-editor structure templates /// (station_templates/*.json, settlement_templates/*.json). When set, they are merged /// into the template pools at load so player-authored structures appear in new worlds without a @@ -251,6 +261,15 @@ public IReadOnlyList ApplyCommandLine(string[]? args) case "user-content": UserContentDir = value; applied.Add("usercontent"); break; + case "database": + case "database-provider": + DatabaseProvider = value; applied.Add("database-provider"); + break; + case "postgres": + case "postgres-connection": + case "postgres-connection-string": + PostgresConnectionString = value; applied.Add("postgres-connection-string"); + break; case "world": case "world-name": WorldName = value; applied.Add("world"); @@ -456,10 +475,16 @@ public IReadOnlyList ApplyEnvironment() if (Env("BBS_SAVES") is { } saves) { SavesRoot = saves; applied.Add("BBS_SAVES"); } if (Env("BBS_DATA") is { } data) { DataDir = data; applied.Add("BBS_DATA"); } if (Env("BBS_USERCONTENT") is { } userContent) { UserContentDir = userContent; applied.Add("BBS_USERCONTENT"); } + if ((Env("BBS_DATABASE_PROVIDER") ?? Env("BBS_DATABASE")) is { } databaseProvider) { DatabaseProvider = databaseProvider; applied.Add("BBS_DATABASE_PROVIDER"); } + if ((Env("BBS_POSTGRES_CONNECTION_STRING") ?? Env("DATABASE_URL")) is { } pg) { PostgresConnectionString = pg; applied.Add("BBS_POSTGRES_CONNECTION_STRING"); } if (Env("BBS_SEED") is { } seedStr && long.TryParse(seedStr, out var seed)) { Seed = seed; applied.Add("BBS_SEED"); } if (Env("BBS_START_PLANET") is { } startPlanet) { StartPlanet = startPlanet.Trim(); applied.Add("BBS_START_PLANET"); } if (Env("BBS_TICK_RATE") is { } tickStr && int.TryParse(tickStr, out var tick)) { TickRate = tick; applied.Add("BBS_TICK_RATE"); } if (Env("BBS_VIEW_DISTANCE") is { } vdStr && int.TryParse(vdStr, out var vd)) { ViewDistanceChunks = vd; applied.Add("BBS_VIEW_DISTANCE"); } + if (Env("BBS_FREE_FLIGHT") is { } ffStr && bool.TryParse(ffStr, out var ff)) { Rules.FreeSpaceFlight = ff; applied.Add("BBS_FREE_FLIGHT"); } + if (Env("BBS_SPACE_COMBAT") is { } scStr && Enum.TryParse(scStr, ignoreCase: true, out var sc)) { Rules.SpaceCombat = sc; applied.Add("BBS_SPACE_COMBAT"); } + if (Env("BBS_SHIP_WEAPONS") is { } swStr && Enum.TryParse(swStr, ignoreCase: true, out var sw)) { Rules.ShipWeapons = sw; applied.Add("BBS_SHIP_WEAPONS"); } + if (Env("BBS_SPACE_NPCS") is { } snStr && Enum.TryParse(snStr, ignoreCase: true, out var sn)) { Rules.SpaceNpcEnemies = sn; applied.Add("BBS_SPACE_NPCS"); } if (Env("BBS_AI_LEVEL") is { } aiStr && Enum.TryParse(aiStr, ignoreCase: true, out var ai)) { AiLevel = ai; applied.Add("BBS_AI_LEVEL"); } if (Env("BBS_AI_BACKEND_URL") is { } aiUrl) { AiBackendUrl = aiUrl; applied.Add("BBS_AI_BACKEND_URL"); } if (Env("BBS_CRASH_REPORT_ENDPOINT") is { } crashUrl) { CrashReportEndpoint = crashUrl; applied.Add("BBS_CRASH_REPORT_ENDPOINT"); } diff --git a/src/BlocksBeyondTheStars.Tools/Program.cs b/src/BlocksBeyondTheStars.Tools/Program.cs index 7f241c0e..d4b1eb74 100644 --- a/src/BlocksBeyondTheStars.Tools/Program.cs +++ b/src/BlocksBeyondTheStars.Tools/Program.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text. using BlocksBeyondTheStars.Persistence; +using BlocksBeyondTheStars.Shared.Configuration; using BlocksBeyondTheStars.Shared.Content; // BlocksBeyondTheStars server tools — a small CLI for hosts and developers: validate content, @@ -56,6 +57,7 @@ static void PrintUsage() Console.WriteLine(" BlocksBeyondTheStars.Tools backup Create a consistent world backup"); Console.WriteLine(" BlocksBeyondTheStars.Tools export-pack Export admin content pack"); Console.WriteLine(" BlocksBeyondTheStars.Tools import-pack [dataDir] Import & validate a content pack"); + Console.WriteLine("Set BBS_DATABASE_PROVIDER=postgresql and BBS_POSTGRES_CONNECTION_STRING for hosted PostgreSQL worlds."); } static void RequireArgs(string[] args, int count, string usage) @@ -82,34 +84,41 @@ static int ValidateContent(string dataDir) static int WorldInfo(string savesRoot, string world) { var paths = new SaveGamePaths(savesRoot, world); - if (!File.Exists(paths.DatabaseFile)) + var config = ToolConfig(savesRoot, world); + if (!WorldRepositoryFactory.IsPostgreSql(config) && !File.Exists(paths.DatabaseFile)) { Console.Error.WriteLine($"No world database at {paths.DatabaseFile}"); return 1; } - using var repo = new SqliteWorldRepository(paths); + using var repo = WorldRepositoryFactory.Create(config, paths); repo.Initialize(); var meta = repo.LoadMetadata(); Console.WriteLine($"World: {meta?.WorldName ?? "(unknown)"}"); + Console.WriteLine($"Backend: {WorldRepositoryFactory.DisplayName(config)}"); Console.WriteLine($"Seed: {meta?.Seed}"); Console.WriteLine($"Start planet: {meta?.DefaultPlanetType}"); Console.WriteLine($"Save version: {meta?.SaveVersion}"); Console.WriteLine($"Players: {repo.ListPlayerIds().Count}"); - Console.WriteLine($"DB size: {new FileInfo(paths.DatabaseFile).Length / 1024.0:0.0} KiB"); + if (!WorldRepositoryFactory.IsPostgreSql(config)) + { + Console.WriteLine($"DB size: {new FileInfo(paths.DatabaseFile).Length / 1024.0:0.0} KiB"); + } + return 0; } static int Backup(string savesRoot, string world) { var paths = new SaveGamePaths(savesRoot, world); - if (!File.Exists(paths.DatabaseFile)) + var config = ToolConfig(savesRoot, world); + if (!WorldRepositoryFactory.IsPostgreSql(config) && !File.Exists(paths.DatabaseFile)) { Console.Error.WriteLine($"No world database at {paths.DatabaseFile}"); return 1; } - using var repo = new SqliteWorldRepository(paths); + using var repo = WorldRepositoryFactory.Create(config, paths); repo.Initialize(); var path = repo.CreateBackup("backup_" + DateTime.UtcNow.ToString("yyyyMMdd_HHmmss")); Console.WriteLine($"Backup created: {path}"); @@ -118,7 +127,7 @@ static int Backup(string savesRoot, string world) static int ExportPack(string savesRoot, string world, string outFile) { - using var repo = new SqliteWorldRepository(new SaveGamePaths(savesRoot, world)); + using var repo = OpenToolRepository(savesRoot, world); repo.Initialize(); var pack = new BlocksBeyondTheStars.Shared.Missions.ContentPack { Name = world + "-content", Missions = repo.ListMissions().ToList() }; File.WriteAllText(outFile, System.Text.Json.JsonSerializer.Serialize(pack, new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); @@ -142,7 +151,7 @@ static int ImportPack(string savesRoot, string world, string inFile, string data return 1; } - using var repo = new SqliteWorldRepository(new SaveGamePaths(savesRoot, world)); + using var repo = OpenToolRepository(savesRoot, world); repo.Initialize(); int imported = 0, rejected = 0; foreach (var mission in pack.Missions) @@ -162,3 +171,18 @@ static int ImportPack(string savesRoot, string world, string inFile, string data Console.WriteLine($"Imported {imported}, rejected {rejected}."); return rejected == 0 ? 0 : 2; } + +static ServerConfig ToolConfig(string savesRoot, string world) +{ + var config = new ServerConfig(); + config.ApplyEnvironment(); + config.SavesRoot = savesRoot; + config.WorldName = world; + return config; +} + +static IWorldRepository OpenToolRepository(string savesRoot, string world) +{ + var config = ToolConfig(savesRoot, world); + return WorldRepositoryFactory.Create(config, new SaveGamePaths(savesRoot, world)); +} diff --git a/tests/BlocksBeyondTheStars.Tests/AdminServiceTests.cs b/tests/BlocksBeyondTheStars.Tests/AdminServiceTests.cs index cf9c17cf..c1844ea6 100644 --- a/tests/BlocksBeyondTheStars.Tests/AdminServiceTests.cs +++ b/tests/BlocksBeyondTheStars.Tests/AdminServiceTests.cs @@ -55,6 +55,7 @@ public void Status_ReportsWorldAndBackup_AfterWorldCreated() var before = admin.GetStatus(); Assert.True(before.WorldExists); Assert.Equal(1, before.RegisteredPlayers); + Assert.Equal("SQLite", before.PersistenceBackend); var backupName = admin.CreateBackup(); Assert.EndsWith(".db", backupName); diff --git a/tests/BlocksBeyondTheStars.Tests/BlocksBeyondTheStars.Tests.csproj b/tests/BlocksBeyondTheStars.Tests/BlocksBeyondTheStars.Tests.csproj index 318deae4..d5ba3c0f 100644 --- a/tests/BlocksBeyondTheStars.Tests/BlocksBeyondTheStars.Tests.csproj +++ b/tests/BlocksBeyondTheStars.Tests/BlocksBeyondTheStars.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/tests/BlocksBeyondTheStars.Tests/PostgreSqlRepositoryTests.cs b/tests/BlocksBeyondTheStars.Tests/PostgreSqlRepositoryTests.cs new file mode 100644 index 00000000..eeefdee3 --- /dev/null +++ b/tests/BlocksBeyondTheStars.Tests/PostgreSqlRepositoryTests.cs @@ -0,0 +1,122 @@ +// Blocks Beyond the Stars — Copyright (c) 2026 Justus Dütscher & Marcel Dütscher (JuMaVe Games) +// SPDX-License-Identifier: AGPL-3.0-or-later +// This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text. +using BlocksBeyondTheStars.Persistence; +using BlocksBeyondTheStars.Shared.Geometry; +using BlocksBeyondTheStars.Shared.Missions; +using BlocksBeyondTheStars.Shared.State; +using BlocksBeyondTheStars.Shared.World; +using Npgsql; + +namespace BlocksBeyondTheStars.Tests; + +/// +/// Real PostgreSQL smoke tests. They are opt-in because ordinary local/CI runs should not require Docker or a +/// hosted database; set BBS_POSTGRES_TEST_CONNECTION_STRING to run them against an actual PostgreSQL server. +/// +public sealed class PostgreSqlRepositoryTests +{ + private const string ConnectionStringEnv = "BBS_POSTGRES_TEST_CONNECTION_STRING"; + + [Fact] + public void PostgreSqlRepository_RoundTripsAgainstRealDatabase() + { + string? connectionString = Environment.GetEnvironmentVariable(ConnectionStringEnv); + if (string.IsNullOrWhiteSpace(connectionString)) + { + return; + } + + string world = "pg_" + Guid.NewGuid().ToString("N"); + string root = Path.Combine(Path.GetTempPath(), "bbts_pg_" + Guid.NewGuid().ToString("N")); + string schema = SchemaNameFor(world); + try + { + AssertPostgreSqlServerResponds(connectionString); + + var paths = new SaveGamePaths(root, world); + using var repo = new PostgreSqlWorldRepository(paths, connectionString); + repo.Initialize(); + repo.SaveMetadata(new WorldMetadata { WorldName = world, Seed = 987654321, DefaultPlanetType = "ice" }); + repo.SavePlayer(new PlayerState { PlayerId = "pilot", Name = "Pilot" }); + repo.SaveShip("ship_pilot", new ShipState { CurrentLocationId = "ice" }); + repo.SetBlock("ice", new Vector3i(1, 2, 3), 42, tint: 7, glow: 3, shape: 5); + repo.SaveMission(new MissionDefinition { Id = "admin_pg", Title = "PostgreSQL smoke mission" }); + + repo.RunInTransaction(() => + { + repo.SetLocationStatus("sys0-p1", "visited"); + repo.SaveAlliance(new StoredAlliance { PlayerA = "pilot", PlayerB = "wing", FormedUtc = DateTime.UtcNow.ToString("o") }); + }); + + var loaded = repo.LoadMetadata(); + Assert.NotNull(loaded); + Assert.Equal(987654321, loaded!.Seed); + Assert.Equal("Pilot", repo.LoadPlayer("pilot")!.Name); + Assert.Single(repo.LoadChunkEdits("ice", new ChunkCoord(0, 0, 0))); + Assert.Equal("visited", repo.LoadLocationStatuses()["sys0-p1"]); + Assert.Single(repo.ListAlliances()); + Assert.Single(repo.ListMissions()); + + string backup = repo.CreateBackup("real_pg_backup"); + Assert.EndsWith(".postgresql.json", backup); + Assert.Contains("\"world_meta\"", File.ReadAllText(backup), StringComparison.Ordinal); + } + finally + { + DropSchema(connectionString, schema); + try + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + catch + { + // Best-effort cleanup; a failed temp-folder delete must not hide database failures. + } + } + } + + private static void AssertPostgreSqlServerResponds(string connectionString) + { + using var conn = new NpgsqlConnection(connectionString); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SHOW server_version;"; + string? version = cmd.ExecuteScalar() as string; + Assert.False(string.IsNullOrWhiteSpace(version)); + } + + private static void DropSchema(string connectionString, string schema) + { + try + { + using var conn = new NpgsqlConnection(connectionString); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "DROP SCHEMA IF EXISTS " + QuoteIdentifier(schema) + " CASCADE;"; + cmd.ExecuteNonQuery(); + } + catch + { + // Best-effort cleanup; the test world schema is unique and harmless if left behind. + } + } + + private static string SchemaNameFor(string worldName) + { + char[] chars = worldName.ToLowerInvariant().ToCharArray(); + for (int i = 0; i < chars.Length; i++) + { + bool ok = (chars[i] >= 'a' && chars[i] <= 'z') || (chars[i] >= '0' && chars[i] <= '9'); + chars[i] = ok ? chars[i] : '_'; + } + + return "bbs_" + new string(chars).Trim('_'); + } + + private static string QuoteIdentifier(string value) + => "\"" + (value ?? string.Empty).Replace("\"", "\"\"") + "\""; +} diff --git a/tests/BlocksBeyondTheStars.Tests/ServerConfigTests.cs b/tests/BlocksBeyondTheStars.Tests/ServerConfigTests.cs index f2320226..51f43335 100644 --- a/tests/BlocksBeyondTheStars.Tests/ServerConfigTests.cs +++ b/tests/BlocksBeyondTheStars.Tests/ServerConfigTests.cs @@ -1,6 +1,7 @@ // Blocks Beyond the Stars — Copyright (c) 2026 Justus Dütscher & Marcel Dütscher (JuMaVe Games) // SPDX-License-Identifier: AGPL-3.0-or-later // This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text. +using BlocksBeyondTheStars.Persistence; using BlocksBeyondTheStars.Shared.Configuration; using BlocksBeyondTheStars.Shared.World; using Xunit; @@ -21,6 +22,8 @@ public void ApplyCommandLine_OverridesKnownKeys() "--world", "singleplayer", "--saves", @"C:\sp\saves", "--data", @"C:\sp\data", + "--database-provider", "postgresql", + "--postgres-connection-string", "Host=db;Database=bbs;Username=bbs;Password=secret", "--max-players", "1", "--view-distance", "3", }); @@ -30,6 +33,8 @@ public void ApplyCommandLine_OverridesKnownKeys() Assert.Equal("singleplayer", config.WorldName); Assert.Equal(@"C:\sp\saves", config.SavesRoot); Assert.Equal(@"C:\sp\data", config.DataDir); + Assert.Equal("postgresql", config.DatabaseProvider); + Assert.Equal("Host=db;Database=bbs;Username=bbs;Password=secret", config.PostgresConnectionString); Assert.Equal(1, config.MaxPlayers); Assert.Equal(3, config.ViewDistanceChunks); Assert.Contains("port", applied); @@ -138,7 +143,13 @@ public void ApplyEnvironment_OverridesKnownKeys() ["BBS_ENABLE_WEBSOCKET"] = "true", ["BBS_ADMINS"] = "Alice, Bob", ["BBS_WORLD"] = "dockerworld", + ["BBS_FREE_FLIGHT"] = "false", + ["BBS_SPACE_COMBAT"] = "PvE", + ["BBS_SHIP_WEAPONS"] = "All", + ["BBS_SPACE_NPCS"] = "Normal", ["BBS_AI_LEVEL"] = "Suggest", + ["BBS_DATABASE_PROVIDER"] = "postgresql", + ["BBS_POSTGRES_CONNECTION_STRING"] = "Host=db;Database=bbs;Username=bbs;Password=secret", }; WithEnvironment(vars, () => @@ -154,13 +165,36 @@ public void ApplyEnvironment_OverridesKnownKeys() Assert.True(config.EnableWebSocket); Assert.Equal(new[] { "Alice", "Bob" }, config.AdminPlayers); Assert.Equal("dockerworld", config.WorldName); + Assert.False(config.Rules.FreeSpaceFlight); + Assert.Equal(SpaceCombatMode.PvE, config.Rules.SpaceCombat); + Assert.Equal(ShipWeaponMode.All, config.Rules.ShipWeapons); + Assert.Equal(AlienActivity.Normal, config.Rules.SpaceNpcEnemies); Assert.Equal(AiLevel.Suggest, config.AiLevel); + Assert.Equal("postgresql", config.DatabaseProvider); + Assert.Equal("Host=db;Database=bbs;Username=bbs;Password=secret", config.PostgresConnectionString); Assert.Contains("BBS_PORT", applied); Assert.Contains("BBS_ADMIN_BIND", applied); + Assert.Contains("BBS_FREE_FLIGHT", applied); Assert.Contains("BBS_AI_LEVEL", applied); }); } + [Fact] + public void RepositoryFactory_SelectsPostgreSqlFromConfig() + { + var config = new ServerConfig + { + DatabaseProvider = "postgres", + PostgresConnectionString = "Host=db;Database=bbs;Username=bbs;Password=secret", + }; + + using var repo = WorldRepositoryFactory.Create(config, new SaveGamePaths(Path.GetTempPath(), "factory")); + + Assert.IsType(repo); + Assert.True(WorldRepositoryFactory.IsPostgreSql(config)); + Assert.Equal("PostgreSQL", WorldRepositoryFactory.DisplayName(config)); + } + [Fact] public void ApplyEnvironment_IgnoresUnsetAndUnparseableValues() { diff --git a/tests/BlocksBeyondTheStars.Tests/WebSocketTransportTests.cs b/tests/BlocksBeyondTheStars.Tests/WebSocketTransportTests.cs new file mode 100644 index 00000000..0d1bf76f --- /dev/null +++ b/tests/BlocksBeyondTheStars.Tests/WebSocketTransportTests.cs @@ -0,0 +1,155 @@ +// Blocks Beyond the Stars — Copyright (c) 2026 Justus Dütscher & Marcel Dütscher (JuMaVe Games) +// SPDX-License-Identifier: AGPL-3.0-or-later +// This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text. +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Net.WebSockets; +using BlocksBeyondTheStars.Networking; +using BlocksBeyondTheStars.Networking.Messages; +using BlocksBeyondTheStars.Networking.Transport; +using BlocksBeyondTheStars.Persistence; +using BlocksBeyondTheStars.Shared.Configuration; +using BlocksBeyondTheStars.Shared.Content; +using Xunit; +using SvGameServer = BlocksBeyondTheStars.GameServer.GameServer; + +namespace BlocksBeyondTheStars.Tests; + +public sealed class WebSocketTransportTests : IDisposable +{ + private readonly string _root; + private readonly GameContent _content; + + public WebSocketTransportTests() + { + _root = Path.Combine(Path.GetTempPath(), "bbts_ws_" + Guid.NewGuid().ToString("N")); + _content = ContentLoader.LoadFromDirectory(TestPaths.DataDir()); + } + + [Fact] + public async Task WebSocketTransport_JoinsAndStreamsChunksAsync() + { + int port = FreeTcpPort(); + using var repo = new SqliteWorldRepository(new SaveGamePaths(_root, "browser")); + using var transport = new WebSocketServerTransport("127.0.0.1"); + var config = new ServerConfig + { + WorldName = "browser", + GameplayPort = port, + Seed = 11, + AutoSaveIntervalMinutes = 9999, + PlaceStarterShip = false, + ViewDistanceChunks = 1, + ChunkStreamPerTick = 8, + }; + + var server = new SvGameServer(config, _content, transport, repo); + server.Start(); + + using var ws = new ClientWebSocket(); + await ws.ConnectAsync(new Uri($"ws://127.0.0.1:{port}/"), CancellationToken.None); + using var receiveCts = new CancellationTokenSource(); + var received = new ConcurrentQueue(); + var receiveTask = ReceiveLoopAsync(ws, received, receiveCts.Token); + + try + { + await ws.SendAsync(NetCodec.EncodeJson(new JoinRequest { PlayerName = "BrowserPilot", ViewDistanceChunks = 1 }), + WebSocketMessageType.Binary, true, CancellationToken.None); + + bool joined = false; + bool receivedChunk = false; + for (int i = 0; i < 160 && (!joined || !receivedChunk); i++) + { + server.Tick(0.1); + while (received.TryDequeue(out var payload)) + { + switch (NetCodec.Decode(payload)) + { + case JoinAccepted: + joined = true; + break; + case ChunkDataMessage: + receivedChunk = true; + break; + } + } + + await Task.Delay(25); + } + + Assert.True(joined, "Browser WebSocket clients should complete the join handshake."); + Assert.True(receivedChunk, "Browser WebSocket clients should receive authoritative world chunks."); + } + finally + { + await receiveCts.CancelAsync(); + ws.Abort(); + await Task.WhenAny(receiveTask, Task.Delay(500)); + server.Stop(); + } + } + + [Fact] + public void NetCodec_TryConvertToJsonPayload_DropsMalformedPayloads() + { + Assert.False(NetCodec.TryConvertToJsonPayload(new byte[] { 254, 1, 2, 3 }, out var converted)); + Assert.Empty(converted); + } + + [Fact] + public void NetCodec_Decode_DropsOversizedJsonPayloads() + { + var payload = new byte[NetCodec.MaxJsonPayloadBytes + 1]; + payload[0] = 255; + + Assert.Null(NetCodec.Decode(payload)); + } + + private static async Task ReceiveLoopAsync(ClientWebSocket ws, ConcurrentQueue received, CancellationToken token) + { + var buffer = new byte[16 * 1024]; + try + { + while (ws.State == WebSocketState.Open && !token.IsCancellationRequested) + { + using var ms = new MemoryStream(); + WebSocketReceiveResult result; + do + { + result = await ws.ReceiveAsync(buffer, token); + if (result.MessageType == WebSocketMessageType.Close) + { + return; + } + + await ms.WriteAsync(buffer.AsMemory(0, result.Count), token); + } + while (!result.EndOfMessage); + + received.Enqueue(ms.ToArray()); + } + } + catch (OperationCanceledException) + { + } + catch (WebSocketException) + { + } + } + + private static int FreeTcpPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { } + } +} diff --git a/tests/BlocksBeyondTheStars.Tests/WorldOptionsTests.cs b/tests/BlocksBeyondTheStars.Tests/WorldOptionsTests.cs index 705ce6b3..793b78ee 100644 --- a/tests/BlocksBeyondTheStars.Tests/WorldOptionsTests.cs +++ b/tests/BlocksBeyondTheStars.Tests/WorldOptionsTests.cs @@ -10,6 +10,7 @@ using BlocksBeyondTheStars.Persistence; using BlocksBeyondTheStars.Shared.Configuration; using BlocksBeyondTheStars.Shared.Content; +using BlocksBeyondTheStars.Shared.State; using BlocksBeyondTheStars.Shared.World; using BlocksBeyondTheStars.WorldGeneration; using Xunit; @@ -125,6 +126,35 @@ public void Rules_BakeIntoTheSave_AndSurviveARelaunchWithDefaultConfig() } } + [Fact] + public void LaunchRules_EnableFreeFlightForExistingWorlds() + { + var paths = new SaveGamePaths(_root, "flightupgrade"); + using var repo = new SqliteWorldRepository(paths); + repo.Initialize(); + repo.SaveMetadata(new WorldMetadata + { + WorldName = "flightupgrade", + Seed = 11, + DefaultPlanetType = "rocky", + ActiveLocationId = "rocky", + RulesOverride = new GameRules { FreeSpaceFlight = false, PlanetEnemies = AlienActivity.Off }, + }); + + using var serverTransport = new LoopbackServerTransport(new LoopbackLink()); + var config = new ServerConfig { WorldName = "flightupgrade", Seed = 11, AutoSaveIntervalMinutes = 9999 }; + config.Rules.FreeSpaceFlight = true; + var server = new SvGameServer(config, _content, serverTransport, repo); + server.Start(); + + Assert.True(config.Rules.FreeSpaceFlight); + Assert.Equal(AlienActivity.Off, config.Rules.PlanetEnemies); + Assert.True(repo.LoadMetadata()!.RulesOverride!.FreeSpaceFlight); + Assert.Equal(AlienActivity.Off, repo.LoadMetadata()!.RulesOverride!.PlanetEnemies); + + server.Stop(); + } + // ---- Creature abundance --------------------------------------------------------------------- [Fact]
${k}${v}