From abe01ff23e4047d1e916e20ef8d96b3a0e43a1ae Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Thu, 5 Nov 2026 17:39:38 +0100 Subject: [PATCH] Co-op matches of editor campaigns Co-Authored-By: Claude Opus 5.5 --- docs/CAMPAIGN_EDITOR.md | 3 ++ docs/MULTIPLAYER.md | 11 ++++ lang/de.lang | 4 ++ lang/en.lang | 4 ++ lang/es.lang | 4 ++ lang/it.lang | 4 ++ lang/ru.lang | 4 ++ server/src/netplay.rs | 83 ++++++++++++++++++++++++++---- src/campaign.rs | 61 ++++++++++++++++++++-- src/game.rs | 11 ++-- src/lobby.rs | 110 +++++++++++++++++++++++++++++++++++++++- src/main.rs | 85 ++++++++++++++++++++++++------- src/menu.rs | 54 +++++++++++++++++--- 13 files changed, 394 insertions(+), 44 deletions(-) diff --git a/docs/CAMPAIGN_EDITOR.md b/docs/CAMPAIGN_EDITOR.md index e5d9d58..c445493 100644 --- a/docs/CAMPAIGN_EDITOR.md +++ b/docs/CAMPAIGN_EDITOR.md @@ -116,6 +116,9 @@ Ctrl+Z/Y undo/redo, Ctrl+C/V copy/paste the selected block, Delete removes it, C - **Online** (server with the savegame service): own campaigns per user, overwrite on the next save; *Published* adds the campaign to the list every logged-in player sees and can open (as a copy) and play. Server API: `/api/campaigns`, `/api/public/campaigns` (see `server/src/main.rs`). +- **Co-op**: the campaign last opened in the editor can be played in a co-op match (Multiplayer, + *Editor campaign*), one mission per match; missions need a map of at least 72x72 and a goal besides + rival masters. See `docs/MULTIPLAYER.md`. ## File format (`src/campaign_doc.rs`) diff --git a/docs/MULTIPLAYER.md b/docs/MULTIPLAYER.md index da33b0a..a3cd1f4 100644 --- a/docs/MULTIPLAYER.md +++ b/docs/MULTIPLAYER.md @@ -69,6 +69,17 @@ per turn. Fog of war, particles and the camera are local and not part of the sha by the simplified rival AI on every client, so they stay deterministic. - **Co-op** (`Game::coop`): the master sides are allies against the heroes; allied creatures never target each other, allied ground does not block claims. Co-op campaign matches use `campaign::start_coop_mission`. +- **Editor campaigns in co-op**: on the Match page the host can switch from the built-in campaign to the + campaign last opened in the editor (`Menu::campaign_doc`) and pick one of its missions that fits co-op + (`campaign::is_editor_coop_mission`: map at least 72x72, a goal besides rival masters). The create message + carries the whole campaign (`"doc"`, the `CampaignDoc` JSON, up to `MAX_WS_MESSAGE` = 40 MB per WebSocket + message); the relay keeps it in the room and adds it to the `start` message (room updates only show + `"editor": true`). Every client builds the mission with `campaign::start_editor_coop_mission(doc, i, seed)` from + the room's shared seed (rival masters of the map and the Rivals goal are left out, like in built-in co-op), + then the usual co-op setup. Block scripts run from side 0 in the fixed step. When the mission ends, the match + ends and the players go back to the lobby; there is no branching to a next mission and no progress is stored. +- **Shared simulation side**: the shared part of a step (waves, units, towers, rivals, dwarves) runs with the + first master switched in on every client; only the end check and the fog use the local master. ## Steps diff --git a/lang/de.lang b/lang/de.lang index b240c67..8e639d1 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -658,6 +658,10 @@ net.no_rooms = Keine offenen Partien. net.create = Partie erstellen net.coop_mission = Koop-Kampagne: Mission {0} ({1}) net.coop_create = Koop-Kampagne +net.coop_editor = Editor-Kampagne +net.coop_builtin = Eingebaute Kampagne +net.coop_editor_mission = Editor-Kampagne {0}: Mission {1} ({2}) +net.coop_editor_none = Keine Mission der Editor-Kampagne passt für Koop (Karte mindestens 72x72, ein Ziel außer Rivalen-Meistern) net.join = Beitreten net.join_hint = Partiecode net.seats = Plätze diff --git a/lang/en.lang b/lang/en.lang index ef31be9..3130563 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -659,6 +659,10 @@ net.no_rooms = No open matches. net.create = Create match net.coop_mission = Co-op campaign: mission {0} ({1}) net.coop_create = Co-op campaign +net.coop_editor = Editor campaign +net.coop_builtin = Built-in campaign +net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) +net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters) net.join = Join net.join_hint = Match code net.seats = Seats diff --git a/lang/es.lang b/lang/es.lang index ccc2e7a..4c935b7 100644 --- a/lang/es.lang +++ b/lang/es.lang @@ -1328,3 +1328,7 @@ demo.m2.title = Otra tirada demo.m2.story = Este mapa se genera de nuevo en cada partida de la campaña. Derrota a todos los héroes. demo.m2.start = Un mapa aleatorio. Ni siquiera yo lo había visto antes. demo.m2.wave = Han pasado dos minutos: llegan los héroes. +net.coop_editor = Editor campaign +net.coop_builtin = Built-in campaign +net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) +net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters) diff --git a/lang/it.lang b/lang/it.lang index e7c5afa..c41663b 100644 --- a/lang/it.lang +++ b/lang/it.lang @@ -1328,3 +1328,7 @@ demo.m2.title = Rimescolata demo.m2.story = Questa mappa viene generata di nuovo a ogni partita della campagna. Sconfiggi tutti gli eroi. demo.m2.start = Una mappa casuale. Nemmeno io l'avevo mai vista. demo.m2.wave = Sono passati due minuti: arrivano gli eroi. +net.coop_editor = Editor campaign +net.coop_builtin = Built-in campaign +net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) +net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters) diff --git a/lang/ru.lang b/lang/ru.lang index 8b97890..dde88d6 100644 --- a/lang/ru.lang +++ b/lang/ru.lang @@ -1328,3 +1328,7 @@ demo.m2.title = Новый бросок demo.m2.story = Эта карта создаётся заново при каждом прохождении кампании. Победи всех героев. demo.m2.start = Случайная карта. Даже я её раньше не видел. demo.m2.wave = Две минуты прошли: идут герои. +net.coop_editor = Editor campaign +net.coop_builtin = Built-in campaign +net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) +net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters) diff --git a/server/src/netplay.rs b/server/src/netplay.rs index 26ad124..a52b034 100644 --- a/server/src/netplay.rs +++ b/server/src/netplay.rs @@ -6,7 +6,9 @@ // // Lobby messages (JSON, one object per WebSocket frame): // {"t":"rooms"} -> list of open matches -// {"t":"create","sides":[0,2,3,4]?,"map_w":96?,"map_h":128?,"difficulty":1?,"mission":12?} +// {"t":"create","sides":[0,2,3,4]?,"map_w":96?,"map_h":128?,"difficulty":1?,"mission":12?,"doc":{..}?} +// -> "doc": an editor campaign (CampaignDoc JSON) for a co-op +// match of its mission "mission"; it goes along with "start" // {"t":"join","code":"ABC123"} // {"t":"take_side","side":2} // {"t":"ready","ready":true} @@ -43,6 +45,8 @@ const MAX_PENDING: usize = 200; const MAX_ROOMS: usize = 64; const MASTER_SIDES: [u8; 4] = [0, 2, 3, 4]; const MAX_MISSIONS: usize = 30; // keep this in step with the campaign mission count in the client +// an editor campaign travels in one WebSocket message (create, start): as large as a stored campaign +pub const MAX_WS_MESSAGE: usize = 40 << 20; #[derive(Debug, Default)] pub struct Netplay { @@ -75,6 +79,7 @@ struct Room { map_h: usize, difficulty: u8, mission: Option, + doc: Option, // editor campaign of a co-op match (sent to every client at the start) } #[derive(Debug)] @@ -176,7 +181,7 @@ fn mission_of(v: Option<&Value>) -> Option { } pub async fn netplay_ws(ws: WebSocketUpgrade, Query(q): Query, State(st): State) -> Response { - ws.on_upgrade(move |socket| handle_socket(socket, st, q)) + ws.max_message_size(MAX_WS_MESSAGE).max_frame_size(MAX_WS_MESSAGE).on_upgrade(move |socket| handle_socket(socket, st, q)) } async fn handle_socket(socket: WebSocket, st: AppState, q: WsQuery) { @@ -323,13 +328,25 @@ impl Netplay { let map_h = msg.get("map_h").and_then(|v| v.as_u64()).unwrap_or(128).clamp(72, 256) as usize; let difficulty = msg.get("difficulty").and_then(|v| v.as_u64()).unwrap_or(1).clamp(0, 2) as u8; let mission = mission_of(msg.get("mission")); - if mission.map_or(false, |m| m >= MAX_MISSIONS) { - self.error(conn, "unknown campaign mission"); - return None; + let doc = msg.get("doc").filter(|d| !d.is_null()).cloned(); + match &doc { + Some(d) => { + let count = d.get("missions").and_then(|m| m.as_array()).map_or(0, |m| m.len()); + if !d.is_object() || mission.map_or(true, |m| m >= count) { + self.error(conn, "unknown campaign mission"); + return None; + } + } + None => { + if mission.map_or(false, |m| m >= MAX_MISSIONS) { + self.error(conn, "unknown campaign mission"); + return None; + } + } } let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into()); let seats = sides.iter().map(|s| (*s, Seat { conn: None, name: String::new(), ready: false, ai: false })).collect::>(); - let room = Room { code: code.clone(), sides, seats, pending: HashMap::new(), turn: 0, started: false, paused: false, missing: false, seed: None, map_w, map_h, difficulty, mission }; + let room = Room { code: code.clone(), sides, seats, pending: HashMap::new(), turn: 0, started: false, paused: false, missing: false, seed: None, map_w, map_h, difficulty, mission, doc }; self.rooms.insert(code.clone(), room); if let Some(c) = self.clients.get_mut(&conn) { c.room = Some(code.clone()); @@ -527,7 +544,13 @@ impl Netplay { r.sides = active; } let msg = match self.rooms.get(&code) { - Some(r) => json!({"t":"start","seed":r.seed.unwrap_or(0),"map_w":r.map_w,"map_h":r.map_h,"difficulty":r.difficulty,"sides":r.sides,"mission":r.mission,"ai":r.started_ai_sides()}), + Some(r) => { + let mut m = json!({"t":"start","seed":r.seed.unwrap_or(0),"map_w":r.map_w,"map_h":r.map_h,"difficulty":r.difficulty,"sides":r.sides,"mission":r.mission,"ai":r.started_ai_sides()}); + if let Some(doc) = &r.doc { + m["doc"] = doc.clone(); + } + m + } None => return, }; self.room_msg_all(&code); @@ -757,7 +780,8 @@ impl Netplay { "map_h":r.map_h, "difficulty":r.difficulty, "seed":r.seed, - "mission":r.mission + "mission":r.mission, + "editor":r.doc.is_some() })) } @@ -767,7 +791,7 @@ impl Netplay { .values() .map(|r| { let players = r.seats.values().filter(|s| s.conn.is_some()).count(); - json!({"code":r.code,"players":players,"sides":r.sides,"started":r.started,"paused":r.paused,"mission":r.mission}) + json!({"code":r.code,"players":players,"sides":r.sides,"started":r.started,"paused":r.paused,"mission":r.mission,"editor":r.doc.is_some()}) }) .collect(); self.send(conn, json!({"t":"rooms","rooms":rooms})); @@ -852,6 +876,47 @@ mod tests { assert_eq!(start_red["mission"].as_u64(), Some(2)); } + #[test] + fn an_editor_campaign_goes_from_the_create_to_every_start() { + let mut net = Netplay::new(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let red = net.connect(tx, Some("red".into()), None); + let doc = json!({"format":"dungeoncraft-campaign","name":"Mine","missions":[{"key":"a","title":"First"},{"key":"b","title":"Second","size":[96,96]}]}); + // a mission the campaign does not have is refused + assert!(net.handle(red, json!({"t":"create","sides":[0,2],"mission":2,"doc":doc})).is_empty()); + assert_eq!(until(&mut rx, "error")["why"].as_str(), Some("unknown campaign mission")); + let rooms = net.handle(red, json!({"t":"create","sides":[0,2],"map_w":96,"map_h":96,"mission":1,"doc":doc})); + let code = rooms[0].clone(); + let room = until(&mut rx, "room"); + assert_eq!(room["editor"].as_bool(), Some(true)); + assert!(room.get("doc").is_none(), "room updates stay small"); + + let (tx2, mut rx2) = mpsc::unbounded_channel(); + let green = net.connect(tx2, Some("green".into()), None); + net.handle(green, json!({"t":"join","code":&code})); + net.handle(red, json!({"t":"ready"})); + net.handle(green, json!({"t":"ready"})); + net.handle(red, json!({"t":"start"})); + let start_red = until(&mut rx, "start"); + let start_green = until(&mut rx2, "start"); + assert_eq!(start_red["seed"], start_green["seed"]); + assert_eq!(start_green["mission"].as_u64(), Some(1)); + assert_eq!(start_red["doc"], doc); + assert_eq!(start_green["doc"], doc, "the joined player gets the campaign too"); + } + + #[test] + fn a_built_in_start_carries_no_campaign() { + let mut net = Netplay::new(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let red = net.connect(tx, Some("red".into()), None); + net.handle(red, json!({"t":"create","sides":[0,2],"mission":2})); + net.handle(red, json!({"t":"ready"})); + net.handle(red, json!({"t":"start"})); + let start = until(&mut rx, "start"); + assert!(start.get("doc").is_none()); + } + #[test] fn a_non_host_cannot_start_a_match() { let mut net = Netplay::new(); diff --git a/src/campaign.rs b/src/campaign.rs index ed87349..a7a0ac8 100644 --- a/src/campaign.rs +++ b/src/campaign.rs @@ -619,13 +619,68 @@ pub fn run_map_seed(run_seed: u32, mission: usize) -> u32 { // a mission of an editor campaign: its painted map or a random map (rolled from the run's // seed), the goals it can actually have, and its block scripts pub fn start_editor_mission(doc: &crate::campaign_doc::MissionDoc, i: usize, run_seed: u32) -> Game { + editor_mission_mode(doc, i, run_seed, false) +} + +// the same mission for a co-op match: the other master seats (2, 3, 4) belong to the players, so +// rival masters of the map are left out (like the built-in co-op missions) and so is the Rivals goal +pub fn start_editor_coop_mission(doc: &crate::campaign_doc::MissionDoc, i: usize, run_seed: u32) -> Game { + editor_mission_mode(doc, i, run_seed, true) +} + +// editor missions that can be played in co-op: the master areas need a map of at least 72x72, +// and a goal other than beating rival masters +pub fn is_editor_coop_mission(doc: &crate::campaign_doc::MissionDoc) -> bool { + doc.size.0 >= 72 && doc.size.1 >= 72 && crate::campaign_doc::parse_goals(&doc.goals).iter().any(|g| *g != Goal::Rivals) +} + +pub fn editor_coop_indexes(doc: &crate::campaign_doc::CampaignDoc) -> Vec { + doc.missions.iter().enumerate().filter(|(_, m)| is_editor_coop_mission(m)).map(|(i, _)| i).collect() +} + +pub fn next_editor_coop_mission(doc: &crate::campaign_doc::CampaignDoc, i: usize, dir: i32) -> usize { + let list = editor_coop_indexes(doc); + if list.is_empty() { + return 0; + } + let pos = list.iter().position(|m| *m == i).unwrap_or(0); + list[((pos as i32 + dir).rem_euclid(list.len() as i32)) as usize] +} + +// a painted map without its rival masters: their creatures go, their tiles become unowned +fn remove_rivals(g: &mut Game) { + let sides: Vec = g.rivals.iter().map(|r| r.side).collect(); + if sides.is_empty() { + return; + } + g.rivals.clear(); + g.ents.retain(|e| e.hero || !sides.contains(&e.side)); + let owners: Vec = sides.iter().map(|&s| Owner::of_side(s)).collect(); + for t in g.map.t.iter_mut() { + if owners.contains(&t.owner) { + t.owner = Owner::None; + if t.kind == Kind::Core { + t.kind = Kind::Floor; + } + } + } + g.recount(); +} + +fn editor_mission_mode(doc: &crate::campaign_doc::MissionDoc, i: usize, run_seed: u32, coop: bool) -> Game { use crate::campaign_doc::MapDoc; + let generated = || start_mission_def(&generated_def(doc, run_map_seed(run_seed, i)), i, None, coop); let mut g = match &doc.map { MapDoc::Fixed { game } => match load_fixed_map(game) { - Some(g) => g, - None => start_mission_def(&generated_def(doc, run_map_seed(run_seed, i)), i, None, false), + Some(mut g) => { + if coop { + remove_rivals(&mut g); + } + g + } + None => generated(), }, - MapDoc::Random => start_mission_def(&generated_def(doc, run_map_seed(run_seed, i)), i, None, false), + MapDoc::Random => generated(), }; g.msgs.clear(); g.state = 0; diff --git a/src/game.rs b/src/game.rs index 969ef92..57ca8ac 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1624,9 +1624,12 @@ impl Game { } fn after_economy(&mut self, dt: f32) { - // from here on it is about the local master and the humans: that side is switched in first + // the shared world (waves, units, towers, rivals ...) runs from the first master's side: the + // same on every computer of a match, whichever master plays at this one. Single-player + // games only have that one side. let local = self.local_side; - self.switch_to(local); + let first = self.master_sides().first().copied().unwrap_or(local); + self.switch_to(first); if self.waves_on() { self.wave_tick(dt); } @@ -1636,7 +1639,7 @@ impl Game { self.update_contested(); self.update_units(dt); - self.switch_to(local); // update_units worked master by master + self.switch_to(first); // update_units worked master by master self.update_towers(dt); self.update_reveals(dt); self.update_graves(dt); @@ -1644,6 +1647,8 @@ impl Game { self.update_rivals(dt); } self.update_dwarves(dt); + // the end of the game and the fog are about the master at this computer + self.switch_to(local); self.check_end(); self.update_fog(dt); } diff --git a/src/lobby.rs b/src/lobby.rs index 68410ea..7707d95 100644 --- a/src/lobby.rs +++ b/src/lobby.rs @@ -79,6 +79,9 @@ pub struct StartInfo { pub local_side: Option, pub mission: Option, pub ai: Vec, + // an editor campaign sent along by the host: `mission` is then one of its missions + pub doc: Option, + 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) { + 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, doc: Option) { 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| ::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"); + } } diff --git a/src/main.rs b/src/main.rs index 6bfd0e6..236297f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -286,6 +286,13 @@ fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut A menu.error = Some(i18n::tr("net.status_closed")); } } + MenuAction::MatchCoopEditorCreate(mission) => { + if menu.campaign_doc.missions.get(mission).map_or(false, campaign::is_editor_coop_mission) { + app.client.create_coop_doc(&menu.campaign_doc, mission, &[0, 2, 3, 4], app.settings.difficulty); + } else { + 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::MatchReady(ready) => app.client.ready(ready), @@ -296,12 +303,14 @@ fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut A app.game.coop = false; app.match_mode = false; app.coop = false; + app.coop_editor = false; } MenuAction::MatchLeave => { app.client.leave(); app.game.coop = false; app.match_mode = false; app.coop = false; + app.coop_editor = false; } MenuAction::MatchChat(text) => app.client.chat(&text, false), MenuAction::MatchEditField(i) => { @@ -438,6 +447,7 @@ struct App { ws_id: i32, match_mode: bool, coop: bool, + coop_editor: bool, // the co-op match plays a mission of an editor campaign fps: f32, renderer: String, frame_ms: f32, @@ -561,33 +571,56 @@ impl App { self.game.coop = false; self.match_mode = false; self.coop = false; + self.coop_editor = false; self.client.on_disconnect(); } + // the shared part of a co-op start (built-in or editor mission): the masters, AI seats, settings + fn start_coop_game(&mut self, mut game: Game, info: &StartInfo) -> bool { + let local = info.local_side.unwrap_or(0); + game.coop = true; + if !game.start_match(&info.sides, local) { + return false; + } + game.ai_difficulty = info.difficulty; + game.sync_ai_sides(&info.ai, info.difficulty); + apply_game_settings(&mut game, &self.settings); + self.game = game; + let (cx, cy) = self.game.map.core; + self.cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5); + self.match_mode = true; + self.coop = true; + self.seed = info.seed.wrapping_mul(31) | 1; + true + } + fn start_match_game(&mut self, info: StartInfo) -> bool { + self.coop_editor = false; if !info.sides.contains(&0) { return false; } + if info.editor { + // a co-op mission of an editor campaign: every client builds it from the same file and + // the room's shared seed (random maps and block scripts roll from it) + let (Some(doc), Some(mission)) = (info.doc.as_ref(), info.mission) else { return false }; + let Some(mission_doc) = doc.missions.get(mission) else { return false }; + if !campaign::is_editor_coop_mission(mission_doc) { + return false; + } + let game = campaign::start_editor_coop_mission(mission_doc, mission, info.seed); + if !self.start_coop_game(game, &info) { + return false; + } + self.coop_editor = true; + self.pending_briefing = briefing_of(doc, mission); + return true; + } if let Some(mission) = info.mission { if !campaign::is_coop_mission(mission) { return false; } - let local = info.local_side.unwrap_or(0); - let mut game = campaign::start_coop_mission(mission, None); - game.coop = true; - if !game.start_match(&info.sides, local) { - return false; - } - game.ai_difficulty = info.difficulty; - game.sync_ai_sides(&info.ai, info.difficulty); - apply_game_settings(&mut game, &self.settings); - self.game = game; - let (cx, cy) = self.game.map.core; - self.cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5); - self.match_mode = true; - self.coop = true; - self.seed = info.seed.wrapping_mul(31) | 1; - return true; + let game = campaign::start_coop_mission(mission, None); + return self.start_coop_game(game, &info); } if info.map_w < 72 || info.map_h < 72 { return false; @@ -660,7 +693,7 @@ async fn main() { let seed = (miniquad::date::now() * 1000.0) as u32 | 1; // behind the title menu: the endless bridge battle let (game, cam) = Game::new_demo(seed); - let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, fps: 0.0, renderer: platform::renderer(), frame_ms: 0.0, frame_hist: [0.0; FRAME_HIST], frame_hist_pos: 0, custom_campaign: None, custom_progress: None, custom_mission_index: None, custom_next_index: None, editor: None, editor_testing: false, editor_test_only: false, editor_public_pending: false, editor_loading: None, pending_briefing: None }; + let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, coop_editor: false, fps: 0.0, renderer: platform::renderer(), frame_ms: 0.0, frame_hist: [0.0; FRAME_HIST], frame_hist_pos: 0, custom_campaign: None, custom_progress: None, custom_mission_index: None, custom_next_index: None, editor: None, editor_testing: false, editor_test_only: false, editor_public_pending: false, editor_loading: None, pending_briefing: None }; menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(); apply_game_settings(&mut app.game, &app.settings); let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, minimap_drag: false, tlast: None, touch_world: false, inspect: None }; @@ -1368,6 +1401,7 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i); | MenuAction::MatchResume | MenuAction::MatchAbort | MenuAction::MatchLeave + | MenuAction::MatchCoopEditorCreate(..) | MenuAction::MatchChat(..) | MenuAction::MatchEditField(..)) => account_action(a, &mut cloud, &mut menu, &mut app, now), MenuAction::EditName => { @@ -1411,6 +1445,16 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i); } // end of a mission: next mission (won) or try again (lost) UiAction::EndNext | UiAction::EndCampaign if app.editor_testing && app.editor_test_only => back_to_editor(&mut app, &mut menu, &mut r3d), + // a co-op mission of an editor campaign is over: the match ends, back to the lobby + UiAction::EndNext | UiAction::EndCampaign if app.coop_editor => { + app.leave_match(); + app.seed = next_seed(app.seed); + (app.game, app.cam) = Game::new_demo(app.seed); + menu.open_match(true); + if !app.connect_match(&cloud) { + menu.error = Some(i18n::tr("net.status_offline")); + } + } UiAction::EndNext => { if app.custom_campaign.is_some() { let campaign = app.custom_campaign.clone(); @@ -1621,8 +1665,11 @@ app.pending_briefing = briefing_of(&campaign, i); for ev in app.game.events.drain(..) { sounds.play(ev, vec2(app.cam.rx, app.cam.rz), right, hear, now); } - // a won mission goes into the campaign progress (with the carry-over) - if app.custom_campaign.is_none() { + // a won mission goes into the campaign progress (with the carry-over); a co-op mission of an + // editor campaign keeps no progress (its index is not a built-in mission) + if app.coop_editor { + app.recorded = app.game.state != 0; + } else if app.custom_campaign.is_none() { if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) { if app.game.state != 0 { app.recorded = true; diff --git a/src/menu.rs b/src/menu.rs index f8c4add..1e8bcbc 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -278,6 +278,7 @@ enum Btn { MatchCoopPrev, MatchCoopNext, MatchCoopCreate, + MatchCoopSource, // switch between the built-in campaign and the editor campaign MatchTake(usize), MatchAi(usize), MatchReady, @@ -392,6 +393,7 @@ pub enum MenuAction { MatchJoin(String), MatchCreate, MatchCoopCreate(usize), + MatchCoopEditorCreate(usize), // co-op match of a mission of `campaign_doc` MatchTakeSide(u8), MatchSetAi(u8, bool), MatchReady(bool), @@ -414,6 +416,8 @@ pub struct Menu { pub error: Option, // message on the login and account pages pub match_ui: MatchUi, // copied state of the multiplayer relay, shown on the Match page pub coop_mission: usize, // co-op campaign mission selected on the Match page + pub coop_editor: bool, // the Match page offers the editor campaign instead of the built-in one + pub coop_editor_mission: usize, // mission of `campaign_doc` selected for co-op pub in_match: bool, // a match is being played; the pause menu changes its shape pub editor_test: bool, // a mission of the editor is being tested: back to the editor pub custom_briefing: (String, String), // title and text of an editor mission's briefing @@ -451,6 +455,8 @@ impl Menu { error: None, match_ui: MatchUi::default(), coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0), + coop_editor: false, + coop_editor_mission: 0, in_match: false, editor_test: false, custom_briefing: (String::new(), String::new()), @@ -746,16 +752,23 @@ impl Menu { None => MenuAction::Consumed, }, Some(Btn::MatchCreate) => MenuAction::MatchCreate, - Some(Btn::MatchCoopPrev) => { - self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, -1); + Some(b @ (Btn::MatchCoopPrev | Btn::MatchCoopNext)) => { + let dir = if b == Btn::MatchCoopPrev { -1 } else { 1 }; + if self.coop_editor { + self.coop_editor_mission = crate::campaign::next_editor_coop_mission(&self.campaign_doc, self.coop_editor_mission, dir); + } else { + self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, dir); + } self.confirm = None; MenuAction::Consumed } - Some(Btn::MatchCoopNext) => { - self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, 1); + Some(Btn::MatchCoopSource) => { + self.coop_editor = !self.coop_editor && !self.campaign_doc.missions.is_empty(); + self.coop_editor_mission = crate::campaign::next_editor_coop_mission(&self.campaign_doc, self.coop_editor_mission, 0); self.confirm = None; MenuAction::Consumed } + Some(Btn::MatchCoopCreate) if self.coop_editor => MenuAction::MatchCoopEditorCreate(self.coop_editor_mission), Some(Btn::MatchCoopCreate) => MenuAction::MatchCoopCreate(self.coop_mission), Some(Btn::MatchReady) => MenuAction::MatchReady(!self.match_ui.ready), Some(Btn::MatchStart) => MenuAction::MatchStart, @@ -1866,7 +1879,27 @@ impl Menu { yy += CH() * 1.6; } - if !ui.started && !ui.room_open() { + // the editor campaign can only be offered while it has missions (it may have been replaced) + let editor_coop = self.coop_editor && !self.campaign_doc.missions.is_empty(); + let editor_list = if editor_coop { crate::campaign::editor_coop_indexes(&self.campaign_doc) } else { Vec::new() }; + if editor_coop && !editor_list.contains(&self.coop_editor_mission) { + self.coop_editor_mission = editor_list.first().copied().unwrap_or(0); + } + if !ui.started && !ui.room_open() && editor_coop { + let line = match self.campaign_doc.missions.get(self.coop_editor_mission).filter(|_| !editor_list.is_empty()) { + Some(m) => { + let name = if self.campaign_doc.name.trim().is_empty() { "?".to_string() } else { self.campaign_doc.name.clone() }; + let title = if m.title.trim().is_empty() { m.key.clone() } else { m.title.clone() }; + trf("net.coop_editor_mission", &[name, (self.coop_editor_mission + 1).to_string(), title]) + } + None => tr("net.coop_editor_none"), + }; + let maxc = ((cols - 6) as f32 * CW() / (GW * SMALL())) as usize; + for text in wrap(&line, maxc).iter().take(2) { + gfx.text(text, ix, yy, SMALL(), GOLD); + yy += GH * SMALL() * 1.35; + } + } else if !ui.started && !ui.room_open() { if let Some(d) = missions().get(self.coop_mission) { let line = trf("net.coop_mission", &[(self.coop_mission + 1).to_string(), tr(&format!("camp.{}.title", d.key))]); let maxc = ((cols - 6) as f32 * CW() / (GW * SMALL())) as usize; @@ -1885,9 +1918,14 @@ impl Menu { items.push((Btn::MatchCreate, tr("net.create"))); items.push((Btn::MatchJoin, tr("net.join"))); if !ui.room_open() { - items.push((Btn::MatchCoopPrev, "<".to_string())); - items.push((Btn::MatchCoopCreate, tr("net.coop_create"))); - items.push((Btn::MatchCoopNext, ">".to_string())); + if !editor_coop || !editor_list.is_empty() { + items.push((Btn::MatchCoopPrev, "<".to_string())); + items.push((Btn::MatchCoopCreate, tr("net.coop_create"))); + items.push((Btn::MatchCoopNext, ">".to_string())); + } + if !self.campaign_doc.missions.is_empty() { + items.push((Btn::MatchCoopSource, tr(if editor_coop { "net.coop_builtin" } else { "net.coop_editor" }))); + } } if ui.room_open() { items.push((Btn::MatchReady, tr(if ui.ready { "net.unready" } else { "net.ready" })));