// 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, Serialize}; 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, Login, Me, Logout, Password, Users, CreateUser, DeleteUser, } // the logged-in account #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Session { pub token: String, pub name: String, pub admin: bool, } #[derive(Clone, Debug, Deserialize)] pub struct UserInfo { pub name: String, pub admin: bool, } const SESSION_KEY: &str = "dc.session"; pub enum CloudEvent { None, Saved(String), // name of the stored savegame Loaded(Vec), // savegame data Deleted, Error(&'static str), // language key LoggedIn, // login accepted (or the stored session is still valid) LoginNeeded, // the service is there but nobody is logged in LoginFailed(&'static str), PasswordChanged, UsersChanged, } pub struct Cloud { pub online: Online, pub setup: bool, // no account yet: the first login creates the admin pub session: Option, pub list: Vec, pub listed: bool, // the list has been fetched at least once pub users: Vec, pending: Option<(u32, Req)>, save_name: String, login_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", 401 => "login.err_session", 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), &[]); let session: Option = platform::storage_get(SESSION_KEY).and_then(|s| serde_json::from_str(&s).ok()); if let Some(s) = &session { platform::set_token(&s.token); } Cloud { online: Online::Unknown, setup: false, session, list: Vec::new(), listed: false, users: Vec::new(), pending: Some((id, Req::Health)), save_name: String::new(), login_name: String::new(), } } // savegames on the server: the service is there and somebody is logged in pub fn available(&self) -> bool { self.online == Online::Yes && self.session.is_some() } pub fn login(&mut self, name: &str, password: &str) -> bool { self.login_name = name.to_string(); let body = serde_json::json!({ "name": name, "password": password }).to_string(); self.start("POST", "login", body.as_bytes(), Req::Login) } pub fn logout(&mut self) { self.start("POST", "logout", &[], Req::Logout); self.session = None; platform::set_token(""); platform::storage_set(SESSION_KEY, ""); } pub fn change_password(&mut self, old: &str, new: &str) -> bool { let body = serde_json::json!({ "old": old, "new": new }).to_string(); self.start("POST", "password", body.as_bytes(), Req::Password) } pub fn load_users(&mut self) -> bool { self.start("GET", "users", &[], Req::Users) } pub fn create_user(&mut self, name: &str, password: &str) -> bool { let body = serde_json::json!({ "name": name, "password": password }).to_string(); self.start("POST", "users", body.as_bytes(), Req::CreateUser) } pub fn delete_user(&mut self, name: &str) -> bool { self.start("DELETE", &format!("users/{}", encode(name)), &[], Req::DeleteUser) } 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 }; self.setup = res.body.ends_with(b"setup"); if self.online != Online::Yes { return CloudEvent::None; } // a stored session: still valid? if self.session.is_some() && !self.setup { self.start("GET", "me", &[], Req::Me); return CloudEvent::None; } self.session = None; CloudEvent::LoginNeeded } Req::Me => { if ok { CloudEvent::LoggedIn } else if res.status == 0 { self.online = Online::No; CloudEvent::None } else { self.session = None; platform::set_token(""); platform::storage_set(SESSION_KEY, ""); CloudEvent::LoginNeeded } } Req::Login => { #[derive(Deserialize)] struct Answer { token: String, name: String, admin: bool, } match (ok, serde_json::from_slice::(&res.body)) { (true, Ok(a)) => { let s = Session { token: a.token, name: a.name, admin: a.admin }; platform::set_token(&s.token); platform::storage_set(SESSION_KEY, &serde_json::to_string(&s).unwrap_or_default()); self.session = Some(s); self.setup = false; CloudEvent::LoggedIn } _ => CloudEvent::LoginFailed(match res.status { 0 => "cloud.err_net", 400 => "login.err_invalid", 429 => "cloud.err_rate", _ => "login.err_wrong", }), } } Req::Logout => CloudEvent::None, Req::Password => { if ok { CloudEvent::PasswordChanged } else { CloudEvent::Error(match res.status { 403 => "account.err_old", 400 => "login.err_invalid", _ => error_key(res.status), }) } } Req::Users => { if ok { self.users = serde_json::from_slice(&res.body).unwrap_or_default(); CloudEvent::None } else { CloudEvent::Error(error_key(res.status)) } } Req::CreateUser | Req::DeleteUser => { if ok { self.load_users(); CloudEvent::UsersChanged } else { CloudEvent::Error(match res.status { 409 => "account.err_exists", 400 => "login.err_invalid", _ => error_key(res.status), }) } } Req::List => { if !ok { return CloudEvent::Error(error_key(res.status)); } match serde_json::from_slice::>(&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"); } }