diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 3af7017..89b57dd 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -47,8 +47,8 @@ jobs: # OS: ubuntu-latest - TARGET: aarch64-unknown-linux-gnu # tested on aws t4g.nano OS: ubuntu-latest - - TARGET: aarch64-unknown-linux-musl # tested on aws t4g.nano in alpine container - OS: ubuntu-latest + # - TARGET: aarch64-unknown-linux-musl # tested on aws t4g.nano in alpine container + # OS: ubuntu-latest # - TARGET: armv7-unknown-linux-gnueabihf # raspberry pi 2-3-4, not tested # OS: ubuntu-latest # - TARGET: armv7-unknown-linux-musleabihf # raspberry pi 2-3-4, not tested diff --git a/battlefield_rcon/src/bf4.rs b/battlefield_rcon/src/bf4.rs index f6087b1..5f73c69 100644 --- a/battlefield_rcon/src/bf4.rs +++ b/battlefield_rcon/src/bf4.rs @@ -9,6 +9,7 @@ use error::Bf4Error; use futures_core::Stream; use player_info_block::{parse_pib, PlayerInfo}; use server_info::{parse_serverinfo, ServerInfo}; +use team_scores::{parse_team_scores}; use tokio::{ net::ToSocketAddrs, sync::{broadcast, mpsc, oneshot}, @@ -22,6 +23,7 @@ pub mod map_list; pub(crate) mod player_cache; pub mod player_info_block; pub mod server_info; +pub mod team_scores; mod util; pub mod ban_list; @@ -350,6 +352,28 @@ impl Bf4Client { winning_team: Team::rcon_decode(&packet.words[1])?, }) } + "server.onRoundOverTeamScores" => { + if packet.words.len() < 5 { + return Err(Bf4Error::Rcon(RconError::malformed_packet( + packet.words.clone(), + format!("{} packet must have at least {} words", &packet.words[0], 5), + ))); + } + + let team_scores = parse_team_scores(&packet.words)?; + + Ok(Event::RoundOverTeamScores { + number_of_entries: team_scores.number_of_entries, + scores: team_scores.scores, + target_score: team_scores.target_score, + }) + } + "server.onRoundOverPlayers" => { + let pib = parse_pib(&packet.words[1..])?; + Ok(Event::RoundOverPlayers { + players: pib, + }) + } "punkBuster.onMessage" => { assert_len(&packet, 2)?; Ok(Event::PunkBusterMessage(packet.words[1].to_string())) diff --git a/battlefield_rcon/src/bf4/defs.rs b/battlefield_rcon/src/bf4/defs.rs index 488ed34..86f6adb 100644 --- a/battlefield_rcon/src/bf4/defs.rs +++ b/battlefield_rcon/src/bf4/defs.rs @@ -235,6 +235,14 @@ pub enum Event { RoundOver { winning_team: Team, }, + RoundOverTeamScores { + number_of_entries: i32, + scores: Vec, + target_score: i32, + }, + RoundOverPlayers { + players: Vec + }, Join { player: Player, }, diff --git a/battlefield_rcon/src/bf4/team_scores.rs b/battlefield_rcon/src/bf4/team_scores.rs new file mode 100644 index 0000000..17c6087 --- /dev/null +++ b/battlefield_rcon/src/bf4/team_scores.rs @@ -0,0 +1,36 @@ +use crate::bf4::util::{parse_int}; +use crate::rcon::{RconError, RconResult}; +use ascii::AsciiString; +use serde::{Deserialize, Serialize}; + +/// +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TeamScores { + pub number_of_entries: i32, + pub scores: Vec, + pub target_score: i32, +} + +/// Expects the TeamScores, without any leading "OK". +pub fn parse_team_scores(words: &[AsciiString]) -> RconResult { + if words.is_empty() { + return Err(RconError::protocol_msg( + "Failed to parse TeamScores: Zero length?", + )); + } + + let teams_count = parse_int(&words[1])? as usize; + let mut offset = 2; + let mut teamscores: Vec = Vec::new(); + for _ in 0..teams_count { + teamscores.push(parse_int(&words[offset]).unwrap()); + + offset += 1; + } + + Ok(TeamScores { + number_of_entries: teams_count as i32, + scores: teamscores, + target_score: parse_int(&words[offset]).unwrap(), + }) +} diff --git a/battlefox/Dockerfile.cross-platform b/battlefox/Dockerfile.cross-platform index 48042fe..0789e57 100644 --- a/battlefox/Dockerfile.cross-platform +++ b/battlefox/Dockerfile.cross-platform @@ -11,9 +11,9 @@ RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then export ARCHITECTURE=x86_64-un RUN tar -xf battlefox.tar.gz -FROM debian:bullseye-slim +FROM frolvlad/alpine-glibc:glibc-2.34 -RUN apt-get update && apt-get install -y ca-certificates +# RUN apt-get update && apt-get install -y ca-certificates WORKDIR /app COPY --from=downloader /home/curl_user/battlefox . diff --git a/battlefox/configs/mapvote.yaml b/battlefox/configs/mapvote.yaml index a8d5137..2bd7a11 100644 --- a/battlefox/configs/mapvote.yaml +++ b/battlefox/configs/mapvote.yaml @@ -2,6 +2,7 @@ enabled: true n_options: 4 max_options: 6 max_noms_per_vip: 1 +vote_start_interval: 30 spammer_interval: 180 endscreen_votetime: 15 endscreen_post_votetime: 5 diff --git a/battlefox/src/mapmanager.rs b/battlefox/src/mapmanager.rs index 3714b89..92c0c95 100644 --- a/battlefox/src/mapmanager.rs +++ b/battlefox/src/mapmanager.rs @@ -35,6 +35,8 @@ pub struct PopState { pub pool: MapPool, /// At `min_players` or more players, activate this pool. Unless a pool with even higher `min_players` exists. pub min_players: usize, + /// The amount of map history to be excluded when generating a new vote + pub excluded_maps_count: Option, } impl PopState { @@ -128,6 +130,9 @@ impl Plugin for MapManager { // Err(MapListError::Rcon(r)) => return Err(r), Err(mle) => error!("While starting up MapManager: {:?}. MapManager is *not* starting now!", mle), } + + // Populate the current map in the history + let _ = self.current_map(&bf4).await; } async fn event(self: Arc, bf4: Arc, event: Event) -> RconResult<()> { @@ -393,6 +398,34 @@ impl MapManager { Some(hist[0]) } } + + pub fn recent_maps(&self) -> Vec { + let hist = { + let inner = self.inner.lock().unwrap(); + inner.map_history.clone() + }; + + let popstate = { + let lock = self.inner.lock().unwrap(); + lock.pop_state.clone() + }; + + hist.iter() + .take(popstate.excluded_maps_count.unwrap_or(1)) + .cloned() + .collect() + } + + pub fn is_recently_played(&self, map: &Map) -> bool { + let recent_maps = self.recent_maps(); + + for m in recent_maps.iter() { + if map.eq(&m) { + return true; + } + }; + false + } } impl std::fmt::Debug for MapManager { diff --git a/battlefox/src/mapmanager/pool.rs b/battlefox/src/mapmanager/pool.rs index 692133c..cf0cf94 100644 --- a/battlefox/src/mapmanager/pool.rs +++ b/battlefox/src/mapmanager/pool.rs @@ -204,6 +204,18 @@ impl MapPool { .collect(), } } + + /// Returns a new map pool which contains the same items, except with any with `map` removed. + pub fn without_many_vec(&self, maps: &Vec) -> Self { + Self { + pool: self + .pool + .iter() + .filter(|&mip| !maps.contains(&mip.map)) + .cloned() + .collect(), + } + } } #[cfg(test)] diff --git a/battlefox/src/mapvote.rs b/battlefox/src/mapvote.rs index c22ae1c..9713ea1 100644 --- a/battlefox/src/mapvote.rs +++ b/battlefox/src/mapvote.rs @@ -23,6 +23,7 @@ use futures::{future::join_all, StreamExt}; use itertools::Itertools; use matching::AltMatcher; use multimap::MultiMap; +use num_bigint::BigInt; use rand::{RngCore, thread_rng}; use std::cell::Cell; use std::fmt::Write; @@ -43,7 +44,7 @@ use tokio::{ time::{sleep, Interval}, }; -use num_rational::BigRational as Rat; +use num_rational::{BigRational as Rat, Ratio}; use num_traits::{One, ToPrimitive}; pub mod config; @@ -216,9 +217,9 @@ impl Inner { } } - fn set_up_new_vote(&mut self, n_options: usize, without: Option) { + fn set_up_new_vote(&mut self, n_options: usize, without: Option>) { let pool = if let Some(without) = without { - self.popstate.pool.without(without) + self.popstate.pool.without_many_vec(&without) } else { self.popstate.pool.clone() }; @@ -322,11 +323,13 @@ impl Plugin for Mapvote { async fn event(self: Arc, bf4: Arc, event: Event) -> RconResult<()> { match event { Event::Chat { vis, player, msg } => { - if msg.as_str().starts_with("/bfox endvote") && self.admins.is_admin(&player.name) { - self.handle_round_over(&bf4).await; - } else { - let _ = self.handle_chat_msg(bf4, vis, player, msg).await; - } + let _ = self.handle_chat_msg(bf4, vis, player, msg).await; + } + Event::LevelLoaded { level_name, game_mode, rounds_played, rounds_total } => { + tokio::spawn(async move { + tokio::time::sleep(self.config.vote_start_interval).await; + self.start_new_vote().await; + }); } Event::RoundOver { .. } => { self.handle_round_over(&bf4).await; @@ -621,7 +624,7 @@ impl Mapvote { prefs.retain(|MapInPool { map, mode, vehicles}| inner.alternatives.contains_map(*map)); let weight = match player.clone().cases() { - Left(yesvip) => Rat::one() + Rat::one(), // 2 + Left(yesvip) => self.vip_vote_weight(), Right(novip) => Rat::one(), }; @@ -760,22 +763,31 @@ impl Mapvote { if inner.popstate.pool.contains_map(map) { // make sure this VIP hasn't exceeded their nomination limit this round. if inner.vip_n_noms(&player) < self.config.max_noms_per_vip { - // phew, that was a lot of ifs... - info!("Player {} has nominated {} (vehicles: {:?})", player.name, map.Pretty(), vehicles); - inner.vip_nom(&player, map, vehicles); - info!("The new alternatives are {:?}.", inner.alternatives); - - let announce = self.config.announce_nominator.unwrap_or(true); - if announce { - futures.push(bf4.say_lines(vec![ - format!("Our beloved VIP {} has nominated {}!", &*player, map.Pretty()), - format!("{} has been added to the options, everyone can vote on it now <3", map.Pretty()), - ], Visibility::All)); + // Check if the nominated map has recently been played + if !self.mapman.is_recently_played(&map) { + // phew, that was a lot of ifs... + info!("Player {} has nominated {} (vehicles: {:?})", player.name, map.Pretty(), vehicles); + inner.vip_nom(&player, map, vehicles); + info!("The new alternatives are {:?}.", inner.alternatives); + + let announce = self.config.announce_nominator.unwrap_or(true); + if announce { + futures.push(bf4.say_lines(vec![ + format!("Our beloved VIP {} has nominated {}!", &*player, map.Pretty()), + format!("{} has been added to the options, everyone can vote on it now <3", map.Pretty()), + ], Visibility::All)); + } + else { + futures.push(bf4.say_lines(vec![ + format!("{} has been added to the options, everyone can vote on it now <3", map.Pretty()), + ], Visibility::All)); + } } else { futures.push(bf4.say_lines(vec![ - format!("{} has been added to the options, everyone can vote on it now <3", map.Pretty()), - ], Visibility::All)); + format!("Apologies, {}, that map was just recently played.", &*player), + "Try nominating some other map".to_string(), + ], player.get().into())); } } else { futures.push(bf4.say_lines(vec![ @@ -899,6 +911,14 @@ impl Mapvote { let _ = bf4.say("You are not admin (according to bfox config).", player).await; } }, + "startvote" => { + if self.admins.is_admin(&player.name) { + let _ = bf4.say("Starting new vote.", player).await; + self.start_new_vote().await; + } else { + let _ = bf4.say("You are not admin (according to bfox config).", player).await; + } + }, _ => (), } } @@ -997,8 +1017,18 @@ impl Mapvote { Ok(()) } + async fn start_new_vote(&self) { + let recent_maps = self.mapman.recent_maps(); + + let mut lock = self.inner.lock().await; + if let Some(inner) = &mut *lock { + info!("Starting a new vote: {:#?}", &inner.votes); + inner.set_up_new_vote(self.config.n_options, Some(recent_maps)); + } + } + async fn handle_round_over(&self, bf4: &Arc) { - let current_map = self.mapman.current_map(bf4).await; + let recent_maps = self.mapman.recent_maps(); self.broadcast_status(bf4).await; // send everyone the voting options. // let's wait like 10 seconds because people might still vote in the end screen. let _ = bf4.say(format!("Mapvote is still going for {}s! Hurry!", self.config.endscreen_votetime.as_secs()), Visibility::All).await; @@ -1018,7 +1048,7 @@ impl Mapvote { // get each player's votes, so we can simulate how the votes go later. let assignment = inner.to_assignment(); - inner.set_up_new_vote(self.config.n_options, current_map); + // inner.set_up_new_vote(self.config.n_options, Some(recent_maps)); Some((profile, assignment, inner.anim_override_override.clone())) } else { None @@ -1078,6 +1108,17 @@ impl Mapvote { } } } + + /// Returns the configured (or default 2) VIP vote weight + /// - Can't be less than 1 + fn vip_vote_weight(&self) -> Ratio { + let mut weight = Rat::one(); + let config_weight = self.config.vip_vote_weight.unwrap_or(2); + for n in 1..config_weight { + weight += Rat::one(); + } + weight + } } pub enum ParseMapsResult { diff --git a/battlefox/src/mapvote/config.rs b/battlefox/src/mapvote/config.rs index 93573b2..124115a 100644 --- a/battlefox/src/mapvote/config.rs +++ b/battlefox/src/mapvote/config.rs @@ -13,6 +13,8 @@ pub struct MapVoteConfig { pub max_noms_per_vip: usize, + pub vote_start_interval: Duration, + pub spammer_interval: Duration, pub endscreen_votetime: Duration, @@ -22,6 +24,7 @@ pub struct MapVoteConfig { pub vip_ad: String, pub announce_nominator: Option, + pub vip_vote_weight: Option, pub animate: bool, pub animate_override: HashMap, @@ -41,6 +44,8 @@ pub struct MapVoteConfigJson { pub max_noms_per_vip: usize, + pub vote_start_interval: u64, + pub spammer_interval: u64, pub endscreen_votetime: u64, @@ -50,6 +55,7 @@ pub struct MapVoteConfigJson { pub vip_nom: String, pub announce_nominator: Option, + pub vip_vote_weight: Option, pub animate: bool, pub animate_override: HashMap, @@ -66,12 +72,14 @@ impl MapVoteConfig { n_options: other.n_options, max_options: other.max_options, max_noms_per_vip: other.max_noms_per_vip, + vote_start_interval: Duration::from_secs(other.vote_start_interval), spammer_interval: Duration::from_secs(other.spammer_interval), endscreen_votetime: Duration::from_secs(other.endscreen_votetime), endscreen_post_votetime: Duration::from_secs(other.endscreen_post_votetime), vip_nom: other.vip_ad, vip_ad: other.vip_nom, announce_nominator: other.announce_nominator, + vip_vote_weight: other.vip_vote_weight, animate: other.animate, animate_override: other.animate_override.iter().map(|(k, v)| (k.clone().into_ascii_string().unwrap(), *v)).collect(), options_minlen: other.options_minlen, diff --git a/battlefox_database/sqlx-data.json b/battlefox_database/sqlx-data.json index e8f7dfe..14605cb 100644 --- a/battlefox_database/sqlx-data.json +++ b/battlefox_database/sqlx-data.json @@ -128,5 +128,82 @@ } }, "query": "SELECT PlayerID, ClanTag, SoldierName, EAGUID, ban_notes, ban_status, ban_startTime, ban_endTime, record_message\n FROM tbl_playerdata AS pd\n INNER JOIN adkats_bans AS bans ON pd.PlayerId = bans.player_id\n INNER JOIN adkats_records_main AS records ON records.record_id = bans.latest_record_id\n WHERE pd.EAGUID = ?;" + }, + "6a10a7bdf64d0bee4d778a5b7fe1c338926a97807f80feffb5d73fb23ba211b5": { + "describe": { + "columns": [ + { + "name": "player_id", + "ordinal": 0, + "type_info": { + "char_set": 63, + "flags": { + "bits": 4139 + }, + "max_size": 10, + "type": "Long" + } + }, + { + "name": "persona_id", + "ordinal": 1, + "type_info": { + "char_set": 63, + "flags": { + "bits": 4137 + }, + "max_size": 20, + "type": "LongLong" + } + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": { + "char_set": 63, + "flags": { + "bits": 4137 + }, + "max_size": 20, + "type": "LongLong" + } + }, + { + "name": "gravatar", + "ordinal": 3, + "type_info": { + "char_set": 224, + "flags": { + "bits": 0 + }, + "max_size": 128, + "type": "VarString" + } + }, + { + "name": "persona_banned", + "ordinal": 4, + "type_info": { + "char_set": 63, + "flags": { + "bits": 1 + }, + "max_size": 1, + "type": "Tiny" + } + } + ], + "nullable": [ + false, + false, + false, + true, + false + ], + "parameters": { + "Right": 1 + } + }, + "query": "SELECT * from adkats_battlelog_players WHERE persona_id = ?" } } \ No newline at end of file