Files
dungeoncraft/src/cloud.rs
T

566 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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));
}
}