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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-05 19:59:43 +01:00
1 parent 811c955bad
commit fc9c49d4ca
16 files changed
+1896 -49

No files matched your search

+193 -7
View File
@@ -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();