Server-side savegames: Rust service with a shared savegame list in the menu, fallback to files

- server/: small axum service storing savegames as files (1 MB each, 500 MB total, rate limit)
- docker compose: second container with a volume; nginx forwards /api/
- JS plugin: asynchronous HTTP requests polled by the game
- Save/Load open a list page (name field, new, overwrite, load, delete with confirmation)
- Without a reachable service the game keeps using savegame files and the browser autosave

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 09:31:36 +01:00
1 parent c5b7896609
commit 7f4324abca
17 files changed
+1390 -30

No files matched your search

+55 -1
View File
@@ -1,9 +1,12 @@
// DungeonCraft browser plugin for miniquad: storage, file download/upload, save-on-leave, audio output.
// DungeonCraft browser plugin for miniquad: storage, file download/upload, save-on-leave,
// HTTP requests (savegame service), audio output.
(function () {
"use strict";
var pending = null; // value handed over by dc_storage_get
var file = null; // bytes of a file chosen via the file picker
var cache = {}; // key -> string, persisted to localStorage when the page is hidden/closed
var http = {}; // request id -> { done, status, body }
var httpNext = 1;
function bytes(ptr, len) {
return new Uint8Array(wasm_memory.buffer, ptr, len);
@@ -34,8 +37,59 @@
});
document.addEventListener("DOMContentLoaded", function () { document.body.appendChild(input); });
// Asynchronous HTTP request; the result is polled by the game.
// status 0 = network error (e.g. no service, or the page was opened as a local file)
function httpStart(method, url, body) {
var id = httpNext++;
var req = { done: false, status: 0, body: new Uint8Array(0) };
http[id] = req;
var ctrl = typeof AbortController !== "undefined" ? new AbortController() : null;
var timer = setTimeout(function () { if (ctrl) ctrl.abort(); }, 15000);
try {
var opts = { method: method, cache: "no-store", headers: {} };
if (ctrl) opts.signal = ctrl.signal;
if (body !== null) { opts.body = body; opts.headers["Content-Type"] = "application/json"; }
fetch(url, opts).then(function (r) {
req.status = r.status;
return r.arrayBuffer();
}).then(function (buf) {
req.body = new Uint8Array(buf);
req.done = true;
clearTimeout(timer);
}).catch(function () {
req.status = 0;
req.done = true;
clearTimeout(timer);
});
} catch (e) {
req.done = true;
}
return id;
}
function register(importObject) {
var env = importObject.env;
env.dc_http = function (mp, ml, up, ul, bp, bl) {
var body = bl > 0 ? bytes(bp, bl).slice() : null;
return httpStart(str(mp, ml), str(up, ul), body);
};
// -2 = still running, otherwise the HTTP status (0 = network error)
env.dc_http_poll = function (id) {
var r = http[id];
if (!r) return 0;
return r.done ? r.status : -2;
};
env.dc_http_len = function (id) {
var r = http[id];
return r ? r.body.length : 0;
};
// copies the response body and forgets the request
env.dc_http_take = function (id, ptr) {
var r = http[id];
if (!r) return;
bytes(ptr, r.body.length).set(r.body);
delete http[id];
};
env.dc_storage_get = function (kp, kl) {
var v = null;
try { v = window.localStorage.getItem(str(kp, kl)); } catch (e) { }