Resume interrupted editor co-op runs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f87b59390e
commit
c7c3e979d6
24 files changed
+145
-6
No files matched your search
@@ -18,6 +18,12 @@ pub struct CampaignProgress {
|
||||
// random maps are rolled once per run of the campaign: this seed is fixed for the run
|
||||
#[serde(default)]
|
||||
pub run_seed: u32,
|
||||
// co-op runs (kept by the host): the mission the run goes on with and what every master carries
|
||||
// into it; None once the run is over
|
||||
#[serde(default)]
|
||||
pub next: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub carry: Vec<(u8, crate::campaign::Carry)>,
|
||||
}
|
||||
|
||||
impl CampaignProgress {
|
||||
@@ -27,6 +33,8 @@ impl CampaignProgress {
|
||||
done: vec![false; n],
|
||||
completed_choice: vec![None; n],
|
||||
run_seed: (macroquad::miniquad::date::now() * 1000.0) as u64 as u32 | 1,
|
||||
next: None,
|
||||
carry: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+41
-1
@@ -230,10 +230,23 @@ impl MatchClient {
|
||||
// a co-op match of an editor campaign: the whole campaign file goes to the relay, which hands
|
||||
// it to every client with the start message
|
||||
pub fn create_coop_doc(&mut self, doc: &crate::campaign_doc::CampaignDoc, mission: usize, sides: &[u8], difficulty: u8) {
|
||||
self.create_coop_doc_resumed(doc, mission, sides, difficulty, None);
|
||||
}
|
||||
|
||||
// the same, going on with an interrupted run: its seed and what every master carries along
|
||||
pub fn create_coop_doc_resumed(&mut self, doc: &crate::campaign_doc::CampaignDoc, mission: usize, sides: &[u8], difficulty: u8, resume: Option<(u32, &[(u8, crate::campaign::Carry)])>) {
|
||||
let Some(m) = doc.missions.get(mission) else { return };
|
||||
let (map_w, map_h) = m.size;
|
||||
let doc = serde_json::to_value(doc).unwrap_or(Value::Null);
|
||||
let n = self.outbox.len();
|
||||
self.send_create_with(sides, map_w, map_h, difficulty, Some(mission), Some(doc));
|
||||
if let (Some((seed, carry)), Some(text)) = (resume, self.outbox.get_mut(n)) {
|
||||
if let Ok(mut msg) = serde_json::from_str::<Value>(text) {
|
||||
msg["seed"] = json!(seed);
|
||||
msg["carry"] = serde_json::to_value(carry).unwrap_or(Value::Array(Vec::new()));
|
||||
*text = msg.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send_create(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8, mission: Option<usize>) {
|
||||
@@ -534,7 +547,10 @@ impl MatchClient {
|
||||
}
|
||||
r.ai = ai.clone();
|
||||
}
|
||||
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai, doc, editor, teams, next: false, carry: Vec::new() });
|
||||
// a resumed editor campaign run goes on like the next mission of a running one
|
||||
let resume = editor && v.get("resume").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||
let carry: Vec<(u8, crate::campaign::Carry)> = if resume { v.get("carry").cloned().and_then(|c| serde_json::from_value(c).ok()).unwrap_or_default() } else { Vec::new() };
|
||||
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai, doc, editor, teams, next: resume, carry });
|
||||
self.error = None;
|
||||
self.status = None;
|
||||
}
|
||||
@@ -898,6 +914,30 @@ mod tests {
|
||||
assert_eq!(start.teams, vec![(0, 1), (2, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interrupted_coop_run_goes_on_with_its_seed_and_carry() {
|
||||
let doc = coop_doc();
|
||||
let mut host = MatchClient::new();
|
||||
host.on_connect("red");
|
||||
pop(&mut host); // rooms
|
||||
let carry = vec![(0u8, crate::campaign::Carry { gold: 250.0, ..Default::default() })];
|
||||
host.create_coop_doc_resumed(&doc, 1, &[0, 2, 3, 4], 1, Some((4242, &carry)));
|
||||
let create = pop(&mut host);
|
||||
assert_eq!(create["seed"].as_u64(), Some(4242));
|
||||
assert_eq!(create["carry"][0][0].as_u64(), Some(0));
|
||||
assert_eq!(create["mission"].as_u64(), Some(1));
|
||||
let start = json!({"t":"start","seed":4242,"map_w":96,"map_h":96,"difficulty":1,"sides":[0,2],"mission":1,"ai":[],"doc":serde_json::to_value(&doc).unwrap(),"carry":create["carry"].clone(),"resume":true}).to_string();
|
||||
host.feed(&start);
|
||||
let info = host.take_start().unwrap();
|
||||
assert!(info.next, "a resumed run keeps its progress");
|
||||
assert_eq!(info.seed, 4242);
|
||||
assert_eq!(info.carry.len(), 1);
|
||||
assert_eq!(info.carry[0].1.gold, 250.0);
|
||||
// old stored progress without the run position still reads
|
||||
let p: crate::campaign_doc::CampaignProgress = serde_json::from_str(r#"{"done":[true],"completed_choice":[null],"run_seed":5}"#).unwrap();
|
||||
assert_eq!((p.next, p.carry.len()), (None, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_clients_play_the_same_editor_campaign_mission_in_coop() {
|
||||
let doc = coop_doc();
|
||||
|
||||
+35
-3
@@ -225,10 +225,17 @@ fn store_progress(p: &campaign::Progress) {
|
||||
// progress of editor campaigns played in co-op, kept by the host: campaign name -> progress
|
||||
const COOP_PROGRESS_KEY: &str = "dc.coop_editor_progress";
|
||||
|
||||
fn coop_progress_all() -> std::collections::BTreeMap<String, campaign_doc::CampaignProgress> {
|
||||
pub(crate) fn coop_progress_all() -> std::collections::BTreeMap<String, campaign_doc::CampaignProgress> {
|
||||
platform::storage_get(COOP_PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
// where an interrupted co-op run of this campaign goes on: mission, run seed, carry
|
||||
pub(crate) fn coop_resume_of(doc: &campaign_doc::CampaignDoc) -> Option<(usize, u32, Vec<(u8, campaign::Carry)>)> {
|
||||
let p = coop_progress_all().remove(&doc.name)?;
|
||||
let next = p.next.filter(|n| doc.missions.get(*n).map_or(false, campaign::is_editor_coop_mission))?;
|
||||
Some((next, p.run_seed, p.carry))
|
||||
}
|
||||
|
||||
fn store_coop_progress(name: &str, p: &campaign_doc::CampaignProgress) {
|
||||
let mut all = coop_progress_all();
|
||||
all.insert(name.to_string(), p.clone());
|
||||
@@ -260,6 +267,9 @@ impl CoopRun {
|
||||
let mut p = coop_progress_all().remove(&self.doc.name).unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(&self.doc));
|
||||
p.run_seed = self.run_seed;
|
||||
p.mark_done(self.mission, self.doc.missions.len(), choice);
|
||||
// the run goes on with the next mission (or is over)
|
||||
p.next = self.next;
|
||||
p.carry = self.carry_out.clone();
|
||||
store_coop_progress(&self.doc.name, &p);
|
||||
}
|
||||
} else {
|
||||
@@ -353,6 +363,24 @@ fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut A
|
||||
menu.error = Some(i18n::tr("net.coop_editor_none"));
|
||||
}
|
||||
}
|
||||
MenuAction::MatchCoopResume(doc) => {
|
||||
// with the relay: a room that goes on with the run; without it: offline with an AI partner
|
||||
match coop_resume_of(&doc) {
|
||||
Some((mission, seed, carry)) if app.client.connected => {
|
||||
app.client.create_coop_doc_resumed(&doc, mission, &[0, 2, 3, 4], app.settings.difficulty, Some((seed, &carry)));
|
||||
}
|
||||
Some((mission, seed, carry)) => {
|
||||
if app.start_offline_coop(Some(doc), mission, Some(seed), carry) {
|
||||
menu.page = None;
|
||||
menu.error = None;
|
||||
app.set_status(i18n::tr("net.coop_solo_started"), now);
|
||||
} else {
|
||||
menu.error = Some(i18n::tr("net.coop_editor_none"));
|
||||
}
|
||||
}
|
||||
None => menu.error = Some(i18n::tr("net.coop_editor_none")),
|
||||
}
|
||||
}
|
||||
MenuAction::MatchTakeSide(side) => app.client.take_side(side),
|
||||
MenuAction::MatchSetAi(side, ai) => app.client.set_ai(side, ai),
|
||||
MenuAction::MatchSetTeam(side, team, own) => app.client.set_team(if own { None } else { Some(side) }, team),
|
||||
@@ -703,11 +731,14 @@ impl App {
|
||||
self.coop_editor = true;
|
||||
self.recorded = false;
|
||||
self.pending_briefing = briefing_of(doc, mission);
|
||||
// the host keeps the progress of the run (a new run starts with the room's seed)
|
||||
if !info.next && info.local_side == Some(0) {
|
||||
// the host keeps the progress of the run (a new run starts with the room's seed) and
|
||||
// where it stands, so an interrupted run can go on later
|
||||
if info.local_side == Some(0) {
|
||||
let mut p = coop_progress_all().remove(&doc.name).unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(doc));
|
||||
p.ensure(doc.missions.len());
|
||||
p.run_seed = info.seed;
|
||||
p.next = Some(mission);
|
||||
p.carry = info.carry.clone();
|
||||
store_coop_progress(&doc.name, &p);
|
||||
}
|
||||
self.coop_run = Some(CoopRun { doc: doc.clone(), mission, run_seed: info.seed, carry_in: info.carry.clone(), next: None, carry_out: Vec::new(), sent: false });
|
||||
@@ -1506,6 +1537,7 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
|
||||
| MenuAction::MatchLeave
|
||||
| MenuAction::MatchCoopEditorCreate(..)
|
||||
| MenuAction::MatchCoopSolo(..)
|
||||
| MenuAction::MatchCoopResume(..)
|
||||
| MenuAction::MatchChat(..)
|
||||
| MenuAction::MatchEditField(..)) => account_action(a, &mut cloud, &mut menu, &mut app, now),
|
||||
MenuAction::EditName => {
|
||||
|
||||
@@ -280,6 +280,7 @@ enum Btn {
|
||||
MatchCoopCreate,
|
||||
MatchCoopSource, // switch between the built-in campaign and the editor campaign
|
||||
MatchCoopSolo, // the selected co-op mission without the server, with an AI partner
|
||||
MatchCoopResume, // go on with the interrupted co-op run of the selected campaign
|
||||
MatchTake(usize),
|
||||
MatchAi(usize),
|
||||
MatchTeam(usize), // next team for that seat
|
||||
@@ -399,6 +400,7 @@ pub enum MenuAction {
|
||||
MatchCoopCreate(usize),
|
||||
MatchCoopEditorCreate(usize), // co-op match of a mission of `campaign_doc`
|
||||
MatchCoopSolo(Option<crate::campaign_doc::CampaignDoc>, usize), // offline with an AI partner: editor campaign or built-in, mission
|
||||
MatchCoopResume(crate::campaign_doc::CampaignDoc), // an interrupted run of this campaign goes on
|
||||
MatchTakeSide(u8),
|
||||
MatchSetAi(u8, bool),
|
||||
MatchSetTeam(u8, u8, bool), // side, team, the own seat
|
||||
@@ -800,6 +802,7 @@ impl Menu {
|
||||
self.confirm = None;
|
||||
MenuAction::Consumed
|
||||
}
|
||||
Some(Btn::MatchCoopResume) => MenuAction::MatchCoopResume(self.coop_doc().clone()),
|
||||
Some(Btn::MatchCoopSolo) if self.coop_editor => MenuAction::MatchCoopSolo(Some(self.coop_doc().clone()), self.coop_editor_mission),
|
||||
Some(Btn::MatchCoopSolo) => MenuAction::MatchCoopSolo(None, self.coop_mission),
|
||||
Some(Btn::MatchCoopCreate) if self.coop_editor => MenuAction::MatchCoopEditorCreate(self.coop_editor_mission),
|
||||
@@ -1998,6 +2001,12 @@ impl Menu {
|
||||
items.push((Btn::MatchCoopNext, ">".to_string()));
|
||||
items.push((Btn::MatchCoopSolo, tr("net.coop_solo")));
|
||||
}
|
||||
// an interrupted run of the shown campaign can go on (online, or offline with the AI)
|
||||
if editor_coop {
|
||||
if let Some((m, _, _)) = crate::coop_resume_of(&doc) {
|
||||
items.push((Btn::MatchCoopResume, trf("net.coop_resume", &[(m + 1).to_string()])));
|
||||
}
|
||||
}
|
||||
// the button names what comes next
|
||||
let next = match (editor_coop, self.coop_league) {
|
||||
(false, _) => "net.coop_league",
|
||||
|
||||
Reference in new issue
Block a user