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

+1 -1
View File
@@ -1 +1 @@
v202611-00013 v202611-00014
+2 -1
View File
@@ -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, - 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**. 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. - 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. - 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 - 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 other, their creatures help each other, gold can be sent, allied ground can be walked; a fallen heart only
+2
View File
@@ -643,6 +643,8 @@ net.side0 = Rot
net.side2 = Blau net.side2 = Blau
net.side3 = Grün net.side3 = Grün
net.side4 = Gelb net.side4 = Gelb
net.ai = KI
net.ai_off = Aus
# rival masters (free game) # rival masters (free game)
newgame.difficulty = Gegner newgame.difficulty = Gegner
+2
View File
@@ -644,6 +644,8 @@ net.side0 = Red
net.side2 = Blue net.side2 = Blue
net.side3 = Green net.side3 = Green
net.side4 = Yellow net.side4 = Yellow
net.ai = AI
net.ai_off = Off
# rival masters (free game) # rival masters (free game)
newgame.difficulty = Opponents newgame.difficulty = Opponents
+156 -14
View File
@@ -16,7 +16,8 @@
// {"t":"chat","text":"hello"} // {"t":"chat","text":"hello"}
// {"t":"resume"} -> side 0 resumes a paused match // {"t":"resume"} -> side 0 resumes a paused match
// {"t":"abort"} -> side 0 closes the 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. // Server messages include: hello, rooms, room, start, turn, sum, chat, closed, error.
@@ -81,6 +82,7 @@ struct Seat {
conn: Option<u64>, conn: Option<u64>,
name: String, name: String,
ready: bool, ready: bool,
ai: bool,
} }
impl Room { impl Room {
@@ -88,13 +90,37 @@ impl Room {
self.seats.values().any(|s| s.conn.is_some()) self.seats.values().any(|s| s.conn.is_some())
} }
fn ai_sides(&self) -> Vec<u8> { fn active_sides(&self) -> Vec<u8> {
self.sides self.sides
.iter() .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() .copied()
.collect() .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)] #[derive(Debug, Deserialize)]
@@ -138,6 +164,10 @@ fn sides_of(v: Option<&Value>) -> Vec<u8> {
out 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> { fn mission_of(v: Option<&Value>) -> Option<usize> {
match v { match v {
None | Some(Value::Null) => None, None | Some(Value::Null) => None,
@@ -233,6 +263,7 @@ impl Netplay {
"join" => self.join(conn, &msg), "join" => self.join(conn, &msg),
"take_side" => self.take_side(conn, &msg), "take_side" => self.take_side(conn, &msg),
"ready" => self.ready(conn, &msg), "ready" => self.ready(conn, &msg),
"set_ai" => self.set_ai(conn, &msg),
"start" => self.start(conn), "start" => self.start(conn),
"cmds" => self.cmds(conn, &msg), "cmds" => self.cmds(conn, &msg),
"sum" => self.sum(conn, &msg), "sum" => self.sum(conn, &msg),
@@ -261,7 +292,7 @@ impl Netplay {
} }
} }
r.turn += 1; r.turn += 1;
Some((r.turn, cmds, r.ai_sides())) Some((r.turn, cmds, r.started_ai_sides()))
} else { } else {
None None
} }
@@ -294,7 +325,7 @@ impl Netplay {
return None; return None;
} }
let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into()); 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 }; 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); self.rooms.insert(code.clone(), room);
if let Some(c) = self.clients.get_mut(&conn) { if let Some(c) = self.clients.get_mut(&conn) {
@@ -326,7 +357,7 @@ impl Netplay {
self.error(conn, "cannot join a match in progress"); self.error(conn, "cannot join a match in progress");
return; 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, Some(s) => s,
None => { None => {
self.error(conn, "match is full"); self.error(conn, "match is full");
@@ -365,7 +396,7 @@ impl Netplay {
let mut taken = false; let mut taken = false;
if let Some(r) = self.rooms.get_mut(&code) { if let Some(r) = self.rooms.get_mut(&code) {
started = r.started; 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() { for s in r.seats.values_mut() {
if s.conn == Some(conn) { if s.conn == Some(conn) {
s.conn = None; s.conn = None;
@@ -377,6 +408,7 @@ impl Netplay {
t.conn = Some(conn); t.conn = Some(conn);
t.name = name; t.name = name;
t.ready = false; t.ready = false;
t.ai = false;
taken = true; 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) { fn start(&mut self, conn: u64) {
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) { let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
Some(c) => c, Some(c) => c,
@@ -429,17 +509,25 @@ impl Netplay {
return self.error(conn, "not everyone is ready"); 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.started = true;
r.paused = false; r.paused = false;
r.missing = false; r.missing = false;
r.seed = Some(random_seed()); r.seed = Some(random_seed());
r.turn = 0; r.turn = 0;
r.sides = active;
} }
let msg = match self.rooms.get(&code) { 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, None => return,
}; };
self.room_msg(conn, &code); self.room_msg_all(&code);
self.broadcast_room(&code, msg); self.broadcast_room(&code, msg);
} }
@@ -550,6 +638,9 @@ impl Netplay {
Some(r) => { Some(r) => {
for s in r.seats.values_mut() { for s in r.seats.values_mut() {
if s.conn == Some(conn) { if s.conn == Some(conn) {
if r.started {
s.ai = true;
}
s.conn = None; s.conn = None;
s.name.clear(); s.name.clear();
s.ready = false; s.ready = false;
@@ -643,16 +734,16 @@ impl Netplay {
fn room_json(&self, code: &str) -> Option<Value> { fn room_json(&self, code: &str) -> Option<Value> {
let r = self.rooms.get(code)?; let r = self.rooms.get(code)?;
let seats: Vec<Value> = r let sides: Vec<u8> = if r.started { r.active_sides() } else { r.sides.clone() };
.sides let seats: Vec<Value> = sides
.iter() .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(); .collect();
let ai: Vec<u8> = if r.started { r.ai_sides() } else { Vec::new() }; let ai: Vec<u8> = r.room_ai_sides();
Some(json!({ Some(json!({
"t":"room", "t":"room",
"code":r.code, "code":r.code,
"sides":r.sides, "sides":sides,
"seats":seats, "seats":seats,
"ai":ai, "ai":ai,
"turn":r.turn, "turn":r.turn,
@@ -799,6 +890,57 @@ mod tests {
assert_eq!(turn["ai"].as_array().unwrap()[0].as_u64(), Some(2)); 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] #[test]
fn the_relay_closes_a_started_match_after_the_last_player_leaves() { fn the_relay_closes_a_started_match_after_the_last_player_leaves() {
let mut net = Netplay::new(); let mut net = Netplay::new();
+35 -6
View File
@@ -1308,12 +1308,15 @@ impl Game {
side == self.me_side || self.masters.iter().any(|m| m.side == side) side == self.me_side || self.masters.iter().any(|m| m.side == side)
} }
fn is_hostile_master(&self, side: u8) -> bool { pub(crate) fn is_coop_ally_side(&self, side: u8) -> bool {
self.coop && Owner::MASTER_SIDES.contains(&self.me_side) && Owner::MASTER_SIDES.contains(&side) 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 { 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 { 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 #[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 { pub fn start_masters(&mut self, sides: &[u8], local: u8) -> bool {
let sides: Vec<u8> = sides.iter().copied().filter(|s| Owner::MASTER_SIDES.contains(s)).collect(); let sides: Vec<u8> = 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; return false;
} }
let (w, h) = (mw() as i32, mh() as i32); let (w, h) = (mw() as i32, mh() as i32);
@@ -2362,8 +2368,8 @@ impl Game {
let mut bd = radius * radius; let mut bd = radius * radius;
for o in &self.ents { for o in &self.ents {
// every other side is an enemy; co-op master sides fight the heroes, not each other // 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()); let coop_ally = self.is_coop_ally_side(o.side());
if !o.alive || o.held || o.side() == e.side() || coop_allies || o.captive != 0 || o.chained || o.invis > 0.0 { if !o.alive || o.held || o.side() == e.side() || coop_ally || o.captive != 0 || o.chained || o.invis > 0.0 {
continue; continue;
} }
let d = (o.x - e.x).powi(2) + (o.y - e.y).powi(2); let d = (o.x - e.x).powi(2) + (o.y - e.y).powi(2);
@@ -6295,5 +6301,28 @@ mod tests {
assert!(seen); assert!(seen);
assert!(g.ents.iter().filter(|e| e.kind == EK::Chainbreaker).count() <= 1); 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");
}
} }
+49 -2
View File
@@ -29,6 +29,7 @@ pub struct SeatUi {
pub name: String, pub name: String,
pub ready: bool, pub ready: bool,
pub connected: bool, pub connected: bool,
pub ai: bool,
} }
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
@@ -51,6 +52,7 @@ pub struct MatchUi {
pub is_host: bool, pub is_host: bool,
pub sides: Vec<u8>, pub sides: Vec<u8>,
pub seats: Vec<SeatUi>, pub seats: Vec<SeatUi>,
pub ai: Vec<u8>,
pub rooms: Vec<RoomUi>, pub rooms: Vec<RoomUi>,
pub started: bool, pub started: bool,
pub paused: bool, pub paused: bool,
@@ -76,6 +78,7 @@ pub struct StartInfo {
pub sides: Vec<u8>, pub sides: Vec<u8>,
pub local_side: Option<u8>, pub local_side: Option<u8>,
pub mission: Option<usize>, pub mission: Option<usize>,
pub ai: Vec<u8>,
} }
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
@@ -84,6 +87,7 @@ struct SeatInfo {
name: String, name: String,
ready: bool, ready: bool,
connected: bool, connected: bool,
ai: bool,
} }
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
@@ -211,6 +215,13 @@ impl MatchClient {
self.outbox.push_back(json!({"t":"take_side","side":side}).to_string()); 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) { pub fn ready(&mut self, ready: bool) {
if !self.connected { if !self.connected {
return; return;
@@ -394,6 +405,12 @@ impl MatchClient {
let difficulty = v.get("difficulty").and_then(|x| x.as_u64()).unwrap_or(1) as u8; let difficulty = v.get("difficulty").and_then(|x| x.as_u64()).unwrap_or(1) as u8;
let sides = array_bytes(v.get("sides")); let sides = array_bytes(v.get("sides"));
let mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize); 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)); 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; self.local_side = local_side;
if let Some(r) = self.room.as_mut() { if let Some(r) = self.room.as_mut() {
@@ -404,8 +421,9 @@ impl MatchClient {
if !sides.is_empty() { if !sides.is_empty() {
r.sides = sides.clone(); 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.error = None;
self.status = None; self.status = None;
} }
@@ -449,7 +467,8 @@ impl MatchClient {
ready, ready,
is_host, is_host,
sides: room.sides.clone(), 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(), rooms: self.rooms.clone(),
started: room.started, started: room.started,
paused: room.paused, paused: room.paused,
@@ -477,6 +496,7 @@ fn seats_from(v: Option<&Value>) -> Vec<SeatInfo> {
name: s.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string(), 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), 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), 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() .collect()
}) })
@@ -542,4 +562,31 @@ mod tests {
let chat = client.take_new_chat(); let chat = client.take_new_chat();
assert_eq!(chat[0], (Some(0), "red".to_string(), "hello".to_string())); 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]);
}
} }
+6 -5
View File
@@ -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::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::MatchReady(ready) => app.client.ready(ready),
MenuAction::MatchStart => app.client.start(), MenuAction::MatchStart => app.client.start(),
MenuAction::MatchResume => app.client.resume(), MenuAction::MatchResume => app.client.resume(),
@@ -518,6 +519,7 @@ impl App {
return false; return false;
} }
game.ai_difficulty = info.difficulty; game.ai_difficulty = info.difficulty;
game.sync_ai_sides(&info.ai, info.difficulty);
game.hints = self.settings.hints; game.hints = self.settings.hints;
self.game = game; self.game = game;
let (cx, cy) = self.game.map.core; let (cx, cy) = self.game.map.core;
@@ -537,6 +539,7 @@ impl App {
} }
self.game.coop = false; self.game.coop = false;
self.game.ai_difficulty = info.difficulty; self.game.ai_difficulty = info.difficulty;
self.game.sync_ai_sides(&info.ai, info.difficulty);
self.game.hints = self.settings.hints; self.game.hints = self.settings.hints;
self.game.init_fog(); self.game.init_fog();
let (cx, cy) = self.game.map.core; let (cx, cy) = self.game.map.core;
@@ -552,11 +555,8 @@ impl App {
return; return;
} }
for (_, cmds, ai) in self.client.take_turns() { for (_, cmds, ai) in self.client.take_turns() {
if self.coop { let difficulty = self.game.ai_difficulty;
self.game.ai_sides.clear(); self.game.sync_ai_sides(&ai, difficulty);
} else {
self.game.sync_ai_sides(&ai, self.settings.difficulty);
}
self.game.receive_turn(&cmds); self.game.receive_turn(&cmds);
} }
for (_, turn, sum) in self.client.take_checks() { for (_, turn, sum) in self.client.take_checks() {
@@ -1098,6 +1098,7 @@ async fn main() {
| MenuAction::MatchCreate | MenuAction::MatchCreate
| MenuAction::MatchCoopCreate(..) | MenuAction::MatchCoopCreate(..)
| MenuAction::MatchTakeSide(..) | MenuAction::MatchTakeSide(..)
| MenuAction::MatchSetAi(..)
| MenuAction::MatchReady(..) | MenuAction::MatchReady(..)
| MenuAction::MatchStart | MenuAction::MatchStart
| MenuAction::MatchResume | MenuAction::MatchResume
+38 -7
View File
@@ -235,6 +235,7 @@ enum Btn {
MatchCoopNext, MatchCoopNext,
MatchCoopCreate, MatchCoopCreate,
MatchTake(usize), MatchTake(usize),
MatchAi(usize),
MatchReady, MatchReady,
MatchStart, MatchStart,
MatchResume, MatchResume,
@@ -312,6 +313,7 @@ pub enum MenuAction {
MatchCreate, MatchCreate,
MatchCoopCreate(usize), MatchCoopCreate(usize),
MatchTakeSide(u8), MatchTakeSide(u8),
MatchSetAi(u8, bool),
MatchReady(bool), MatchReady(bool),
MatchStart, MatchStart,
MatchResume, MatchResume,
@@ -654,6 +656,10 @@ impl Menu {
Some(s) => MenuAction::MatchTakeSide(s.side), Some(s) => MenuAction::MatchTakeSide(s.side),
None => MenuAction::Consumed, 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) => { Some(Btn::Campaign) => {
self.page = Some(Page::Campaign); self.page = Some(Page::Campaign);
self.scroll = 0; self.scroll = 0;
@@ -1462,7 +1468,12 @@ impl Menu {
yy += CH() * 1.4; yy += CH() * 1.4;
let seat_cols = (cols - 6) as usize; let seat_cols = (cols - 6) as usize;
for (k, s) in ui.seats.iter().take(4).enumerate() { 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 { let side_label = match s.side {
0 => tr("net.side0"), 0 => tr("net.side0"),
2 => tr("net.side2"), 2 => tr("net.side2"),
@@ -1470,13 +1481,33 @@ impl Menu {
4 => tr("net.side4"), 4 => tr("net.side4"),
_ => s.side.to_string(), _ => 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() }); let status = if s.connected { "●" } else if s.ai { "AI" } else { "○" };
gfx.text(&line, ix, yy, SMALL(), if s.connected { TXT } else { DIM }); 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 { 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); if ui.is_host && s.side != 0 {
let label = if Some(s.side) == ui.local_side { tr("net.you") } else { tr("net.join") }; if s.ai {
button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME); let r = Rect::new(ix + 34.0 * CW(), yy, 12.0 * CW(), CH() * 2.0);
self.buttons.push((r, Btn::MatchTake(k))); 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; yy += CH() * 2.0;
} }
+17 -7
View File
@@ -196,16 +196,26 @@ impl Game {
// attack: about two thirds of the creatures march on an enemy // attack: about two thirds of the creatures march on an enemy
if self.rivals[k].attack_t <= 0.0 { if self.rivals[k].attack_t <= 0.0 {
self.rivals[k].attack_t = p.attack * (0.8 + self.rng.f() * 0.4); self.rivals[k].attack_t = p.attack * (0.8 + self.rng.f() * 0.4);
let mut targets: Vec<(u8, u32)> = vec![(0, 3)]; let mut targets: Vec<(u8, u32)> = Vec::new();
for o in &self.rivals { if self.coop {
if o.alive && o.side != side { if self.gate_on || self.ents.iter().any(|e| e.alive && e.hero) {
targets.push((o.side, 1)); 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(); let total: u32 = targets.iter().map(|t| t.1).sum();
if total == 0 {
continue;
}
let mut r = self.rng.next() % total; let mut r = self.rng.next() % total;
let mut target = 0; let mut target = 0;
for (s, w) in targets { for (s, w) in targets {