Per-user campaign storage and public campaign list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-05 16:58:19 +01:00
1 parent 535800c7dc
commit 65677504f6
5 files changed
+527 -12

No files matched your search

+169 -1
View File
@@ -1,5 +1,8 @@
// Client for the savegame service (server/): a shared list of savegames on the web server.
// 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.
@@ -23,6 +26,31 @@ pub struct SaveInfo {
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,
@@ -30,6 +58,7 @@ pub enum Online {
No,
}
#[allow(dead_code)]
#[derive(Clone, Copy, PartialEq, Debug)]
enum Req {
Health,
@@ -44,6 +73,13 @@ enum Req {
Users,
CreateUser,
DeleteUser,
CampaignList,
CampaignLoad,
CampaignSave,
CampaignDelete,
CampaignPublish,
PublicList,
PublicLoad,
}
// the logged-in account
@@ -82,6 +118,13 @@ pub struct Cloud {
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>,
saved_campaign: Option<CampaignInfo>,
pending: Option<(u32, Req)>,
save_name: String,
login_name: String,
@@ -127,6 +170,13 @@ impl Cloud {
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,
saved_campaign: None,
pending: Some((id, Req::Health)),
save_name: String::new(),
login_name: String::new(),
@@ -326,10 +376,120 @@ impl Cloud {
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()
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);
@@ -374,4 +534,12 @@ mod tests {
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));
}
}