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

+3
View File
@@ -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 - **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 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`). 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`) ## File format (`src/campaign_doc.rs`)
+11
View File
@@ -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. 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 - **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`. 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 ## Steps
+4
View File
@@ -658,6 +658,10 @@ net.no_rooms = Keine offenen Partien.
net.create = Partie erstellen net.create = Partie erstellen
net.coop_mission = Koop-Kampagne: Mission {0} ({1}) net.coop_mission = Koop-Kampagne: Mission {0} ({1})
net.coop_create = Koop-Kampagne 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 = Beitreten
net.join_hint = Partiecode net.join_hint = Partiecode
net.seats = Plätze net.seats = Plätze
+4
View File
@@ -659,6 +659,10 @@ net.no_rooms = No open matches.
net.create = Create match net.create = Create match
net.coop_mission = Co-op campaign: mission {0} ({1}) net.coop_mission = Co-op campaign: mission {0} ({1})
net.coop_create = Co-op campaign 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 = Join
net.join_hint = Match code net.join_hint = Match code
net.seats = Seats net.seats = Seats
+4
View File
@@ -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.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.start = Un mapa aleatorio. Ni siquiera yo lo había visto antes.
demo.m2.wave = Han pasado dos minutos: llegan los héroes. 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)
+4
View File
@@ -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.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.start = Una mappa casuale. Nemmeno io l'avevo mai vista.
demo.m2.wave = Sono passati due minuti: arrivano gli eroi. 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)
+4
View File
@@ -1328,3 +1328,7 @@ demo.m2.title = Новый бросок
demo.m2.story = Эта карта создаётся заново при каждом прохождении кампании. Победи всех героев. demo.m2.story = Эта карта создаётся заново при каждом прохождении кампании. Победи всех героев.
demo.m2.start = Случайная карта. Даже я её раньше не видел. demo.m2.start = Случайная карта. Даже я её раньше не видел.
demo.m2.wave = Две минуты прошли: идут герои. 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)
+74 -9
View File
@@ -6,7 +6,9 @@
// //
// Lobby messages (JSON, one object per WebSocket frame): // Lobby messages (JSON, one object per WebSocket frame):
// {"t":"rooms"} -> list of open matches // {"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":"join","code":"ABC123"}
// {"t":"take_side","side":2} // {"t":"take_side","side":2}
// {"t":"ready","ready":true} // {"t":"ready","ready":true}
@@ -43,6 +45,8 @@ const MAX_PENDING: usize = 200;
const MAX_ROOMS: usize = 64; const MAX_ROOMS: usize = 64;
const MASTER_SIDES: [u8; 4] = [0, 2, 3, 4]; 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 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)] #[derive(Debug, Default)]
pub struct Netplay { pub struct Netplay {
@@ -75,6 +79,7 @@ struct Room {
map_h: usize, map_h: usize,
difficulty: u8, difficulty: u8,
mission: Option<usize>, mission: Option<usize>,
doc: Option<Value>, // editor campaign of a co-op match (sent to every client at the start)
} }
#[derive(Debug)] #[derive(Debug)]
@@ -176,7 +181,7 @@ fn mission_of(v: Option<&Value>) -> Option<usize> {
} }
pub async fn netplay_ws(ws: WebSocketUpgrade, Query(q): Query<WsQuery>, State(st): State<AppState>) -> Response { pub async fn netplay_ws(ws: WebSocketUpgrade, Query(q): Query<WsQuery>, State(st): State<AppState>) -> 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) { 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 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 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")); let mission = mission_of(msg.get("mission"));
if mission.map_or(false, |m| m >= MAX_MISSIONS) { let doc = msg.get("doc").filter(|d| !d.is_null()).cloned();
self.error(conn, "unknown campaign mission"); match &doc {
return None; 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 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::<HashMap<_, _>>(); let seats = sides.iter().map(|s| (*s, Seat { conn: None, name: String::new(), ready: false, ai: false })).collect::<HashMap<_, _>>();
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); self.rooms.insert(code.clone(), room);
if let Some(c) = self.clients.get_mut(&conn) { if let Some(c) = self.clients.get_mut(&conn) {
c.room = Some(code.clone()); c.room = Some(code.clone());
@@ -527,7 +544,13 @@ impl Netplay {
r.sides = active; r.sides = active;
} }
let msg = match self.rooms.get(&code) { 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, None => return,
}; };
self.room_msg_all(&code); self.room_msg_all(&code);
@@ -757,7 +780,8 @@ impl Netplay {
"map_h":r.map_h, "map_h":r.map_h,
"difficulty":r.difficulty, "difficulty":r.difficulty,
"seed":r.seed, "seed":r.seed,
"mission":r.mission "mission":r.mission,
"editor":r.doc.is_some()
})) }))
} }
@@ -767,7 +791,7 @@ impl Netplay {
.values() .values()
.map(|r| { .map(|r| {
let players = r.seats.values().filter(|s| s.conn.is_some()).count(); 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(); .collect();
self.send(conn, json!({"t":"rooms","rooms":rooms})); self.send(conn, json!({"t":"rooms","rooms":rooms}));
@@ -852,6 +876,47 @@ mod tests {
assert_eq!(start_red["mission"].as_u64(), Some(2)); 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] #[test]
fn a_non_host_cannot_start_a_match() { fn a_non_host_cannot_start_a_match() {
let mut net = Netplay::new(); let mut net = Netplay::new();
+58 -3
View File
@@ -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 // 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 // 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 { 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<usize> {
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<u8> = 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<Owner> = 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; 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 { let mut g = match &doc.map {
MapDoc::Fixed { game } => match load_fixed_map(game) { MapDoc::Fixed { game } => match load_fixed_map(game) {
Some(g) => g, Some(mut g) => {
None => start_mission_def(&generated_def(doc, run_map_seed(run_seed, i)), i, None, false), 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.msgs.clear();
g.state = 0; g.state = 0;
+8 -3
View File
@@ -1624,9 +1624,12 @@ impl Game {
} }
fn after_economy(&mut self, dt: f32) { 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; 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() { if self.waves_on() {
self.wave_tick(dt); self.wave_tick(dt);
} }
@@ -1636,7 +1639,7 @@ impl Game {
self.update_contested(); self.update_contested();
self.update_units(dt); 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_towers(dt);
self.update_reveals(dt); self.update_reveals(dt);
self.update_graves(dt); self.update_graves(dt);
@@ -1644,6 +1647,8 @@ impl Game {
self.update_rivals(dt); self.update_rivals(dt);
} }
self.update_dwarves(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.check_end();
self.update_fog(dt); self.update_fog(dt);
} }
+108 -2
View File
@@ -79,6 +79,9 @@ pub struct StartInfo {
pub local_side: Option<u8>, pub local_side: Option<u8>,
pub mission: Option<usize>, pub mission: Option<usize>,
pub ai: Vec<u8>, 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)] #[derive(Clone, Debug, Default)]
@@ -211,7 +214,20 @@ impl MatchClient {
self.send_create(sides, map_w, map_h, difficulty, Some(mission)); 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>) { 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 { if !self.connected {
return; return;
} }
@@ -219,7 +235,11 @@ impl MatchClient {
self.status = None; self.status = None;
self.chat.clear(); self.chat.clear();
self.chat_seen = 0; 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) { 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 difficulty = v.get("difficulty").and_then(|x| x.as_u64()).unwrap_or(1) as u8;
let sides = array_bytes(v.get("sides")); let sides = array_bytes(v.get("sides"));
let mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize); 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")); let mut ai = array_bytes(v.get("ai"));
if ai.is_empty() { if ai.is_empty() {
if let Some(r) = self.room.as_ref() { if let Some(r) = self.room.as_ref() {
@@ -467,7 +489,7 @@ impl MatchClient {
} }
r.ai = ai.clone(); 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.error = None;
self.status = None; self.status = None;
} }
@@ -655,4 +677,88 @@ mod tests {
assert_eq!(start.sides, vec![0u8, 3u8]); assert_eq!(start.sides, vec![0u8, 3u8]);
assert_eq!(start.ai, vec![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");
}
} }
+66 -19
View File
@@ -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")); 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::MatchTakeSide(side) => app.client.take_side(side),
MenuAction::MatchSetAi(side, ai) => app.client.set_ai(side, ai), MenuAction::MatchSetAi(side, ai) => app.client.set_ai(side, ai),
MenuAction::MatchReady(ready) => app.client.ready(ready), 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.game.coop = false;
app.match_mode = false; app.match_mode = false;
app.coop = false; app.coop = false;
app.coop_editor = false;
} }
MenuAction::MatchLeave => { MenuAction::MatchLeave => {
app.client.leave(); app.client.leave();
app.game.coop = false; app.game.coop = false;
app.match_mode = false; app.match_mode = false;
app.coop = false; app.coop = false;
app.coop_editor = false;
} }
MenuAction::MatchChat(text) => app.client.chat(&text, false), MenuAction::MatchChat(text) => app.client.chat(&text, false),
MenuAction::MatchEditField(i) => { MenuAction::MatchEditField(i) => {
@@ -438,6 +447,7 @@ struct App {
ws_id: i32, ws_id: i32,
match_mode: bool, match_mode: bool,
coop: bool, coop: bool,
coop_editor: bool, // the co-op match plays a mission of an editor campaign
fps: f32, fps: f32,
renderer: String, renderer: String,
frame_ms: f32, frame_ms: f32,
@@ -561,33 +571,56 @@ impl App {
self.game.coop = false; self.game.coop = false;
self.match_mode = false; self.match_mode = false;
self.coop = false; self.coop = false;
self.coop_editor = false;
self.client.on_disconnect(); 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 { fn start_match_game(&mut self, info: StartInfo) -> bool {
self.coop_editor = false;
if !info.sides.contains(&0) { if !info.sides.contains(&0) {
return false; 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 let Some(mission) = info.mission {
if !campaign::is_coop_mission(mission) { if !campaign::is_coop_mission(mission) {
return false; return false;
} }
let local = info.local_side.unwrap_or(0); let game = campaign::start_coop_mission(mission, None);
let mut game = campaign::start_coop_mission(mission, None); return self.start_coop_game(game, &info);
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;
} }
if info.map_w < 72 || info.map_h < 72 { if info.map_w < 72 || info.map_h < 72 {
return false; return false;
@@ -660,7 +693,7 @@ async fn main() {
let seed = (miniquad::date::now() * 1000.0) as u32 | 1; let seed = (miniquad::date::now() * 1000.0) as u32 | 1;
// behind the title menu: the endless bridge battle // behind the title menu: the endless bridge battle
let (game, cam) = Game::new_demo(seed); 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(); 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); 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 }; 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::MatchResume
| MenuAction::MatchAbort | MenuAction::MatchAbort
| MenuAction::MatchLeave | MenuAction::MatchLeave
| MenuAction::MatchCoopEditorCreate(..)
| MenuAction::MatchChat(..) | MenuAction::MatchChat(..)
| MenuAction::MatchEditField(..)) => account_action(a, &mut cloud, &mut menu, &mut app, now), | MenuAction::MatchEditField(..)) => account_action(a, &mut cloud, &mut menu, &mut app, now),
MenuAction::EditName => { 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) // 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), 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 => { UiAction::EndNext => {
if app.custom_campaign.is_some() { if app.custom_campaign.is_some() {
let campaign = app.custom_campaign.clone(); let campaign = app.custom_campaign.clone();
@@ -1621,8 +1665,11 @@ app.pending_briefing = briefing_of(&campaign, i);
for ev in app.game.events.drain(..) { for ev in app.game.events.drain(..) {
sounds.play(ev, vec2(app.cam.rx, app.cam.rz), right, hear, now); 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) // a won mission goes into the campaign progress (with the carry-over); a co-op mission of an
if app.custom_campaign.is_none() { // 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 let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) {
if app.game.state != 0 { if app.game.state != 0 {
app.recorded = true; app.recorded = true;
+46 -8
View File
@@ -278,6 +278,7 @@ enum Btn {
MatchCoopPrev, MatchCoopPrev,
MatchCoopNext, MatchCoopNext,
MatchCoopCreate, MatchCoopCreate,
MatchCoopSource, // switch between the built-in campaign and the editor campaign
MatchTake(usize), MatchTake(usize),
MatchAi(usize), MatchAi(usize),
MatchReady, MatchReady,
@@ -392,6 +393,7 @@ pub enum MenuAction {
MatchJoin(String), MatchJoin(String),
MatchCreate, MatchCreate,
MatchCoopCreate(usize), MatchCoopCreate(usize),
MatchCoopEditorCreate(usize), // co-op match of a mission of `campaign_doc`
MatchTakeSide(u8), MatchTakeSide(u8),
MatchSetAi(u8, bool), MatchSetAi(u8, bool),
MatchReady(bool), MatchReady(bool),
@@ -414,6 +416,8 @@ pub struct Menu {
pub error: Option<String>, // message on the login and account pages pub error: Option<String>, // message on the login and account pages
pub match_ui: MatchUi, // copied state of the multiplayer relay, shown on the Match page 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_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 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 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 pub custom_briefing: (String, String), // title and text of an editor mission's briefing
@@ -451,6 +455,8 @@ impl Menu {
error: None, error: None,
match_ui: MatchUi::default(), match_ui: MatchUi::default(),
coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0), coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0),
coop_editor: false,
coop_editor_mission: 0,
in_match: false, in_match: false,
editor_test: false, editor_test: false,
custom_briefing: (String::new(), String::new()), custom_briefing: (String::new(), String::new()),
@@ -746,16 +752,23 @@ impl Menu {
None => MenuAction::Consumed, None => MenuAction::Consumed,
}, },
Some(Btn::MatchCreate) => MenuAction::MatchCreate, Some(Btn::MatchCreate) => MenuAction::MatchCreate,
Some(Btn::MatchCoopPrev) => { Some(b @ (Btn::MatchCoopPrev | Btn::MatchCoopNext)) => {
self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, -1); 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; self.confirm = None;
MenuAction::Consumed MenuAction::Consumed
} }
Some(Btn::MatchCoopNext) => { Some(Btn::MatchCoopSource) => {
self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, 1); 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; self.confirm = None;
MenuAction::Consumed 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::MatchCoopCreate) => MenuAction::MatchCoopCreate(self.coop_mission),
Some(Btn::MatchReady) => MenuAction::MatchReady(!self.match_ui.ready), Some(Btn::MatchReady) => MenuAction::MatchReady(!self.match_ui.ready),
Some(Btn::MatchStart) => MenuAction::MatchStart, Some(Btn::MatchStart) => MenuAction::MatchStart,
@@ -1866,7 +1879,27 @@ impl Menu {
yy += CH() * 1.6; 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) { 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 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; 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::MatchCreate, tr("net.create")));
items.push((Btn::MatchJoin, tr("net.join"))); items.push((Btn::MatchJoin, tr("net.join")));
if !ui.room_open() { if !ui.room_open() {
items.push((Btn::MatchCoopPrev, "<".to_string())); if !editor_coop || !editor_list.is_empty() {
items.push((Btn::MatchCoopCreate, tr("net.coop_create"))); items.push((Btn::MatchCoopPrev, "<".to_string()));
items.push((Btn::MatchCoopNext, ">".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() { if ui.room_open() {
items.push((Btn::MatchReady, tr(if ui.ready { "net.unready" } else { "net.ready" }))); items.push((Btn::MatchReady, tr(if ui.ready { "net.unready" } else { "net.ready" })));