Files
dungeoncraft/src/cloud.rs
T
Richard ZinkandClaude Opus 5.5 c9848c8948 User accounts, login before the title screen, own savegames per user, standalone download
- Service: accounts with Argon2 password hashes and session tokens; the first login creates
  the admin (shared savegames of before move to the admin); the admin creates and deletes
  users; everybody can change the password; savegames are stored and listed per user
- Game: login page before the title (only when the service is reachable), with licence and
  repository link; account page (password, logout; users for the admin); text fields with
  Tab/Enter, masked passwords, browser dialog on touch screens; long status messages wrap
- Download Standalone: DungeonCraft.html with the game embedded (built in the Docker image,
  served to logged-in users via nginx auth_request); works from file:// without server and
  opens downloaded savegames
- Without the service (standalone, local files) there is no login, savegame files as before

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:33:22 +01:00

378 lines
12 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.
// 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<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>,
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(),
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::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
}
}
}
}
// (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");
}
}