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

+219
View File
@@ -0,0 +1,219 @@
// Client for the savegame service (server/): a shared list of savegames on the web server.
// All requests are asynchronous; poll() is called once per frame and reports finished requests.
// When the service cannot be reached (no server, or the game was opened as a local file),
// `online` becomes false and the game falls back to savegame files and the browser autosave.
use crate::platform;
use serde::Deserialize;
const API: &str = "api"; // relative, so the game also works below a sub path
#[derive(Clone, Deserialize, Debug)]
pub struct SaveInfo {
pub id: String,
pub name: String,
pub saved_at: u64,
#[serde(default)]
pub map_w: u64,
#[serde(default)]
pub map_h: u64,
#[serde(default)]
pub wave: u64,
#[serde(default)]
pub time: f64,
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Online {
Unknown,
Yes,
No,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Req {
Health,
List,
Save,
Load,
Delete,
}
pub enum CloudEvent {
None,
Saved(String), // name of the stored savegame
Loaded(Vec<u8>), // savegame data
Deleted,
Error(&'static str), // language key
}
pub struct Cloud {
pub online: Online,
pub list: Vec<SaveInfo>,
pub listed: bool, // the list has been fetched at least once
pending: Option<(u32, Req)>,
save_name: String,
}
// percent-encoding for a query parameter
fn encode(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) {
out.push(b as char);
} else {
out.push_str(&format!("%{:02X}", b));
}
}
out
}
fn error_key(status: i32) -> &'static str {
match status {
0 => "cloud.err_net",
413 => "cloud.err_large",
429 => "cloud.err_rate",
507 => "cloud.err_full",
404 => "cloud.err_missing",
_ => "cloud.err",
}
}
impl Cloud {
// starts the availability check
pub fn new() -> Cloud {
let id = platform::http("GET", &format!("{}/health", API), &[]);
Cloud { online: Online::Unknown, list: Vec::new(), listed: false, pending: Some((id, Req::Health)), save_name: String::new() }
}
pub fn available(&self) -> bool {
self.online == Online::Yes
}
pub fn busy(&self) -> bool {
self.pending.is_some()
}
fn start(&mut self, method: &str, path: &str, body: &[u8], req: Req) -> bool {
if self.pending.is_some() {
return false;
}
let id = platform::http(method, &format!("{}/{}", API, path), body);
self.pending = Some((id, req));
true
}
pub fn refresh(&mut self) {
self.start("GET", "saves", &[], Req::List);
}
// id = None stores a new savegame, Some(id) overwrites an existing one
pub fn save(&mut self, id: Option<&str>, name: &str, json: &str) -> bool {
self.save_name = name.to_string();
match id {
None => self.start("POST", &format!("saves?name={}", encode(name)), json.as_bytes(), Req::Save),
Some(id) => self.start("PUT", &format!("saves/{}?name={}", id, encode(name)), json.as_bytes(), Req::Save),
}
}
pub fn load(&mut self, id: &str) -> bool {
self.start("GET", &format!("saves/{}", id), &[], Req::Load)
}
pub fn delete(&mut self, id: &str) -> bool {
self.start("DELETE", &format!("saves/{}", id), &[], Req::Delete)
}
pub fn poll(&mut self) -> CloudEvent {
let Some((id, req)) = self.pending else { return CloudEvent::None };
let Some(res) = platform::http_poll(id) else { return CloudEvent::None };
self.pending = None;
let ok = (200..300).contains(&res.status);
match req {
Req::Health => {
self.online = if ok && res.body.starts_with(b"ok") { Online::Yes } else { Online::No };
CloudEvent::None
}
Req::List => {
if !ok {
return CloudEvent::Error(error_key(res.status));
}
match serde_json::from_slice::<Vec<SaveInfo>>(&res.body) {
Ok(l) => {
self.list = l;
self.listed = true;
CloudEvent::None
}
Err(_) => CloudEvent::Error("cloud.err"),
}
}
Req::Save => {
if !ok {
return CloudEvent::Error(error_key(res.status));
}
self.refresh();
CloudEvent::Saved(std::mem::take(&mut self.save_name))
}
Req::Load => {
if ok {
CloudEvent::Loaded(res.body)
} else {
CloudEvent::Error(error_key(res.status))
}
}
Req::Delete => {
if !ok {
return CloudEvent::Error(error_key(res.status));
}
self.refresh();
CloudEvent::Deleted
}
}
}
}
// (year, month, day, hour, minute) in UTC from unix seconds (Howard Hinnant's civil date algorithm)
pub fn civil(secs: i64) -> (i64, i64, i64, i64, i64) {
let days = secs.div_euclid(86400);
let rem = secs.rem_euclid(86400);
let z = days + 719468;
let era = z.div_euclid(146097);
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };
(y, m, d, rem / 3600, rem % 3600 / 60)
}
// "2026-11-03 14:20" (UTC)
pub fn date_string(secs: i64) -> String {
let (y, m, d, h, mi) = civil(secs);
format!("{:04}-{:02}-{:02} {:02}:{:02}", y, m, d, h, mi)
}
// play time as "1:02:03" or "12:34"
pub fn duration_string(secs: f64) -> String {
let s = secs.max(0.0) as u64;
if s >= 3600 {
format!("{}:{:02}:{:02}", s / 3600, s / 60 % 60, s % 60)
} else {
format!("{}:{:02}", s / 60, s % 60)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn helpers() {
assert_eq!(date_string(0), "1970-01-01 00:00");
assert_eq!(date_string(1793693897), "2026-11-03 08:18");
assert_eq!(duration_string(754.0), "12:34");
assert_eq!(duration_string(3723.0), "1:02:03");
assert_eq!(encode("Wave 3 – ä"), "Wave%203%20%E2%80%93%20%C3%A4");
}
}