diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 9e744bd..3af7017 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -1,9 +1,11 @@ -name: Build and Deploy +name: CI on: push: tags: - "*" + # branches: + # - main pull_request: workflow_dispatch: # enable button on github to manually trigger this @@ -17,21 +19,21 @@ defaults: shell: bash jobs: - # test: - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v2 - # - name: Cargo cache - # uses: actions/cache@v2 - # with: - # path: | - # ~/.cargo/registry - # ./target - # key: test-cargo-registry - # - name: List - # run: find ./ - # - name: Run tests - # run: cargo test --verbose + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Cargo cache + uses: actions/cache@v2 + with: + path: | + ~/.cargo/registry + ./target + key: test-cargo-registry + - name: List + run: find ./ + - name: Run tests + run: cargo test --verbose build: strategy: @@ -62,7 +64,8 @@ jobs: # needs: test runs-on: ${{ matrix.OS }} env: - NAME: battlefox + BINARY_1: battlefox + BINARY_2: battlefox_discord TARGET: ${{ matrix.TARGET }} OS: ${{ matrix.OS }} steps: @@ -105,22 +108,36 @@ jobs: run: cargo build --release --verbose --target $TARGET - name: List target run: find ./target - - name: Compress + - name: Compress battlefox run: | mkdir -p ./artifacts # windows is the only OS using a different convention for executable file name if [[ $OS =~ ^windows.*$ ]]; then - EXEC=$NAME.exe + EXEC=$BINARY_1.exe else - EXEC=$NAME + EXEC=$BINARY_1 fi if [[ $GITHUB_REF_TYPE =~ ^tag$ ]]; then TAG=$GITHUB_REF_NAME else TAG=$GITHUB_SHA fi - mv ./target/$TARGET/release/$EXEC ./$EXEC - tar -czf ./artifacts/$NAME-$TARGET-$TAG.tar.gz $EXEC + tar -czf ./artifacts/$BINARY_1-$TARGET-$TAG.tar.gz -C ./target/$TARGET/release/ $EXEC + - name: Compress battlefox_discord + run: | + mkdir -p ./artifacts + # windows is the only OS using a different convention for executable file name + if [[ $OS =~ ^windows.*$ ]]; then + EXEC=$BINARY_2.exe + else + EXEC=$BINARY_2 + fi + if [[ $GITHUB_REF_TYPE =~ ^tag$ ]]; then + TAG=$GITHUB_REF_NAME + else + TAG=$GITHUB_SHA + fi + tar -czf ./artifacts/$BINARY_2-$TARGET-$TAG.tar.gz -C ./target/$TARGET/release/ $EXEC - name: Archive artifact uses: actions/upload-artifact@v2 with: @@ -171,12 +188,25 @@ jobs: - name: Build battlefox and push uses: docker/build-push-action@v2 with: - context: . + context: ./battlefox platforms: linux/amd64 # platforms: linux/amd64,linux/arm64 - file: ./Dockerfile.cross-platform + file: ./battlefox/Dockerfile.cross-platform push: true tags: ${{ secrets.DOCKER_USERNAME }}/battlefox:${{ env.COMMIT_TAG }},${{ secrets.DOCKER_USERNAME }}/battlefox:latest build-args: | REPO_URL=${{ github.server_url }}/${{ github.repository }} TAG=${{ env.COMMIT_TAG }} + # Building image and pushing it to DockerHub + - name: Build battlefox_discord and push + uses: docker/build-push-action@v2 + with: + context: ./battlefox_discord + platforms: linux/amd64 + # platforms: linux/amd64,linux/arm64 + file: ./battlefox_discord/Dockerfile.cross-platform + push: true + tags: ${{ secrets.DOCKER_USERNAME }}/battlefox_discord:${{ env.COMMIT_TAG }},${{ secrets.DOCKER_USERNAME }}/battlefox_discord:latest + build-args: | + REPO_URL=${{ github.server_url }}/${{ github.repository }} + TAG=${{ env.COMMIT_TAG }} 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/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..8ec149f 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() }; @@ -621,7 +622,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 +761,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![ @@ -998,7 +1008,7 @@ impl Mapvote { } 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 +1028,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 +1088,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..088f2fd 100644 --- a/battlefox/src/mapvote/config.rs +++ b/battlefox/src/mapvote/config.rs @@ -22,6 +22,7 @@ pub struct MapVoteConfig { pub vip_ad: String, pub announce_nominator: Option, + pub vip_vote_weight: Option, pub animate: bool, pub animate_override: HashMap, @@ -50,6 +51,7 @@ pub struct MapVoteConfigJson { pub vip_nom: String, pub announce_nominator: Option, + pub vip_vote_weight: Option, pub animate: bool, pub animate_override: HashMap, @@ -72,6 +74,7 @@ impl MapVoteConfig { 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_discord/Dockerfile.cross-platform b/battlefox_discord/Dockerfile.cross-platform new file mode 100644 index 0000000..80b811f --- /dev/null +++ b/battlefox_discord/Dockerfile.cross-platform @@ -0,0 +1,20 @@ +FROM curlimages/curl:latest as downloader + +ARG REPO_URL +ARG TAG +ARG TARGETPLATFORM + +WORKDIR /home/curl_user + +RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then export ARCHITECTURE=x86_64-unknown-linux-gnu; elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then export ARCHITECTURE=aarch64-unknown-linux-gnu; else export ARCHITECTURE=aarch64-unknown-linux-gnu; fi \ + && curl -L -o battlefox_discord.tar.gz ${REPO_URL}/releases/download/${TAG}/battlefox_discord-${ARCHITECTURE}-${TAG}.tar.gz + +RUN tar -xf battlefox_discord.tar.gz + +FROM debian:bullseye-slim + +RUN apt-get update && apt-get install -y ca-certificates + +WORKDIR /app +COPY --from=downloader /home/curl_user/battlefox_discord . +CMD ["./battlefox_discord"] \ No newline at end of file