Co-op matches of editor campaigns

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-05 17:39:38 +01:00
1 parent 0966e3318f
commit abe01ff23e
13 files changed
+394 -44

No files matched your search

+108 -2
View File
@@ -79,6 +79,9 @@ pub struct StartInfo {
pub local_side: Option<u8>,
pub mission: Option<usize>,
pub ai: Vec<u8>,
// an editor campaign sent along by the host: `mission` is then one of its missions
pub doc: Option<crate::campaign_doc::CampaignDoc>,
pub editor: bool, // the start carried an editor campaign (even one that could not be read)
}
#[derive(Clone, Debug, Default)]
@@ -211,7 +214,20 @@ impl MatchClient {
self.send_create(sides, map_w, map_h, difficulty, Some(mission));
}
// 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) {
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);
self.send_create_with(sides, map_w, map_h, difficulty, Some(mission), Some(doc));
}
fn send_create(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8, mission: Option<usize>) {
self.send_create_with(sides, map_w, map_h, difficulty, mission, None);
}
fn send_create_with(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8, mission: Option<usize>, doc: Option<Value>) {
if !self.connected {
return;
}
@@ -219,7 +235,11 @@ impl MatchClient {
self.status = None;
self.chat.clear();
self.chat_seen = 0;
self.outbox.push_back(json!({"t":"create","sides":sides,"map_w":map_w,"map_h":map_h,"difficulty":difficulty,"mission":mission}).to_string());
let mut msg = json!({"t":"create","sides":sides,"map_w":map_w,"map_h":map_h,"difficulty":difficulty,"mission":mission});
if let Some(doc) = doc {
msg["doc"] = doc;
}
self.outbox.push_back(msg.to_string());
}
pub fn join(&mut self, code: &str) {
@@ -449,6 +469,8 @@ impl MatchClient {
let difficulty = v.get("difficulty").and_then(|x| x.as_u64()).unwrap_or(1) as u8;
let sides = array_bytes(v.get("sides"));
let mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize);
let editor = v.get("doc").map_or(false, |d| d.is_object());
let doc = v.get("doc").filter(|d| d.is_object()).and_then(|d| <crate::campaign_doc::CampaignDoc as serde::Deserialize>::deserialize(d).ok());
let mut ai = array_bytes(v.get("ai"));
if ai.is_empty() {
if let Some(r) = self.room.as_ref() {
@@ -467,7 +489,7 @@ impl MatchClient {
}
r.ai = ai.clone();
}
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai });
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai, doc, editor });
self.error = None;
self.status = None;
}
@@ -655,4 +677,88 @@ mod tests {
assert_eq!(start.sides, vec![0u8, 3u8]);
assert_eq!(start.ai, vec![3u8]);
}
// a small editor campaign: a random map with a rival master and block scripts, and a painted
// map that still holds a rival master
fn coop_doc() -> crate::campaign_doc::CampaignDoc {
use crate::blocks::{Block, Val};
use crate::campaign_doc::*;
let mut doc = CampaignDoc { name: "Coop test".into(), ..CampaignDoc::default() };
let mut m = MissionDoc { key: "c1".into(), title: "Random".into(), size: (80, 80), seed: 5, goals: vec!["KillAll".into(), "Rivals".into()], waves: true, first_wave: 3.0, gen: GenDoc { rivers: 1, lava: 1, forts: 1, rivals: 1, difficulty: 1 }, ..MissionDoc::default() };
m.scripts.start = vec![Block::Gold { amount: Val::num(250.0) }];
m.scripts.triggers = vec![TriggerDoc { name: "wave".into(), kind: TriggerKind::Timer { secs: 2.0, every: true }, once: false, enabled: true, script: vec![Block::Wave { count: Val::num(1.0) }] }];
doc.missions.push(m.clone());
let mut painted = crate::campaign::start_editor_mission(&MissionDoc { size: (96, 96), seed: 9, ..m.clone() }, 1, 3);
assert!(!painted.rivals.is_empty(), "the painted map has a rival master");
painted.state = 0;
doc.missions.push(MissionDoc { key: "c2".into(), title: "Painted".into(), size: (96, 96), map: MapDoc::Fixed { game: serde_json::to_string(&painted).unwrap() }, ..m });
doc
}
// what the game does with a co-op start of an editor campaign (main.rs start_coop_game)
fn coop_game(info: &StartInfo) -> crate::game::Game {
let doc = info.doc.as_ref().expect("the start carries the campaign");
let i = info.mission.expect("a mission");
assert!(crate::campaign::is_editor_coop_mission(&doc.missions[i]));
let mut g = crate::campaign::start_editor_coop_mission(&doc.missions[i], i, info.seed);
g.coop = true;
assert!(g.start_match(&info.sides, info.local_side.unwrap()), "the masters fit the map");
g.sync_ai_sides(&info.ai, info.difficulty);
g
}
#[test]
fn two_clients_play_the_same_editor_campaign_mission_in_coop() {
let doc = coop_doc();
let mut host = MatchClient::new();
host.on_connect("red");
pop(&mut host); // rooms
host.create_coop_doc(&doc, 1, &[0, 2, 3, 4], 1);
let create = pop(&mut host);
assert_eq!(create["mission"].as_u64(), Some(1));
assert_eq!(create["map_w"].as_u64(), Some(96));
assert_eq!(create["doc"]["missions"].as_array().map(|a| a.len()), Some(2), "the whole campaign goes along");
for mission in [0usize, 1] {
let mut clients = [MatchClient::new(), MatchClient::new()];
let room = r#"{"t":"room","code":"ABC123","sides":[0,2,3,4],"seats":[{"side":0,"name":"red","ready":true,"connected":true},{"side":2,"name":"green","ready":true,"connected":true},{"side":3,"name":"","connected":false,"ai":true}],"ai":[3],"turn":0,"started":false}"#;
let start = json!({"t":"start","seed":4711,"map_w":80,"map_h":80,"difficulty":1,"sides":[0,2,3],"mission":mission,"ai":[3],"doc":serde_json::to_value(&doc).unwrap()}).to_string();
let mut games = Vec::new();
for (c, name) in clients.iter_mut().zip(["red", "green"]) {
c.on_connect(name);
c.feed(room);
c.feed(&start);
let info = c.take_start().expect("start info");
assert!(info.editor);
games.push(coop_game(&info));
}
let (a, b) = games.split_at_mut(1);
let (a, b) = (&mut a[0], &mut b[0]);
assert_eq!((a.local_side, b.local_side), (0, 2));
assert!(a.rivals.iter().all(|r| r.side == 3), "only the AI seat plays as a rival in co-op");
assert!(!a.mission.as_ref().unwrap().goals.contains(&crate::game::Goal::Rivals));
assert!(a.script.is_some());
assert_eq!(a.checksum(), b.checksum(), "mission {mission}: the same start");
let (cx, cy) = a.map.core;
for t in 0..120u32 {
let cmds = if t % 10 == 0 { vec![Command { side: 0, seq: t, act: Act::Dig(Rect::new(cx + 5, cy + (t / 10) as i32 - 3, cx + 6, cy + (t / 10) as i32 - 2)) }] } else { Vec::new() };
a.receive_turn(&cmds);
b.receive_turn(&cmds);
if t % 10 == 9 {
assert_eq!(a.checksum(), b.checksum(), "mission {mission}: the worlds stayed identical up to turn {t}");
}
}
assert!(a.sim_steps > 0 && a.sim_steps == b.sim_steps);
assert!(a.script.as_ref().unwrap().error.is_none(), "{:?}", a.script.as_ref().unwrap().error);
}
}
#[test]
fn a_broken_editor_campaign_in_the_start_is_noticed() {
let mut client = MatchClient::new();
client.on_connect("red");
client.feed(r#"{"t":"start","seed":1,"sides":[0],"mission":0,"doc":{"missions":"nope"}}"#);
let info = client.take_start().expect("start info");
assert!(info.editor && info.doc.is_none(), "the game refuses the start instead of playing a built-in mission");
}
}