566 lines
19 KiB
Rust
566 lines
19 KiB
Rust
// 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<u8>), // 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<Session>,
|
||
pub list: Vec<SaveInfo>,
|
||
pub listed: bool, // the list has been fetched at least once
|
||
pub users: Vec<UserInfo>,
|
||
pub campaigns: Vec<CampaignInfo>, // own campaigns (newest first)
|
||
pub campaigns_listed: bool,
|
||
pub public_campaigns: Vec<PublicCampaignInfo>, // 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<String>,
|
||
pub profile_loaded: Option<String>, // the profile from the server, waiting to be merged
|
||
saved_campaign: Option<CampaignInfo>,
|
||
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<Session> = 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::<Answer>(&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::<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
|
||
}
|
||
Req::CampaignList => {
|
||
if !ok {
|
||
return self.campaign_failed(res.status);
|
||
}
|
||
match serde_json::from_slice::<Vec<CampaignInfo>>(&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::<Vec<PublicCampaignInfo>>(&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::<CampaignInfo>(&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<String> {
|
||
self.loaded_campaign.take()
|
||
}
|
||
|
||
// the entry of the campaign stored by save_campaign()
|
||
pub fn take_saved_campaign(&mut self) -> Option<CampaignInfo> {
|
||
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<CampaignInfo> = 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<PublicCampaignInfo> = 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));
|
||
}
|
||
}
|