// Client for the savegame service (server/): a shared list of savegames on the web server, // plus the own campaigns and the campaigns other users published. // All requests are asynchronous; poll() is called once per frame and reports finished requests. // Campaign requests report through fields/take_* functions instead of new events: on success // poll() returns CloudEvent::None, on failure CloudEvent::Error (also kept in campaign_error). // 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, } // an own campaign on the server #[allow(dead_code)] #[derive(Clone, Deserialize, Debug)] pub struct CampaignInfo { pub id: String, pub name: String, pub updated: u64, #[serde(default)] pub published: bool, #[serde(default)] pub size: u64, } // a campaign some user published #[allow(dead_code)] #[derive(Clone, Deserialize, Debug)] pub struct PublicCampaignInfo { pub owner: String, pub id: String, pub name: String, pub updated: u64, #[serde(default)] pub size: u64, } #[derive(Clone, Copy, PartialEq, Debug)] pub enum Online { Unknown, Yes, No, } #[allow(dead_code)] #[derive(Clone, Copy, PartialEq, Debug)] enum Req { Health, List, Save, Load, Delete, Login, Me, Logout, Password, Users, CreateUser, DeleteUser, CampaignList, CampaignLoad, CampaignSave, CampaignDelete, CampaignPublish, PublicList, PublicLoad, ProfileLoad, ProfileSave, } // 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, pub campaigns: Vec, // own campaigns (newest first) pub campaigns_listed: bool, pub public_campaigns: Vec, // published campaigns of all users pub public_listed: bool, pub campaign_error: Option<&'static str>, // language key of the last failed campaign request loaded_campaign: Option, pub profile_loaded: Option, // the profile from the server, waiting to be merged saved_campaign: Option, 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(), campaigns: Vec::new(), campaigns_listed: false, public_campaigns: Vec::new(), public_listed: false, campaign_error: None, loaded_campaign: None, profile_loaded: None, saved_campaign: None, 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::ProfileLoad => { if ok { self.profile_loaded = Some(String::from_utf8_lossy(&res.body).into_owned()); } CloudEvent::None } Req::ProfileSave => CloudEvent::None, 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 } Req::CampaignList => { if !ok { return self.campaign_failed(res.status); } match serde_json::from_slice::>(&res.body) { Ok(l) => { self.campaigns = l; self.campaigns_listed = true; CloudEvent::None } Err(_) => self.campaign_failed(500), } } Req::PublicList => { if !ok { return self.campaign_failed(res.status); } match serde_json::from_slice::>(&res.body) { Ok(l) => { self.public_campaigns = l; self.public_listed = true; CloudEvent::None } Err(_) => self.campaign_failed(500), } } Req::CampaignLoad | Req::PublicLoad => { if !ok { return self.campaign_failed(res.status); } self.loaded_campaign = Some(String::from_utf8_lossy(&res.body).into_owned()); CloudEvent::None } Req::CampaignSave => { if !ok { return self.campaign_failed(res.status); } self.saved_campaign = serde_json::from_slice::(&res.body).ok(); self.refresh_campaigns(); CloudEvent::None } Req::CampaignDelete | Req::CampaignPublish => { if !ok { return self.campaign_failed(res.status); } self.refresh_campaigns(); CloudEvent::None } } } } // campaigns on the server (the campaign editor UI drives these) #[allow(dead_code)] impl Cloud { // ---------------- campaigns. Every call returns false while another request is running. pub fn refresh_campaigns(&mut self) -> bool { self.start("GET", "campaigns", &[], Req::CampaignList) } // the campaign JSON arrives later through take_loaded_campaign() pub fn load_campaign(&mut self, id: &str) -> bool { self.start("GET", &format!("campaigns/{}", encode(id)), &[], Req::CampaignLoad) } // id = None stores a new campaign, Some(id) overwrites an own one; // the stored entry (with its id) arrives through take_saved_campaign() pub fn save_campaign(&mut self, id: Option<&str>, name: &str, json: &str) -> bool { match id { None => self.start("POST", &format!("campaigns?name={}", encode(name)), json.as_bytes(), Req::CampaignSave), Some(id) => self.start("PUT", &format!("campaigns/{}?name={}", encode(id), encode(name)), json.as_bytes(), Req::CampaignSave), } } pub fn delete_campaign(&mut self, id: &str) -> bool { self.start("DELETE", &format!("campaigns/{}", encode(id)), &[], Req::CampaignDelete) } pub fn publish_campaign(&mut self, id: &str, published: bool) -> bool { let body = serde_json::json!({ "published": published }).to_string(); self.start("POST", &format!("campaigns/{}/publish", encode(id)), body.as_bytes(), Req::CampaignPublish) } pub fn refresh_public_campaigns(&mut self) -> bool { self.start("GET", "public/campaigns", &[], Req::PublicList) } // the campaign JSON arrives later through take_loaded_campaign() pub fn load_public_campaign(&mut self, owner: &str, id: &str) -> bool { self.start("GET", &format!("public/campaigns/{}/{}", encode(owner), encode(id)), &[], Req::PublicLoad) } // JSON text of a campaign loaded by load_campaign() or load_public_campaign() // the profile (statistics and achievements) of the logged-in player pub fn load_profile(&mut self) -> bool { self.session.is_some() && self.start("GET", "profile", &[], Req::ProfileLoad) } pub fn save_profile(&mut self, json: &str) -> bool { self.session.is_some() && self.start("PUT", "profile", json.as_bytes(), Req::ProfileSave) } pub fn take_loaded_campaign(&mut self) -> Option { self.loaded_campaign.take() } // the entry of the campaign stored by save_campaign() pub fn take_saved_campaign(&mut self) -> Option { self.saved_campaign.take() } pub fn take_campaign_error(&mut self) -> Option<&'static str> { self.campaign_error.take() } fn campaign_failed(&mut self, status: i32) -> CloudEvent { let key = error_key(status); self.campaign_error = Some(key); CloudEvent::Error(key) } } // (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"); } #[test] fn campaign_lists_parse() { let own: Vec = serde_json::from_str(r#"[{"id":"17-ab","name":"C","updated":5,"published":true,"size":9}]"#).unwrap(); assert_eq!((own[0].id.as_str(), own[0].published, own[0].size), ("17-ab", true, 9)); let public: Vec = serde_json::from_str(r#"[{"owner":"alice","id":"1-0","name":"P","updated":7}]"#).unwrap(); assert_eq!((public[0].owner.as_str(), public[0].updated), ("alice", 7)); } }