Manual AI seats for matches: host toggles empty seats, co-op allies fight heroes
This commit is contained in:
1 parent
3492eacd59
commit
ee7b76fa6a
10 files changed
+308
-43
No files matched your search
+156
-14
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user