Co-op matches of editor campaigns
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0966e3318f
commit
abe01ff23e
13 files changed
+394
-44
No files matched your search
@@ -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`)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
+74
-9
@@ -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<usize>,
|
||||
doc: Option<Value>, // 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<usize> {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -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::<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);
|
||||
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();
|
||||
|
||||
+58
-3
@@ -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<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;
|
||||
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;
|
||||
|
||||
+8
-3
@@ -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);
|
||||
}
|
||||
|
||||
+108
-2
@@ -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");
|
||||
}
|
||||
}
|
||||
+66
-19
@@ -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;
|
||||
|
||||
+46
-8
@@ -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<String>, // 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" })));
|
||||
|
||||
Reference in new issue
Block a user