Versus teams, gold for allies, fallen hearts as spectators, multi-mission editor co-op
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
811c955bad
commit
fc9c49d4ca
16 files changed
+1896
-49
No files matched your search
+193
-7
@@ -19,9 +19,14 @@
|
||||
// {"t":"resume"} -> side 0 resumes a paused match
|
||||
// {"t":"abort"} -> side 0 closes the match
|
||||
// {"t":"set_ai","side":2,"ai":true} -> the host marks an empty seat as AI, or clears it again
|
||||
// {"t":"set_team","team":1,"side":3?} -> team 0..3 (A-D) of the sender's seat before the start; the
|
||||
// host may name an empty or AI seat with "side"
|
||||
// {"t":"next","mission":3,"seed":77?,"carry":[..]?} -> the host starts the next mission of an editor campaign
|
||||
// co-op match; every client gets "next" (no campaign: they
|
||||
// have it) with the seed, sides, AI seats and the carry
|
||||
// {"t":"leave"} -> a player leaves; a started human seat becomes AI-controlled while anyone is connected
|
||||
//
|
||||
// Server messages include: hello, rooms, room, start, turn, sum, chat, closed, error, pong.
|
||||
// Server messages include: hello, rooms, room, start, next, turn, sum, chat, closed, error, pong.
|
||||
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
@@ -88,6 +93,15 @@ struct Seat {
|
||||
name: String,
|
||||
ready: bool,
|
||||
ai: bool,
|
||||
team: u8, // 0..3 (A-D) in a versus match; by default every seat is a team of its own
|
||||
}
|
||||
|
||||
fn default_team(side: u8) -> u8 {
|
||||
MASTER_SIDES.iter().position(|s| *s == side).unwrap_or(0) as u8
|
||||
}
|
||||
|
||||
fn new_seat(side: u8, ai: bool) -> Seat {
|
||||
Seat { conn: None, name: String::new(), ready: false, ai, team: default_team(side) }
|
||||
}
|
||||
|
||||
impl Room {
|
||||
@@ -126,6 +140,19 @@ impl Room {
|
||||
self.desired_ai_sides()
|
||||
}
|
||||
}
|
||||
|
||||
// the team of every side of `sides`, in the same order
|
||||
fn teams_of(&self, sides: &[u8]) -> Vec<u8> {
|
||||
sides.iter().map(|s| self.seats.get(s).map_or(default_team(*s), |seat| seat.team)).collect()
|
||||
}
|
||||
|
||||
// the name of the editor campaign and the title of its current mission (room list)
|
||||
fn campaign_names(&self) -> (Option<String>, Option<String>) {
|
||||
let Some(doc) = &self.doc else { return (None, None) };
|
||||
let name = doc.get("name").and_then(|n| n.as_str()).map(|n| n.chars().take(40).collect::<String>());
|
||||
let title = self.mission.and_then(|m| doc.get("missions").and_then(|ms| ms.get(m))).and_then(|m| m.get("title")).and_then(|t| t.as_str()).map(|t| t.chars().take(40).collect::<String>());
|
||||
(name, title)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -269,6 +296,8 @@ impl Netplay {
|
||||
"take_side" => self.take_side(conn, &msg),
|
||||
"ready" => self.ready(conn, &msg),
|
||||
"set_ai" => self.set_ai(conn, &msg),
|
||||
"set_team" => self.set_team(conn, &msg),
|
||||
"next" => self.next(conn, &msg),
|
||||
"start" => self.start(conn),
|
||||
"cmds" => self.cmds(conn, &msg),
|
||||
"sum" => self.sum(conn, &msg),
|
||||
@@ -345,7 +374,7 @@ impl Netplay {
|
||||
}
|
||||
}
|
||||
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, new_seat(*s, 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, doc };
|
||||
self.rooms.insert(code.clone(), room);
|
||||
if let Some(c) = self.clients.get_mut(&conn) {
|
||||
@@ -493,7 +522,7 @@ impl Netplay {
|
||||
}
|
||||
r.sides.push(side);
|
||||
sort_sides(&mut r.sides);
|
||||
r.seats.insert(side, Seat { conn: None, name: String::new(), ready: false, ai: true });
|
||||
r.seats.insert(side, new_seat(side, true));
|
||||
} else {
|
||||
if r.seats[&side].conn.is_some() {
|
||||
return self.error(conn, "seat is taken");
|
||||
@@ -512,6 +541,68 @@ impl Netplay {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_team(&mut self, conn: u64, msg: &Value) {
|
||||
let team = match msg.get("team").and_then(|v| v.as_u64()) {
|
||||
Some(n) if n < MASTER_SIDES.len() as u64 => n as u8,
|
||||
_ => return self.error(conn, "unknown team"),
|
||||
};
|
||||
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
||||
Some(c) => c,
|
||||
None => return self.error(conn, "not in a match"),
|
||||
};
|
||||
let Some(own) = self.side_of(&code, conn) else { return self.error(conn, "no seat") };
|
||||
let side = msg.get("side").and_then(|v| v.as_u64()).map_or(own, |n| n as u8);
|
||||
if let Some(r) = self.rooms.get_mut(&code) {
|
||||
if r.started {
|
||||
return self.error(conn, "teams can only be changed before the start");
|
||||
}
|
||||
let Some(seat) = r.seats.get(&side) else { return self.error(conn, "side not in match") };
|
||||
if side != own && (own != 0 || seat.conn.is_some()) {
|
||||
return self.error(conn, "only the red master can set the team of a free or AI seat");
|
||||
}
|
||||
if let Some(seat) = r.seats.get_mut(&side) {
|
||||
seat.team = team;
|
||||
}
|
||||
}
|
||||
self.room_msg_all(&code);
|
||||
}
|
||||
|
||||
// the next mission of an editor campaign co-op match: the host decided it (the branch follows
|
||||
// the reached goal) and hands the carry-over of every side along; the clients already have the
|
||||
// campaign. The turn clock starts again.
|
||||
fn next(&mut self, conn: u64, msg: &Value) {
|
||||
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
||||
Some(c) => c,
|
||||
None => return self.error(conn, "not in a match"),
|
||||
};
|
||||
let mission = mission_of(msg.get("mission"));
|
||||
let seed = msg.get("seed").and_then(|v| v.as_u64()).map(|s| s as u32);
|
||||
let carry = msg.get("carry").filter(|c| c.is_array()).cloned().unwrap_or(Value::Array(Vec::new()));
|
||||
let out = match self.rooms.get_mut(&code) {
|
||||
Some(r) => {
|
||||
if !r.started {
|
||||
return self.error(conn, "match not started");
|
||||
}
|
||||
if r.seats.get(&0).and_then(|s| s.conn) != Some(conn) {
|
||||
return self.error(conn, "only the red master can start the next mission");
|
||||
}
|
||||
let count = r.doc.as_ref().and_then(|d| d.get("missions")).and_then(|m| m.as_array()).map_or(0, |m| m.len());
|
||||
let Some(m) = mission.filter(|m| *m < count) else { return self.error(conn, "unknown campaign mission") };
|
||||
r.mission = Some(m);
|
||||
r.seed = Some(seed.unwrap_or_else(random_seed));
|
||||
r.turn = 0;
|
||||
r.pending.clear();
|
||||
r.paused = false;
|
||||
r.missing = false;
|
||||
r.sides = r.active_sides();
|
||||
json!({"t":"next","seed":r.seed.unwrap_or(0),"difficulty":r.difficulty,"sides":r.sides,"teams":r.teams_of(&r.sides),"mission":m,"ai":r.started_ai_sides(),"carry":carry})
|
||||
}
|
||||
None => return self.error(conn, "not in a match"),
|
||||
};
|
||||
self.room_msg_all(&code);
|
||||
self.broadcast_room(&code, out);
|
||||
}
|
||||
|
||||
fn start(&mut self, conn: u64) {
|
||||
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
||||
Some(c) => c,
|
||||
@@ -545,7 +636,7 @@ impl Netplay {
|
||||
}
|
||||
let msg = match self.rooms.get(&code) {
|
||||
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()});
|
||||
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,"teams":r.teams_of(&r.sides),"mission":r.mission,"ai":r.started_ai_sides()});
|
||||
if let Some(doc) = &r.doc {
|
||||
m["doc"] = doc.clone();
|
||||
}
|
||||
@@ -763,9 +854,10 @@ impl Netplay {
|
||||
let sides: Vec<u8> = if r.started { r.active_sides() } else { r.sides.clone() };
|
||||
let seats: Vec<Value> = sides
|
||||
.iter()
|
||||
.filter_map(|side| r.seats.get(side).map(|s| json!({"side":side,"name":s.name,"ready":s.ready,"connected":s.conn.is_some(),"ai":s.ai})))
|
||||
.filter_map(|side| r.seats.get(side).map(|s| json!({"side":side,"name":s.name,"ready":s.ready,"connected":s.conn.is_some(),"ai":s.ai,"team":s.team})))
|
||||
.collect();
|
||||
let ai: Vec<u8> = r.room_ai_sides();
|
||||
let (campaign, title) = r.campaign_names();
|
||||
Some(json!({
|
||||
"t":"room",
|
||||
"code":r.code,
|
||||
@@ -781,7 +873,9 @@ impl Netplay {
|
||||
"difficulty":r.difficulty,
|
||||
"seed":r.seed,
|
||||
"mission":r.mission,
|
||||
"editor":r.doc.is_some()
|
||||
"editor":r.doc.is_some(),
|
||||
"campaign":campaign,
|
||||
"title":title
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -791,7 +885,8 @@ 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,"editor":r.doc.is_some()})
|
||||
let (campaign, title) = r.campaign_names();
|
||||
json!({"code":r.code,"players":players,"sides":r.sides,"teams":r.teams_of(&r.sides),"started":r.started,"paused":r.paused,"mission":r.mission,"editor":r.doc.is_some(),"campaign":campaign,"title":title})
|
||||
})
|
||||
.collect();
|
||||
self.send(conn, json!({"t":"rooms","rooms":rooms}));
|
||||
@@ -917,6 +1012,97 @@ mod tests {
|
||||
assert!(start.get("doc").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn players_choose_their_teams_and_the_start_carries_them() {
|
||||
let mut net = Netplay::new();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let red = net.connect(tx, Some("red".into()), None);
|
||||
let rooms = net.handle(red, json!({"t":"create","sides":[0,2,3]}));
|
||||
let code = rooms[0].clone();
|
||||
let room = until(&mut rx, "room");
|
||||
let teams: Vec<u64> = room["seats"].as_array().unwrap().iter().map(|s| s["team"].as_u64().unwrap()).collect();
|
||||
assert_eq!(teams, vec![0, 1, 2], "every seat is its own team at first");
|
||||
|
||||
let (tx2, mut rx2) = mpsc::unbounded_channel();
|
||||
let green = net.connect(tx2, Some("green".into()), None);
|
||||
net.handle(green, json!({"t":"join","code":&code}));
|
||||
// green joins red's team; green may not decide for blue's seat, red (host) may
|
||||
net.handle(green, json!({"t":"set_team","team":0}));
|
||||
net.handle(green, json!({"t":"set_team","team":3,"side":3}));
|
||||
assert_eq!(until(&mut rx2, "error")["why"].as_str(), Some("only the red master can set the team of a free or AI seat"));
|
||||
net.handle(red, json!({"t":"set_ai","side":3,"ai":true}));
|
||||
net.handle(red, json!({"t":"set_team","team":3,"side":3}));
|
||||
net.handle(red, json!({"t":"set_team","team":9}));
|
||||
assert_eq!(until(&mut rx, "error")["why"].as_str(), Some("unknown team"));
|
||||
let room = net.room_json(&code).unwrap();
|
||||
let team = |side: u64| room["seats"].as_array().unwrap().iter().find(|s| s["side"].as_u64() == Some(side)).unwrap()["team"].as_u64();
|
||||
assert_eq!((team(0), team(2), team(3)), (Some(0), Some(0), Some(3)));
|
||||
|
||||
net.handle(red, json!({"t":"rooms"}));
|
||||
let list = until(&mut rx, "rooms");
|
||||
assert_eq!(list["rooms"][0]["teams"], json!([0, 0, 3]));
|
||||
|
||||
net.handle(red, json!({"t":"ready"}));
|
||||
net.handle(green, json!({"t":"ready"}));
|
||||
net.handle(red, json!({"t":"start"}));
|
||||
let start = until(&mut rx2, "start");
|
||||
assert_eq!(start["sides"], json!([0, 2, 3]));
|
||||
assert_eq!(start["teams"], json!([0, 0, 3]));
|
||||
net.handle(red, json!({"t":"set_team","team":1}));
|
||||
assert_eq!(until(&mut rx, "error")["why"].as_str(), Some("teams can only be changed before the start"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_starts_the_next_editor_mission_for_everybody() {
|
||||
let mut net = Netplay::new();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let red = net.connect(tx, Some("red".into()), None);
|
||||
let doc = json!({"name":"Deep Run","missions":[{"key":"a","title":"First"},{"key":"b","title":"Second"}]});
|
||||
let rooms = net.handle(red, json!({"t":"create","sides":[0,2],"map_w":96,"map_h":96,"mission":0,"doc":doc}));
|
||||
let code = rooms[0].clone();
|
||||
let (tx2, mut rx2) = mpsc::unbounded_channel();
|
||||
let green = net.connect(tx2, Some("green".into()), None);
|
||||
net.handle(green, json!({"t":"rooms"}));
|
||||
let list = until(&mut rx2, "rooms");
|
||||
assert_eq!(list["rooms"][0]["campaign"].as_str(), Some("Deep Run"), "the room list names the campaign");
|
||||
assert_eq!(list["rooms"][0]["title"].as_str(), Some("First"));
|
||||
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"}));
|
||||
until(&mut rx, "start");
|
||||
until(&mut rx2, "start");
|
||||
net.handle(red, json!({"t":"cmds","cmds":[{"side":0,"seq":0,"act":"CancelOrders"}]}));
|
||||
net.tick(&code);
|
||||
net.tick(&code);
|
||||
assert_eq!(net.rooms[&code].turn, 2);
|
||||
|
||||
// only the host decides; the mission must exist
|
||||
net.handle(green, json!({"t":"next","mission":1}));
|
||||
assert_eq!(until(&mut rx2, "error")["why"].as_str(), Some("only the red master can start the next mission"));
|
||||
net.handle(red, json!({"t":"next","mission":5}));
|
||||
assert_eq!(until(&mut rx, "error")["why"].as_str(), Some("unknown campaign mission"));
|
||||
|
||||
net.handle(red, json!({"t":"cmds","cmds":[{"side":0,"seq":1,"act":"CancelOrders"}]}));
|
||||
let carry = json!([[0, {"creatures":[["Ogre",3,10.0]],"gold":300.0}], [2, {"creatures":[],"gold":40.0}]]);
|
||||
net.handle(red, json!({"t":"next","mission":1,"seed":4711,"carry":carry}));
|
||||
let next_red = until(&mut rx, "next");
|
||||
let next_green = until(&mut rx2, "next");
|
||||
assert_eq!(next_green["mission"].as_u64(), Some(1));
|
||||
assert_eq!(next_green["seed"].as_u64(), Some(4711), "the run seed of the host");
|
||||
assert_eq!(next_green["sides"], json!([0, 2]));
|
||||
assert_eq!(next_green["carry"], carry, "the carry goes to everybody");
|
||||
assert_eq!(next_red, next_green);
|
||||
assert!(next_green.get("doc").is_none(), "the clients have the campaign already");
|
||||
let room = net.room_json(&code).unwrap();
|
||||
assert_eq!(room["title"].as_str(), Some("Second"));
|
||||
assert_eq!(room["turn"].as_u64(), Some(0), "the turn clock starts again");
|
||||
net.tick(&code);
|
||||
let turn = until(&mut rx2, "turn");
|
||||
assert_eq!(turn["turn"].as_u64(), Some(1));
|
||||
assert_eq!(turn["cmds"], json!([]), "orders of the old mission are dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_host_cannot_start_a_match() {
|
||||
let mut net = Netplay::new();
|
||||
|
||||
Reference in new issue
Block a user