Manual AI seats for matches: host toggles empty seats, co-op allies fight heroes

This commit is contained in:
Richard Zink committed 2026-11-05 06:38:38 +01:00
1 parent 3492eacd59
commit ee7b76fa6a
10 files changed
+308 -43

No files matched your search

+156 -14
View File
@@ -16,7 +16,8 @@
// {"t":"chat","text":"hello"}
// {"t":"resume"} -> side 0 resumes a paused match
// {"t":"abort"} -> side 0 closes the match
// {"t":"leave"} -> a player leaves; empty seats are AI-controlled while anyone is connected
// {"t":"set_ai","side":2,"ai":true} -> the host marks an empty seat as AI, or clears it again
// {"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.
@@ -81,6 +82,7 @@ struct Seat {
conn: Option<u64>,
name: String,
ready: bool,
ai: bool,
}
impl Room {
@@ -88,13 +90,37 @@ impl Room {
self.seats.values().any(|s| s.conn.is_some())
}
fn ai_sides(&self) -> Vec<u8> {
fn active_sides(&self) -> Vec<u8> {
self.sides
.iter()
.filter(|side| self.seats.get(side).map(|seat| seat.conn.is_none()).unwrap_or(true))
.copied()
.filter(|side| self.seats.get(side).map(|seat| seat.conn.is_some() || seat.ai).unwrap_or(false))
.collect()
}
fn desired_ai_sides(&self) -> Vec<u8> {
self.sides
.iter()
.filter(|side| self.seats.get(side).map(|seat| seat.conn.is_none() && seat.ai).unwrap_or(false))
.copied()
.collect()
}
fn started_ai_sides(&self) -> Vec<u8> {
self.active_sides()
.iter()
.filter(|side| self.seats.get(side).map(|seat| seat.conn.is_none()).unwrap_or(false))
.copied()
.collect()
}
fn room_ai_sides(&self) -> Vec<u8> {
if self.started {
self.started_ai_sides()
} else {
self.desired_ai_sides()
}
}
}
#[derive(Debug, Deserialize)]
@@ -138,6 +164,10 @@ fn sides_of(v: Option<&Value>) -> Vec<u8> {
out
}
fn sort_sides(sides: &mut Vec<u8>) {
sides.sort_by_key(|s| MASTER_SIDES.iter().position(|m| m == s).unwrap_or(9));
}
fn mission_of(v: Option<&Value>) -> Option<usize> {
match v {
None | Some(Value::Null) => None,
@@ -233,6 +263,7 @@ impl Netplay {
"join" => self.join(conn, &msg),
"take_side" => self.take_side(conn, &msg),
"ready" => self.ready(conn, &msg),
"set_ai" => self.set_ai(conn, &msg),
"start" => self.start(conn),
"cmds" => self.cmds(conn, &msg),
"sum" => self.sum(conn, &msg),
@@ -261,7 +292,7 @@ impl Netplay {
}
}
r.turn += 1;
Some((r.turn, cmds, r.ai_sides()))
Some((r.turn, cmds, r.started_ai_sides()))
} else {
None
}
@@ -294,7 +325,7 @@ impl Netplay {
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 })).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 };
self.rooms.insert(code.clone(), room);
if let Some(c) = self.clients.get_mut(&conn) {
@@ -326,7 +357,7 @@ impl Netplay {
self.error(conn, "cannot join a match in progress");
return;
}
let side = match self.rooms[&code].sides.iter().copied().find(|s| self.rooms[&code].seats.get(s).map(|seat| seat.conn.is_none()).unwrap_or(false)) {
let side = match self.rooms[&code].sides.iter().copied().find(|s| self.rooms[&code].seats.get(s).map(|seat| seat.conn.is_none() && !seat.ai).unwrap_or(false)) {
Some(s) => s,
None => {
self.error(conn, "match is full");
@@ -365,7 +396,7 @@ impl Netplay {
let mut taken = false;
if let Some(r) = self.rooms.get_mut(&code) {
started = r.started;
if !started && r.sides.contains(&side) && r.seats.get(&side).map(|s| s.conn.is_none()).unwrap_or(false) {
if !started && r.sides.contains(&side) && r.seats.get(&side).map(|s| s.conn.is_none() && !s.ai).unwrap_or(false) {
for s in r.seats.values_mut() {
if s.conn == Some(conn) {
s.conn = None;
@@ -377,6 +408,7 @@ impl Netplay {
t.conn = Some(conn);
t.name = name;
t.ready = false;
t.ai = false;
taken = true;
}
}
@@ -412,6 +444,54 @@ impl Netplay {
}
}
fn set_ai(&mut self, conn: u64, msg: &Value) {
let side = match msg.get("side").and_then(|v| v.as_u64()) {
Some(n) => n as u8,
None => return self.error(conn, "need a side"),
};
let ai = msg.get("ai").and_then(|v| v.as_bool()).unwrap_or(true);
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"),
};
if !MASTER_SIDES.contains(&side) {
return self.error(conn, "unknown side");
}
if side == 0 {
return self.error(conn, "the host seat stays human");
}
if let Some(r) = self.rooms.get_mut(&code) {
if r.started {
return self.error(conn, "AI seats can only be changed before the start");
}
if r.seats.get(&0).and_then(|s| s.conn) != Some(conn) {
return self.error(conn, "only the red master can set AI seats");
}
if !r.seats.contains_key(&side) {
if !ai {
return self.error(conn, "side not in match");
}
r.sides.push(side);
sort_sides(&mut r.sides);
r.seats.insert(side, Seat { conn: None, name: String::new(), ready: false, ai: true });
} else {
if r.seats[&side].conn.is_some() {
return self.error(conn, "seat is taken");
}
if let Some(s) = r.seats.get_mut(&side) {
s.ai = ai;
s.ready = false;
if !ai {
s.name.clear();
}
}
}
self.room_msg_all(&code);
} else {
self.error(conn, "not in a match");
}
}
fn start(&mut self, conn: u64) {
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
Some(c) => c,
@@ -429,17 +509,25 @@ impl Netplay {
return self.error(conn, "not everyone is ready");
}
}
let active = r.active_sides();
if active.is_empty() || !active.contains(&0) {
return self.error(conn, "no playable master seat");
}
if active.len() < 2 && r.mission.is_none() {
return self.error(conn, "a free match needs at least two masters");
}
r.started = true;
r.paused = false;
r.missing = false;
r.seed = Some(random_seed());
r.turn = 0;
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}),
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()}),
None => return,
};
self.room_msg(conn, &code);
self.room_msg_all(&code);
self.broadcast_room(&code, msg);
}
@@ -550,6 +638,9 @@ impl Netplay {
Some(r) => {
for s in r.seats.values_mut() {
if s.conn == Some(conn) {
if r.started {
s.ai = true;
}
s.conn = None;
s.name.clear();
s.ready = false;
@@ -643,16 +734,16 @@ impl Netplay {
fn room_json(&self, code: &str) -> Option<Value> {
let r = self.rooms.get(code)?;
let seats: Vec<Value> = r
.sides
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()})))
.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})))
.collect();
let ai: Vec<u8> = if r.started { r.ai_sides() } else { Vec::new() };
let ai: Vec<u8> = r.room_ai_sides();
Some(json!({
"t":"room",
"code":r.code,
"sides":r.sides,
"sides":sides,
"seats":seats,
"ai":ai,
"turn":r.turn,
@@ -799,6 +890,57 @@ mod tests {
assert_eq!(turn["ai"].as_array().unwrap()[0].as_u64(), Some(2));
}
#[test]
fn a_host_can_mark_an_empty_seat_as_ai() {
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]}));
let code = rooms[0].clone();
net.handle(red, json!({"t":"set_ai","side":2,"ai":true}));
net.handle(red, json!({"t":"ready"}));
net.handle(red, json!({"t":"start"}));
let start = until(&mut rx, "start");
assert_eq!(start["sides"], json!([0, 2]));
assert_eq!(start["ai"], json!([2]));
net.tick(&code);
let turn = until(&mut rx, "turn");
assert_eq!(turn["ai"], json!([2]));
}
#[test]
fn a_coop_host_can_clear_an_ai_seat_and_start_alone() {
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],"mission":2}));
net.handle(red, json!({"t":"set_ai","side":2,"ai":true}));
net.handle(red, json!({"t":"set_ai","side":2,"ai":false}));
net.handle(red, json!({"t":"ready"}));
net.handle(red, json!({"t":"start"}));
let start = until(&mut rx, "start");
assert_eq!(start["sides"], json!([0]));
assert_eq!(start["ai"], json!([]));
}
#[test]
fn a_free_match_does_not_turn_empty_seats_into_ai_by_itself() {
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]}));
let code = rooms[0].clone();
net.handle(red, json!({"t":"ready"}));
net.handle(red, json!({"t":"start"}));
let err = until(&mut rx, "error");
assert_eq!(err["why"].as_str(), Some("a free match needs at least two masters"));
assert!(!net.rooms[&code].started);
}
#[test]
fn the_relay_closes_a_started_match_after_the_last_player_leaves() {
let mut net = Netplay::new();