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
+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();
|
||||
|
||||
Reference in new issue
Block a user