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:
1 parent
811c955bad
commit
fc9c49d4ca
16 files changed
+1896
-49
No files matched your search
+193
-2
@@ -30,6 +30,7 @@ pub struct SeatUi {
|
||||
pub ready: bool,
|
||||
pub connected: bool,
|
||||
pub ai: bool,
|
||||
pub team: u8, // 0..3 = A..D (versus)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -39,6 +40,9 @@ pub struct RoomUi {
|
||||
pub sides: Vec<u8>,
|
||||
pub started: bool,
|
||||
pub paused: bool,
|
||||
pub mission: Option<usize>,
|
||||
pub campaign: Option<String>, // the editor campaign of a co-op room
|
||||
pub title: Option<String>, // and the title of its current mission
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -53,6 +57,7 @@ pub struct MatchUi {
|
||||
pub sides: Vec<u8>,
|
||||
pub seats: Vec<SeatUi>,
|
||||
pub ai: Vec<u8>,
|
||||
pub coop: bool, // the open room plays a co-op mission (no teams to choose)
|
||||
pub rooms: Vec<RoomUi>,
|
||||
pub started: bool,
|
||||
pub paused: bool,
|
||||
@@ -82,6 +87,9 @@ pub struct StartInfo {
|
||||
// an editor campaign sent along by the host: `mission` is then one of its missions
|
||||
pub doc: Option<crate::campaign_doc::CampaignDoc>,
|
||||
pub editor: bool, // the start carried an editor campaign (even one that could not be read)
|
||||
pub teams: Vec<(u8, u8)>, // (side, team) of a versus match
|
||||
pub next: bool, // the next mission of a running editor campaign co-op match
|
||||
pub carry: Vec<(u8, crate::campaign::Carry)>, // what every side brings into that mission
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -91,6 +99,7 @@ struct SeatInfo {
|
||||
ready: bool,
|
||||
connected: bool,
|
||||
ai: bool,
|
||||
team: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -103,6 +112,7 @@ struct RoomInfo {
|
||||
started: bool,
|
||||
paused: bool,
|
||||
missing: bool,
|
||||
mission: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -119,6 +129,7 @@ pub struct MatchClient {
|
||||
chat: Vec<(Option<u8>, String, String)>,
|
||||
chat_seen: usize,
|
||||
start: Option<StartInfo>,
|
||||
doc: Option<crate::campaign_doc::CampaignDoc>, // the editor campaign of the running match
|
||||
turns: Vec<(u32, Vec<Command>, Vec<u8>)>,
|
||||
checks: Vec<(Option<u8>, u32, u64)>,
|
||||
pub ping_ms: f32,
|
||||
@@ -142,6 +153,7 @@ impl MatchClient {
|
||||
chat: Vec::new(),
|
||||
chat_seen: 0,
|
||||
start: None,
|
||||
doc: None,
|
||||
turns: Vec::new(),
|
||||
checks: Vec::new(),
|
||||
ping_ms: 0.0,
|
||||
@@ -179,6 +191,7 @@ impl MatchClient {
|
||||
self.code = None;
|
||||
self.local_side = None;
|
||||
self.start = None;
|
||||
self.doc = None;
|
||||
self.turns.clear();
|
||||
self.status = Some("offline".into());
|
||||
self.ping_ms = 0.0;
|
||||
@@ -272,6 +285,28 @@ impl MatchClient {
|
||||
self.outbox.push_back(json!({"t":"set_ai","side":side,"ai":ai}).to_string());
|
||||
}
|
||||
|
||||
// the team (0..3 = A..D) of the own seat, or - for the host - of a free or AI seat
|
||||
pub fn set_team(&mut self, side: Option<u8>, team: u8) {
|
||||
if !self.connected {
|
||||
return;
|
||||
}
|
||||
let mut msg = json!({"t":"set_team","team":team});
|
||||
if let Some(side) = side {
|
||||
msg["side"] = json!(side);
|
||||
}
|
||||
self.outbox.push_back(msg.to_string());
|
||||
}
|
||||
|
||||
// the host starts the next mission of the editor campaign for everybody: the run's seed and
|
||||
// what every side carries along travel with it
|
||||
pub fn next_mission(&mut self, mission: usize, seed: u32, carry: &[(u8, crate::campaign::Carry)]) {
|
||||
if !self.connected {
|
||||
return;
|
||||
}
|
||||
let carry = serde_json::to_value(carry).unwrap_or(Value::Array(Vec::new()));
|
||||
self.outbox.push_back(json!({"t":"next","mission":mission,"seed":seed,"carry":carry}).to_string());
|
||||
}
|
||||
|
||||
pub fn ready(&mut self, ready: bool) {
|
||||
if !self.connected {
|
||||
return;
|
||||
@@ -358,6 +393,7 @@ impl MatchClient {
|
||||
"rooms" => self.parse_rooms(&v),
|
||||
"room" => self.parse_room(&v),
|
||||
"start" => self.parse_start(&v),
|
||||
"next" => self.parse_next(&v),
|
||||
"turn" => self.parse_turn(&v),
|
||||
"sum" => self.parse_sum(&v),
|
||||
"chat" => {
|
||||
@@ -378,6 +414,7 @@ impl MatchClient {
|
||||
self.code = None;
|
||||
self.local_side = None;
|
||||
self.start = None;
|
||||
self.doc = None;
|
||||
self.turns.clear();
|
||||
self.chat.clear();
|
||||
self.chat_seen = 0;
|
||||
@@ -420,6 +457,9 @@ impl MatchClient {
|
||||
sides: array_bytes(r.get("sides")),
|
||||
started: r.get("started").and_then(|x| x.as_bool()).unwrap_or(false),
|
||||
paused: r.get("paused").and_then(|x| x.as_bool()).unwrap_or(false),
|
||||
mission: r.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize),
|
||||
campaign: r.get("campaign").and_then(|x| x.as_str()).map(|x| x.to_string()),
|
||||
title: r.get("title").and_then(|x| x.as_str()).map(|x| x.to_string()),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
@@ -451,6 +491,9 @@ impl MatchClient {
|
||||
if let Some(x) = v.get("missing").and_then(|x| x.as_bool()) {
|
||||
room.missing = x;
|
||||
}
|
||||
if v.get("mission").is_some() {
|
||||
room.mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize);
|
||||
}
|
||||
if let Some(why) = v.get("why").and_then(|x| x.as_str()) {
|
||||
self.status = Some(why.to_string());
|
||||
}
|
||||
@@ -471,6 +514,8 @@ impl MatchClient {
|
||||
let mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize);
|
||||
let editor = v.get("doc").map_or(false, |d| d.is_object());
|
||||
let doc = v.get("doc").filter(|d| d.is_object()).and_then(|d| <crate::campaign_doc::CampaignDoc as serde::Deserialize>::deserialize(d).ok());
|
||||
self.doc = doc.clone();
|
||||
let teams = teams_from(&sides, v.get("teams"));
|
||||
let mut ai = array_bytes(v.get("ai"));
|
||||
if ai.is_empty() {
|
||||
if let Some(r) = self.room.as_ref() {
|
||||
@@ -489,11 +534,41 @@ impl MatchClient {
|
||||
}
|
||||
r.ai = ai.clone();
|
||||
}
|
||||
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai, doc, editor });
|
||||
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai, doc, editor, teams, next: false, carry: Vec::new() });
|
||||
self.error = None;
|
||||
self.status = None;
|
||||
}
|
||||
|
||||
// the next mission of the running editor campaign: built from the campaign of the start
|
||||
fn parse_next(&mut self, v: &Value) {
|
||||
let Some(doc) = self.doc.clone() else { return };
|
||||
let Some(mission) = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize) else { return };
|
||||
let Some(m) = doc.missions.get(mission) else { return };
|
||||
let (map_w, map_h) = m.size;
|
||||
let seed = v.get("seed").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
|
||||
let difficulty = v.get("difficulty").and_then(|x| x.as_u64()).unwrap_or(1) as u8;
|
||||
let sides = array_bytes(v.get("sides"));
|
||||
let ai = array_bytes(v.get("ai"));
|
||||
let teams = teams_from(&sides, v.get("teams"));
|
||||
let carry: Vec<(u8, crate::campaign::Carry)> = v.get("carry").cloned().and_then(|c| serde_json::from_value(c).ok()).unwrap_or_default();
|
||||
// turns that came before belong to the mission that ended
|
||||
self.turns.clear();
|
||||
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)).or(self.local_side);
|
||||
self.local_side = local_side;
|
||||
if let Some(r) = self.room.as_mut() {
|
||||
r.turn = 0;
|
||||
r.paused = false;
|
||||
r.missing = false;
|
||||
r.mission = Some(mission);
|
||||
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: Some(mission), ai, doc: Some(doc), editor: true, teams, next: true, carry });
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
fn parse_turn(&mut self, v: &Value) {
|
||||
let turn = v.get("turn").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
|
||||
if let Some(r) = self.room.as_mut() {
|
||||
@@ -534,7 +609,8 @@ impl MatchClient {
|
||||
is_host,
|
||||
sides: room.sides.clone(),
|
||||
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(),
|
||||
coop: room.mission.is_some(),
|
||||
seats: room.seats.iter().map(|s| SeatUi { side: s.side, name: s.name.clone(), ready: s.ready, connected: s.connected, ai: s.ai, team: s.team }).collect(),
|
||||
rooms: self.rooms.clone(),
|
||||
started: room.started,
|
||||
paused: room.paused,
|
||||
@@ -547,12 +623,23 @@ impl MatchClient {
|
||||
}
|
||||
}
|
||||
|
||||
// every seat starts as a team of its own (the relay does the same)
|
||||
fn default_team(side: u8) -> u8 {
|
||||
crate::map::Owner::MASTER_SIDES.iter().position(|s| *s == side).unwrap_or(0) as u8
|
||||
}
|
||||
|
||||
fn array_bytes(v: Option<&Value>) -> Vec<u8> {
|
||||
v.and_then(|x| x.as_array())
|
||||
.map(|a| a.iter().filter_map(|x| x.as_u64().map(|n| n as u8)).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// (side, team) pairs from the teams list that goes along with `sides`
|
||||
fn teams_from(sides: &[u8], v: Option<&Value>) -> Vec<(u8, u8)> {
|
||||
let teams = array_bytes(v);
|
||||
sides.iter().copied().zip(teams).collect()
|
||||
}
|
||||
|
||||
fn seats_from(v: Option<&Value>) -> Vec<SeatInfo> {
|
||||
v.and_then(|x| x.as_array())
|
||||
.map(|arr| {
|
||||
@@ -563,6 +650,7 @@ fn seats_from(v: Option<&Value>) -> Vec<SeatInfo> {
|
||||
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),
|
||||
team: s.get("team").and_then(|x| x.as_u64()).unwrap_or_else(|| default_team(s.get("side").and_then(|x| x.as_u64()).unwrap_or(0) as u8) as u64) as u8,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
@@ -704,9 +792,112 @@ mod tests {
|
||||
g.coop = true;
|
||||
assert!(g.start_match(&info.sides, info.local_side.unwrap()), "the masters fit the map");
|
||||
g.sync_ai_sides(&info.ai, info.difficulty);
|
||||
crate::campaign::apply_coop_carry(&mut g, &info.carry);
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_clients_play_two_editor_missions_in_a_row_with_carry() {
|
||||
use crate::game::EK;
|
||||
let doc = coop_doc();
|
||||
let mut clients = [MatchClient::new(), MatchClient::new()];
|
||||
let room = r#"{"t":"room","code":"ABC123","sides":[0,2],"seats":[{"side":0,"name":"red","ready":true,"connected":true},{"side":2,"name":"green","ready":true,"connected":true}],"ai":[],"turn":0,"started":false,"mission":0}"#;
|
||||
let start = json!({"t":"start","seed":99,"map_w":80,"map_h":80,"difficulty":1,"sides":[0,2],"teams":[0,1],"mission":0,"ai":[],"doc":serde_json::to_value(&doc).unwrap()}).to_string();
|
||||
let mut games = Vec::new();
|
||||
for (c, name) in clients.iter_mut().zip(["red", "green"]) {
|
||||
c.on_connect(name);
|
||||
c.outbox.clear();
|
||||
c.feed(room);
|
||||
c.feed(&start);
|
||||
let info = c.take_start().expect("start info");
|
||||
assert!(!info.next && info.carry.is_empty());
|
||||
games.push(coop_game(&info));
|
||||
}
|
||||
for t in 0..60u32 {
|
||||
for g in games.iter_mut() {
|
||||
g.receive_turn(&[]);
|
||||
}
|
||||
if t % 20 == 19 {
|
||||
assert_eq!(games[0].checksum(), games[1].checksum(), "mission 1, turn {t}");
|
||||
}
|
||||
}
|
||||
// green raised a strong creature, red has a weak one; then the goal is reached on both
|
||||
for g in games.iter_mut() {
|
||||
let (cx, cy) = g.core_of_side(2);
|
||||
let local = g.local_side;
|
||||
g.switch_to(2);
|
||||
let ogre = g.spawn(EK::Ogre, false, cx as f32 + 4.5, cy as f32 + 1.5, 6);
|
||||
g.switch_to(0);
|
||||
let (rx, ry) = g.core_of_side(0);
|
||||
g.spawn(EK::Kobold, false, rx as f32 + 4.5, ry as f32 + 1.5, 1);
|
||||
g.switch_to(local);
|
||||
assert!(g.ents.iter().any(|e| e.id == ogre && e.side == 2));
|
||||
g.state = 1;
|
||||
}
|
||||
let (next, _) = doc.next_mission_choice(0, |goal| games[0].match_goal_done(goal));
|
||||
assert_eq!(next, Some(1), "the run goes on with the next mission");
|
||||
let carry = crate::campaign::coop_carry_from(&games[0]);
|
||||
assert_eq!(serde_json::to_string(&carry).unwrap(), serde_json::to_string(&crate::campaign::coop_carry_from(&games[1])).unwrap(), "every client sees the same carry");
|
||||
let green_carry = &carry.iter().find(|c| c.0 == 2).unwrap().1;
|
||||
assert!(green_carry.creatures.iter().any(|c| c.0 == EK::Ogre && c.1 == 6), "green takes his ogre along");
|
||||
assert_eq!(green_carry.gold, (games[0].gold_of(2) * crate::campaign::CARRY_GOLD).floor());
|
||||
|
||||
// the host asks the relay for the next mission; the relay hands it to everybody
|
||||
clients[0].next_mission(1, 99, &carry);
|
||||
let req = pop(&mut clients[0]);
|
||||
assert_eq!(req["t"].as_str(), Some("next"));
|
||||
assert_eq!(req["mission"].as_u64(), Some(1));
|
||||
let next = json!({"t":"next","seed":req["seed"],"difficulty":1,"sides":[0,2],"teams":[0,1],"mission":1,"ai":[],"carry":req["carry"]}).to_string();
|
||||
let mut games2 = Vec::new();
|
||||
for c in clients.iter_mut() {
|
||||
c.feed(r#"{"t":"turn","turn":61,"cmds":[]}"#); // a late turn of the old mission
|
||||
c.feed(&next);
|
||||
let info = c.take_start().expect("the next mission starts");
|
||||
assert!(info.next && info.editor && info.doc.is_some());
|
||||
assert_eq!(info.mission, Some(1));
|
||||
assert_eq!(info.carry.len(), 2);
|
||||
assert!(c.take_turns().is_empty(), "turns of the old mission are dropped");
|
||||
games2.push(coop_game(&info));
|
||||
}
|
||||
let (a, b) = (&games2[0], &games2[1]);
|
||||
assert_eq!(a.checksum(), b.checksum(), "mission 2: the same start with the carry");
|
||||
assert!(a.ents.iter().any(|e| e.alive && e.side == 2 && e.kind == EK::Ogre && e.level == 6), "the ogre came along with green");
|
||||
let mut games2 = games2;
|
||||
let (cx, cy) = games2[0].core_of_side(0);
|
||||
for t in 0..80u32 {
|
||||
let cmds = if t % 10 == 0 { vec![Command { side: 0, seq: t, act: Act::Dig(Rect::new(cx + 5, cy + (t / 10) as i32 - 3, cx + 6, cy + (t / 10) as i32 - 2)) }] } else { Vec::new() };
|
||||
for g in games2.iter_mut() {
|
||||
g.receive_turn(&cmds);
|
||||
}
|
||||
if t % 10 == 9 {
|
||||
assert_eq!(games2[0].checksum(), games2[1].checksum(), "mission 2: identical up to turn {t}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bridge_sends_teams_and_reads_them_back() {
|
||||
let mut client = MatchClient::new();
|
||||
client.on_connect("red");
|
||||
pop(&mut client); // rooms
|
||||
client.set_team(None, 1);
|
||||
let req = pop(&mut client);
|
||||
assert_eq!((req["t"].as_str(), req["team"].as_u64(), req.get("side")), (Some("set_team"), Some(1), None));
|
||||
client.set_team(Some(3), 2);
|
||||
assert_eq!(pop(&mut client)["side"].as_u64(), Some(3));
|
||||
client.feed(r#"{"t":"room","code":"ABC123","sides":[0,2],"seats":[{"side":0,"name":"red","connected":true,"team":1},{"side":2,"name":"","connected":false,"team":1}],"mission":null}"#);
|
||||
let ui = client.ui();
|
||||
assert!(!ui.coop);
|
||||
assert_eq!(ui.seats.iter().map(|s| s.team).collect::<Vec<_>>(), vec![1, 1]);
|
||||
client.feed(r#"{"t":"rooms","rooms":[{"code":"ABC123","players":1,"sides":[0,2],"mission":0,"editor":true,"campaign":"Deep Run","title":"First"}]}"#);
|
||||
let ui = client.ui();
|
||||
assert_eq!(ui.rooms[0].campaign.as_deref(), Some("Deep Run"));
|
||||
assert_eq!(ui.rooms[0].title.as_deref(), Some("First"));
|
||||
client.feed(r#"{"t":"start","seed":7,"sides":[0,2],"teams":[1,1]}"#);
|
||||
let start = client.take_start().unwrap();
|
||||
assert_eq!(start.teams, vec![(0, 1), (2, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_clients_play_the_same_editor_campaign_mission_in_coop() {
|
||||
let doc = coop_doc();
|
||||
|
||||
Reference in new issue
Block a user