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:
1 parent
c5b7896609
commit
7f4324abca
17 files changed
+1390
-30
No files matched your search
+219
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user