diff --git a/VERSION b/VERSION index 9cbe113..6b29bfb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v202611-00013 +v202611-00014 diff --git a/docs/MULTIPLAYER.md b/docs/MULTIPLAYER.md index f5615b7..6353401 100644 --- a/docs/MULTIPLAYER.md +++ b/docs/MULTIPLAYER.md @@ -6,8 +6,9 @@ Multiplayer is only available for users of a server (not in the standalone file) - Modes: **versus** (every player a master with an own heart), **coop** (every player an own master, together against AI masters and/or the humans) and a **coop campaign of 20 missions**. -- 2 to 4 players per match; free slots can be AI masters. +- 1 to 4 players per match; free slots are empty unless the host marks them as AI with `set_ai` before the start. - The match **pauses while a player is missing** (connection lost); a player who **leaves** is replaced by an AI. +- Before the start, the host manually marks a free seat as AI with `{"t":"set_ai","side":n,"ai":true}` and clears it again with `ai:false`; side 0 stays human. - The humans (hero waves of the Order) as a third party (can be switched off), chat, alliances/teams. - Coop is "separate but helpful": own heart, gold, mana, research and creatures; allies never fight each other, their creatures help each other, gold can be sent, allied ground can be walked; a fallen heart only diff --git a/lang/de.lang b/lang/de.lang index ae2bb99..3778c6b 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -643,6 +643,8 @@ net.side0 = Rot net.side2 = Blau net.side3 = Grün net.side4 = Gelb +net.ai = KI +net.ai_off = Aus # rival masters (free game) newgame.difficulty = Gegner diff --git a/lang/en.lang b/lang/en.lang index a99f623..33e251a 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -644,6 +644,8 @@ net.side0 = Red net.side2 = Blue net.side3 = Green net.side4 = Yellow +net.ai = AI +net.ai_off = Off # rival masters (free game) newgame.difficulty = Opponents diff --git a/server/src/netplay.rs b/server/src/netplay.rs index 6857a58..aeeafd0 100644 --- a/server/src/netplay.rs +++ b/server/src/netplay.rs @@ -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, 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 { + fn active_sides(&self) -> Vec { 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 { + 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 { + 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 { + 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 { out } +fn sort_sides(sides: &mut Vec) { + sides.sort_by_key(|s| MASTER_SIDES.iter().position(|m| m == s).unwrap_or(9)); +} + fn mission_of(v: Option<&Value>) -> Option { 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::>(); + let seats = sides.iter().map(|s| (*s, Seat { conn: None, name: String::new(), ready: false, ai: false })).collect::>(); 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 { let r = self.rooms.get(code)?; - let seats: Vec = r - .sides + let sides: Vec = if r.started { r.active_sides() } else { r.sides.clone() }; + let seats: Vec = 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 = if r.started { r.ai_sides() } else { Vec::new() }; + let ai: Vec = 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(); diff --git a/src/game.rs b/src/game.rs index 9f1ca16..78fdd4d 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1308,12 +1308,15 @@ impl Game { side == self.me_side || self.masters.iter().any(|m| m.side == side) } - fn is_hostile_master(&self, side: u8) -> bool { - self.coop && Owner::MASTER_SIDES.contains(&self.me_side) && Owner::MASTER_SIDES.contains(&side) + pub(crate) fn is_coop_ally_side(&self, side: u8) -> bool { + self.coop + && Owner::MASTER_SIDES.contains(&self.local_side) + && Owner::MASTER_SIDES.contains(&side) + && (side == self.local_side || self.is_master(side) || self.ai_sides.iter().any(|&s| s == side)) } pub(crate) fn is_hostile(&self, e: &Ent) -> bool { - e.alive && !e.held && e.captive == 0 && !e.chained && e.side() != self.me_side && !self.is_hostile_master(e.side()) + e.alive && !e.held && e.captive == 0 && !e.chained && e.side() != self.me_side && !self.is_coop_ally_side(e.side()) } pub fn is_rival_side(&self, side: u8) -> bool { @@ -1420,7 +1423,10 @@ impl Game { #[allow(dead_code)] // used by the tests; the *Multiplayer* menu (step D) will call it pub fn start_masters(&mut self, sides: &[u8], local: u8) -> bool { let sides: Vec = sides.iter().copied().filter(|s| Owner::MASTER_SIDES.contains(s)).collect(); - if sides.len() < 2 || !sides.contains(&0) || !sides.contains(&local) { + if sides.is_empty() || !sides.contains(&0) || !sides.contains(&local) { + return false; + } + if sides.len() < 2 && !self.coop { return false; } let (w, h) = (mw() as i32, mh() as i32); @@ -2362,8 +2368,8 @@ impl Game { let mut bd = radius * radius; for o in &self.ents { // every other side is an enemy; co-op master sides fight the heroes, not each other - let coop_allies = self.coop && Owner::MASTER_SIDES.contains(&e.side()) && Owner::MASTER_SIDES.contains(&o.side()); - if !o.alive || o.held || o.side() == e.side() || coop_allies || o.captive != 0 || o.chained || o.invis > 0.0 { + let coop_ally = self.is_coop_ally_side(o.side()); + if !o.alive || o.held || o.side() == e.side() || coop_ally || o.captive != 0 || o.chained || o.invis > 0.0 { continue; } let d = (o.x - e.x).powi(2) + (o.y - e.y).powi(2); @@ -6295,5 +6301,28 @@ mod tests { assert!(seen); assert!(g.ents.iter().filter(|e| e.kind == EK::Chainbreaker).count() <= 1); } + + #[test] + fn a_coop_ai_seat_fights_beside_the_host_not_against_him() { + let mut g = Game::new(11, 96, 128); + g.init_fog(); + g.coop = true; + assert!(g.start_masters(&[0, 2, 3], 2), "a co-op match may keep an empty side as AI"); + g.sync_ai_sides(&[3], 0); + assert!(g.is_coop_ally_side(3), "side 3 is an allied master seat, not a rival"); + + g.ents.clear(); + let (cx, cy) = g.map.core; + let local = g.spawn(EK::Kobold, false, cx as f32 + 3.5, cy as f32 + 3.5, 1); + let ally = g.spawn(EK::Kobold, false, cx as f32 + 4.5, cy as f32 + 3.5, 1); + if let Some(e) = g.ents.iter_mut().find(|e| e.id == ally) { + e.side = 3; + } + + let local = g.ents.iter().find(|e| e.id == local).unwrap().clone(); + let ally = g.ents.iter().find(|e| e.id == ally).unwrap().clone(); + assert!(!g.is_hostile(&ally)); + assert_eq!(g.nearest_enemy(&local, 5.0), None, "the allied AI creature is no target for the local master"); + } } diff --git a/src/lobby.rs b/src/lobby.rs index 350addd..b0de22c 100644 --- a/src/lobby.rs +++ b/src/lobby.rs @@ -29,6 +29,7 @@ pub struct SeatUi { pub name: String, pub ready: bool, pub connected: bool, + pub ai: bool, } #[derive(Clone, Debug, Default)] @@ -51,6 +52,7 @@ pub struct MatchUi { pub is_host: bool, pub sides: Vec, pub seats: Vec, + pub ai: Vec, pub rooms: Vec, pub started: bool, pub paused: bool, @@ -76,6 +78,7 @@ pub struct StartInfo { pub sides: Vec, pub local_side: Option, pub mission: Option, + pub ai: Vec, } #[derive(Clone, Debug, Default)] @@ -84,6 +87,7 @@ struct SeatInfo { name: String, ready: bool, connected: bool, + ai: bool, } #[derive(Clone, Debug, Default)] @@ -211,6 +215,13 @@ impl MatchClient { self.outbox.push_back(json!({"t":"take_side","side":side}).to_string()); } + pub fn set_ai(&mut self, side: u8, ai: bool) { + if !self.connected { + return; + } + self.outbox.push_back(json!({"t":"set_ai","side":side,"ai":ai}).to_string()); + } + pub fn ready(&mut self, ready: bool) { if !self.connected { return; @@ -394,6 +405,12 @@ impl MatchClient { let difficulty = v.get("difficulty").and_then(|x| x.as_u64()).unwrap_or(1) as u8; let sides = array_bytes(v.get("sides")); let mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize); + let mut ai = array_bytes(v.get("ai")); + if ai.is_empty() { + if let Some(r) = self.room.as_ref() { + ai = r.ai.clone(); + } + } let local_side = self.room.as_ref().and_then(|r| r.seats.iter().find(|s| s.name == self.name && s.connected).map(|s| s.side)); self.local_side = local_side; if let Some(r) = self.room.as_mut() { @@ -404,8 +421,9 @@ impl MatchClient { if !sides.is_empty() { r.sides = sides.clone(); } + r.ai = ai.clone(); } - self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission }); + self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai }); self.error = None; self.status = None; } @@ -449,7 +467,8 @@ impl MatchClient { ready, is_host, sides: room.sides.clone(), - seats: room.seats.iter().map(|s| SeatUi { side: s.side, name: s.name.clone(), ready: s.ready, connected: s.connected }).collect(), + ai: room.ai.clone(), + seats: room.seats.iter().map(|s| SeatUi { side: s.side, name: s.name.clone(), ready: s.ready, connected: s.connected, ai: s.ai }).collect(), rooms: self.rooms.clone(), started: room.started, paused: room.paused, @@ -477,6 +496,7 @@ fn seats_from(v: Option<&Value>) -> Vec { name: s.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string(), ready: s.get("ready").and_then(|x| x.as_bool()).unwrap_or(false), connected: s.get("connected").and_then(|x| x.as_bool()).unwrap_or(false), + ai: s.get("ai").and_then(|x| x.as_bool()).unwrap_or(false), }) .collect() }) @@ -542,4 +562,31 @@ mod tests { let chat = client.take_new_chat(); assert_eq!(chat[0], (Some(0), "red".to_string(), "hello".to_string())); } + + #[test] + fn the_bridge_holds_ai_seats_through_the_lobby() { + let mut client = MatchClient::new(); + client.on_connect("red"); + pop(&mut client); // rooms + + client.create(&[0, 2, 3, 4], 96, 128, 1); + pop(&mut client); // create + + client.set_ai(3, true); + let req = pop(&mut client); + assert_eq!(req["t"].as_str(), Some("set_ai")); + assert_eq!(req["side"].as_u64(), Some(3)); + assert_eq!(req["ai"].as_bool(), Some(true)); + + client.feed(r#"{"t":"room","code":"ABC123","sides":[0,2,3,4],"seats":[{"side":0,"name":"red","ready":false,"connected":true,"ai":false},{"side":2,"name":"","ready":false,"connected":false,"ai":false},{"side":3,"name":"","ready":false,"connected":false,"ai":true},{"side":4,"name":"","ready":false,"connected":false,"ai":false}],"ai":[3],"turn":0,"started":false,"paused":false,"missing":false}"#); + let ui = client.ui(); + assert_eq!(ui.ai, vec![3u8]); + assert!(ui.seats.iter().any(|s| s.side == 3 && s.ai)); + assert!(ui.seats.iter().any(|s| s.side == 2 && !s.connected && !s.ai)); + + client.feed(r#"{"t":"start","seed":7,"map_w":96,"map_h":128,"difficulty":1,"sides":[0,3],"ai":[3]}"#); + let start = client.take_start().expect("start info with ai seats"); + assert_eq!(start.sides, vec![0u8, 3u8]); + assert_eq!(start.ai, vec![3u8]); + } } diff --git a/src/main.rs b/src/main.rs index 341bb16..b4b1464 100644 --- a/src/main.rs +++ b/src/main.rs @@ -248,6 +248,7 @@ fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut A } } MenuAction::MatchTakeSide(side) => app.client.take_side(side), + MenuAction::MatchSetAi(side, ai) => app.client.set_ai(side, ai), MenuAction::MatchReady(ready) => app.client.ready(ready), MenuAction::MatchStart => app.client.start(), MenuAction::MatchResume => app.client.resume(), @@ -518,6 +519,7 @@ impl App { return false; } game.ai_difficulty = info.difficulty; + game.sync_ai_sides(&info.ai, info.difficulty); game.hints = self.settings.hints; self.game = game; let (cx, cy) = self.game.map.core; @@ -537,6 +539,7 @@ impl App { } self.game.coop = false; self.game.ai_difficulty = info.difficulty; + self.game.sync_ai_sides(&info.ai, info.difficulty); self.game.hints = self.settings.hints; self.game.init_fog(); let (cx, cy) = self.game.map.core; @@ -552,11 +555,8 @@ impl App { return; } for (_, cmds, ai) in self.client.take_turns() { - if self.coop { - self.game.ai_sides.clear(); - } else { - self.game.sync_ai_sides(&ai, self.settings.difficulty); - } + let difficulty = self.game.ai_difficulty; + self.game.sync_ai_sides(&ai, difficulty); self.game.receive_turn(&cmds); } for (_, turn, sum) in self.client.take_checks() { @@ -1098,6 +1098,7 @@ async fn main() { | MenuAction::MatchCreate | MenuAction::MatchCoopCreate(..) | MenuAction::MatchTakeSide(..) + | MenuAction::MatchSetAi(..) | MenuAction::MatchReady(..) | MenuAction::MatchStart | MenuAction::MatchResume diff --git a/src/menu.rs b/src/menu.rs index ea7b16f..1407b32 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -235,6 +235,7 @@ enum Btn { MatchCoopNext, MatchCoopCreate, MatchTake(usize), + MatchAi(usize), MatchReady, MatchStart, MatchResume, @@ -312,6 +313,7 @@ pub enum MenuAction { MatchCreate, MatchCoopCreate(usize), MatchTakeSide(u8), + MatchSetAi(u8, bool), MatchReady(bool), MatchStart, MatchResume, @@ -654,6 +656,10 @@ impl Menu { Some(s) => MenuAction::MatchTakeSide(s.side), None => MenuAction::Consumed, }, + Some(Btn::MatchAi(i)) => match self.match_ui.seats.get(i) { + Some(s) if s.side != 0 => MenuAction::MatchSetAi(s.side, !s.ai), + _ => MenuAction::Consumed, + }, Some(Btn::Campaign) => { self.page = Some(Page::Campaign); self.scroll = 0; @@ -1462,7 +1468,12 @@ impl Menu { yy += CH() * 1.4; let seat_cols = (cols - 6) as usize; for (k, s) in ui.seats.iter().take(4).enumerate() { - let name = if Some(s.side) == ui.local_side { format!("{} ({})", s.name, tr("net.you")) } else { s.name.clone() }; + let is_local = Some(s.side) == ui.local_side; + let name = if is_local { + format!("{} ({})", s.name, tr("net.you")) + } else { + s.name.clone() + }; let side_label = match s.side { 0 => tr("net.side0"), 2 => tr("net.side2"), @@ -1470,13 +1481,33 @@ impl Menu { 4 => tr("net.side4"), _ => s.side.to_string(), }; - let line = format!("{} {} {} {} {}", side_label, name, if s.connected { "●" } else { "○" }, if s.ready { "✓" } else { "—" }, if s.side == 0 { tr("net.host") } else { String::new() }); - gfx.text(&line, ix, yy, SMALL(), if s.connected { TXT } else { DIM }); + let status = if s.connected { "●" } else if s.ai { "AI" } else { "○" }; + let ready = if s.ready || s.ai { "✓" } else { "—" }; + let line = format!("{} {} {} {} {}", side_label, name, status, ready, if s.side == 0 { tr("net.host") } else { String::new() }); + gfx.text(&line, ix, yy, SMALL(), if s.connected || s.ai { TXT } else { DIM }); if !ui.started && ui.local_side.is_some() && !s.connected { - let r = Rect::new(ix + 34.0 * CW(), yy, 12.0 * CW(), CH() * 2.0); - let label = if Some(s.side) == ui.local_side { tr("net.you") } else { tr("net.join") }; - button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME); - self.buttons.push((r, Btn::MatchTake(k))); + if ui.is_host && s.side != 0 { + if s.ai { + let r = Rect::new(ix + 34.0 * CW(), yy, 12.0 * CW(), CH() * 2.0); + let label = tr("net.ai_off"); + button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME); + self.buttons.push((r, Btn::MatchAi(k))); + } else { + let join_label = if is_local { tr("net.you") } else { tr("net.join") }; + let r = Rect::new(ix + 22.0 * CW(), yy, 12.0 * CW(), CH() * 2.0); + button(gfx, r, &join_label, r.contains(vec2(mx, my)), FRAME); + self.buttons.push((r, Btn::MatchTake(k))); + let ai_label = tr("net.ai"); + let r = Rect::new(ix + 36.0 * CW(), yy, 12.0 * CW(), CH() * 2.0); + button(gfx, r, &ai_label, r.contains(vec2(mx, my)), FRAME); + self.buttons.push((r, Btn::MatchAi(k))); + } + } else if !s.ai { + let r = Rect::new(ix + 34.0 * CW(), yy, 12.0 * CW(), CH() * 2.0); + let label = if is_local { tr("net.you") } else { tr("net.join") }; + button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME); + self.buttons.push((r, Btn::MatchTake(k))); + } } yy += CH() * 2.0; } diff --git a/src/rival.rs b/src/rival.rs index 3236c80..cedac10 100644 --- a/src/rival.rs +++ b/src/rival.rs @@ -196,16 +196,26 @@ impl Game { // attack: about two thirds of the creatures march on an enemy if self.rivals[k].attack_t <= 0.0 { self.rivals[k].attack_t = p.attack * (0.8 + self.rng.f() * 0.4); - let mut targets: Vec<(u8, u32)> = vec![(0, 3)]; - for o in &self.rivals { - if o.alive && o.side != side { - targets.push((o.side, 1)); + let mut targets: Vec<(u8, u32)> = Vec::new(); + if self.coop { + if self.gate_on || self.ents.iter().any(|e| e.alive && e.hero) { + targets.push((1, 3)); + } + } else { + targets.push((0, 3)); + for o in &self.rivals { + if o.alive && o.side != side && !self.is_coop_ally_side(o.side) { + targets.push((o.side, 1)); + } + } + if self.has_gate() && self.gate_hp > 0.0 { + targets.push((1, 1)); } } - if self.has_gate() && self.gate_hp > 0.0 { - targets.push((1, 1)); - } let total: u32 = targets.iter().map(|t| t.1).sum(); + if total == 0 { + continue; + } let mut r = self.rng.next() % total; let mut target = 0; for (s, w) in targets {