├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── build.sh ├── cfg ├── playerlist.json └── regx.txt ├── images ├── demo.png ├── logo.png ├── logo_smol.png └── saved_account_demo.png ├── profile.sh └── src ├── gui.rs ├── gui ├── chat_window.rs ├── player_windows.rs └── regex_windows.rs ├── io.rs ├── io ├── command_manager.rs ├── logwatcher.rs └── regexes.rs ├── main.rs ├── player_checker.rs ├── ringbuffer.rs ├── server.rs ├── server ├── parties.rs └── player.rs ├── settings.rs ├── state.rs ├── steamapi.rs ├── timer.rs └── version.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /dependencies.txt 3 | /cfg/settings.json 4 | /glium_app/target 5 | /perf* 6 | /flamegraph.svg 7 | /.vscode 8 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tf2-bot-kicker-gui" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # Some size optimization stuff 7 | [profile.release] 8 | strip = true 9 | panic = "abort" 10 | 11 | [target.x86_64-unknown-linux-gnu] 12 | linker = "/usr/bin/clang" 13 | rustflags = ["-Clink-arg=-fuse-ld=lld", "-Clink-arg=-Wl,--no-rosegment"] 14 | 15 | [target.x86_64-pc-windows-gnu] 16 | linker = "x86_64-w64-mingw32-gcc" 17 | ar = "x86_64-w64-mingw32-gcc-ar" 18 | 19 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 20 | 21 | [dependencies] 22 | rcon = { version = "0.6.0", features = ["rt-tokio"] } 23 | tokio = { version = "1", features = ["full"] } 24 | async-trait = "0.1.52" 25 | reqwest = "0.11.11" 26 | steam-api = "0.4.1" 27 | crossbeam-channel = "0.5.6" 28 | 29 | wgpu_app = { git = "https://github.com/Bash-09/wgpu_app" } 30 | wgpu = "0.15.1" 31 | winit = "0.28.3" 32 | egui = "0.21.0" 33 | egui-wgpu = "0.21.0" 34 | egui-winit = "0.21.1" 35 | egui_extras = { version = "0.21.0", features = ["image"]} 36 | 37 | serde = { version = "1.0", features = ["derive"] } 38 | serde_json = "1.0.73" 39 | 40 | chrono = "0.4.19" 41 | rfd = "0.6.3" 42 | clipboard = "0.5.0" 43 | regex = "1.5.4" 44 | 45 | log = "*" 46 | env_logger = "*" 47 | image = { version = "0.24.2", features = ["jpeg", "png"] } 48 | egui_dock = { version = "0.5.0", features = ["serde"] } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ethan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # This project is no longer maintained and has been superceeded by [TF2 Monitor](https://github.com/Bash-09/TF2-Monitor) 6 | 7 | # TF2 Bot Kicker by Bash 8 | 9 | A (somewhat) cross-platform bot identifier/kicker written in Rust. 10 | 11 | ![Demonstration Image](images/demo.png) 12 | 13 | # Preface 14 | 15 | This project is no longer maintained. It still functions as intended and is a great alternative to TF2BD, however a lot of the later features were never published in a release so I recommend building the application yourself for the best experience. 16 | 17 | I'm currently working on the [MAC Client](https://github.com/MegaAntiCheat/client-backend) instead which will eventually offer most of the functionality of this project (though not all) with some additional benefits. 18 | 19 | # What it does 20 | 21 | This is a program you run alongside TF2 while you play which automatically attempts to identify and vote-kick bots and cheaters on the server you are playing on, and can also send chat messages in-game to alert other players of bots or cheaters joining the server. It does this by maintaining a list of know or previously-seen steamids and checking if the players on the server are on that list, and if that fails it also checks player names against a list of regex rules to identify common bots such as DoesHotter or m4gic. It also features name-stealing detection, however that may not be necessary anymore as Valve has (hopefully) fixed name-stealing for good. 22 | 23 | This program operates using features already included in the Source engine as intended, no cheats required (so no VAC bans!). 24 | 25 | # Usage 26 | 27 | Download the program from [here](https://github.com/Googe14/tf2-bot-kicker-gui/releases). 28 | 29 | 30 | 1. Add the following 3 lines to your TF2 autoexec.cfg (You can choose anything for the rcon_password, you will just have to set it when you start the program) 31 | 32 | ``` 33 | ip 0.0.0.0 34 | rcon_password tf2bk 35 | net_start 36 | ``` 37 | 2. Add `-condebug -conclearlog -usercon` to your TF2 launch options. (Right click Team Fortress 2 in your Steam library, select Properties, and paste into the Launch Options section) 38 | 3. Launch TF2. 39 | 4. Run the program and set your TF2 directory. 40 | 41 | Next time you play TF2 you will just need to start the program and it will do everything else for you! 42 | 43 | # Building 44 | 45 | Install Rust: https://www.rust-lang.org/tools/install 46 | 47 | May need to enable rust nightly: `rustup default nightly` 48 | 49 | On Linux some packages will need to be installed (commands are provided for Ubuntu, other distros will have to figure out how to install the equivalent packages): 50 | ``` 51 | wget http://archive.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.0g-2ubuntu4_amd64.deb 52 | sudo dpkg -i libssl1.1_1.1.0g-2ubuntu4_amd64.deb 53 | sudo apt-get install libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libgtk-3-dev libglib2.0-dev 54 | ``` 55 | 56 | The program can then be built and run with `cargo run --release` 57 | 58 | # Settings and Configuration 59 | 60 | To reset your settings, delete the `settings.json` file in the `cfg` folder. 61 | 62 | ### General 63 | * `User` - Your SteamID3 (like from when you use the status command in-game) to indentify if bots are on the friendly or enemy team. (will stop attempting to kick enemy bots if set) 64 | * `RCon Password` - Make sure this is the same as is set by rcon_password in your autoexec.cfg file. 65 | * `Refresh Period` - How often to refresh the info from the server 66 | ### Kicking 67 | * `Kick Bots` - Automatically call votekicks on identified bots. 68 | * `Kick Cheaters` - Automatically call votekicks on known cheaters. 69 | * `Kick Period` - How many seconds between kick attempts 70 | ### Chat Messages 71 | * `Announce Bots` - Send chat messages indicating bots joining the server. 72 | * `Announce Cheaters` - Send chat messages indicating cheaters joining the server (If both bot and cheater announcements are enabled they will be combined into singular chat messages). 73 | * `Announce Name-stealing` - Announce when a bot changes it's name to another player's name (will check for invisible characters in their name as well). Hopefully this is no longer needed with Valve's recent patches. 74 | * `Ignore Bots with common names` - Don't bother to announce bots who's name matches a regex. Can be used to still announce bots with unpredictable names without spamming the chat that another m4gic is joining the game. 75 | * `Chat Message Period` - Time in seconds between sending chat messages. 76 | ### Bot Detection 77 | * `Mark accounts with a stolen name as bots` - Enable accounts that steal another player's name to be automatically marked as a bot. 78 | 79 | ### Other 80 | Any saved regexes or players can be accessed/added/editted/deleted from the `Saved Data` tab at the top. 81 | 82 | ![Demonstration of editing a saved player](images/saved_account_demo.png) 83 | 84 | ## Account identification 85 | 86 | A list of accounts is stored in `cfg/playerlist.json` containing the SteamID, player type (Player/Bot/Cheater) and any recorded notes for that account. When players join the server their steamid is matched against this list to determine if they are a bot or cheater and will take appropriate action (send chat messages, kick, etc). If they are not a know account their name will be checked against a list of regexes in case they have a common bot name (e.g. DoesHotter). 87 | 88 | # How it works 89 | 90 | The Source engine allows programs to initiate a `remote console` or `RCON` to the game (provided you have the correct launch options set) through TCP, which means this program can connect to TF2 and send in-game commands to the console remotely. By using commands such as `tf_lobby_debug` and `status` we can retrieve information about players on the server you are currently connected to (such as their name, steamid, which team they are on, how long they have been connected to the server, etc) which is used to check the existing steamid list and name rules to identify players as bots or cheaters. Once that is known, we can call a votekick or send chat messages through more remote console commands. 91 | 92 | Some commands do not respond through rcon but instead output their contents into the in-game console. Instead, by using the `-condebug -conclearlog` launch options TF2 will output the in-game console to a log file in real-time which this program reads from, this is why you are required to set your TF2 directory on program startup. 93 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | rm tf2-bot-kicker-gui 2 | rm tf2-bot-kicker-gui.exe 3 | 4 | rm target/release/tf2-bot-kicker-gui 5 | rm target/x86_64-pc-windows-gnu/release/tf2-bot-kicker-gui.exe 6 | 7 | cargo build --release 8 | cp target/release/tf2-bot-kicker-gui . 9 | 10 | cargo build --release --target x86_64-pc-windows-gnu 11 | cp target/x86_64-pc-windows-gnu/release/tf2-bot-kicker-gui.exe . 12 | -------------------------------------------------------------------------------- /cfg/regx.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bash-09/tf2-bot-kicker-gui/0180cad010603543a9a3c32337bb3f67beb66f0a/cfg/regx.txt -------------------------------------------------------------------------------- /images/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bash-09/tf2-bot-kicker-gui/0180cad010603543a9a3c32337bb3f67beb66f0a/images/demo.png -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bash-09/tf2-bot-kicker-gui/0180cad010603543a9a3c32337bb3f67beb66f0a/images/logo.png -------------------------------------------------------------------------------- /images/logo_smol.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bash-09/tf2-bot-kicker-gui/0180cad010603543a9a3c32337bb3f67beb66f0a/images/logo_smol.png -------------------------------------------------------------------------------- /images/saved_account_demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bash-09/tf2-bot-kicker-gui/0180cad010603543a9a3c32337bb3f67beb66f0a/images/saved_account_demo.png -------------------------------------------------------------------------------- /profile.sh: -------------------------------------------------------------------------------- 1 | echo 0 | sudo tee /proc/sys/kernel/perf_event_paranoid 2 | echo 0 | sudo tee /proc/sys/kernel/kptr_restrict 3 | CARGO_PROFILE_RELEASE_DEBUG=true CARGO_PROFILE_RELEASE_STRIP=false cargo flamegraph 4 | -------------------------------------------------------------------------------- /src/gui.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Display, ops::RangeInclusive}; 2 | 3 | use clipboard::{ClipboardContext, ClipboardProvider}; 4 | use egui::{gui_zoom, Color32, Id, Label, RichText, Separator, Ui}; 5 | use egui_dock::Tree; 6 | use serde::{Deserialize, Serialize}; 7 | use wgpu_app::utils::persistent_window::PersistentWindow; 8 | 9 | use crate::{ 10 | io::{command_manager::CommandManager, IORequest}, 11 | player_checker::PlayerRecord, 12 | server::player::{Player, PlayerType, Team, UserAction}, 13 | state::State, 14 | steamapi, 15 | version::VersionResponse, 16 | }; 17 | 18 | use self::{ 19 | chat_window::view_chat_window, 20 | player_windows::{edit_player_window, saved_players_window}, 21 | regex_windows::view_regexes_window, 22 | }; 23 | 24 | pub mod chat_window; 25 | pub mod player_windows; 26 | pub mod regex_windows; 27 | 28 | #[derive(Debug, PartialEq, Eq, Copy, Clone, Serialize, Deserialize)] 29 | pub enum GuiTab { 30 | Settings, 31 | Players, 32 | ChatLog, 33 | DeathLog, 34 | } 35 | 36 | impl Display for GuiTab { 37 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 38 | f.write_str(match self { 39 | GuiTab::Settings => "Settings", 40 | GuiTab::Players => "Players", 41 | GuiTab::ChatLog => "Chat", 42 | GuiTab::DeathLog => "Death Log", 43 | }) 44 | } 45 | } 46 | 47 | pub fn render_top_panel(gui_ctx: &egui::Context, state: &mut State, gui_tree: &mut Tree) { 48 | // Top menu bar 49 | egui::TopBottomPanel::top("top_panel").show(gui_ctx, |ui| { 50 | // File 51 | egui::menu::bar(ui, |ui| { 52 | ui.menu_button("File", |ui| { 53 | if ui.button("Set TF2 Directory").clicked() { 54 | if let Some(pb) = rfd::FileDialog::new().pick_folder() { 55 | let dir = match pb.strip_prefix(std::env::current_dir().unwrap()) { 56 | Ok(pb) => pb.to_string_lossy().to_string(), 57 | Err(_) => pb.to_string_lossy().to_string(), 58 | }; 59 | state.settings.tf2_directory = dir; 60 | state.io.send(crate::io::IORequest::UpdateDirectory( 61 | state.settings.tf2_directory.clone(), 62 | )); 63 | } 64 | } 65 | }); 66 | 67 | ui.menu_button("View", |ui| { 68 | // Allow tabs to be toggled 69 | for tab in &[ 70 | GuiTab::Settings, 71 | GuiTab::Players, 72 | GuiTab::ChatLog, 73 | GuiTab::DeathLog, 74 | ] { 75 | let open_tab = gui_tree.find_tab(tab); 76 | if ui 77 | .selectable_label(open_tab.is_some(), format!("{}", tab)) 78 | .clicked() 79 | { 80 | if let Some(index) = open_tab { 81 | gui_tree.remove_tab(index); 82 | } else { 83 | gui_tree.push_to_focused_leaf(*tab); 84 | } 85 | } 86 | } 87 | }); 88 | 89 | // Import Regexes and SteamIDs 90 | ui.menu_button("Import", |ui| { 91 | if ui.button("Import playlist").clicked() { 92 | if let Err(e) = state.player_checker.import_players() { 93 | state.new_persistent_windows.push(create_dialog_box( 94 | String::from("Could not import playerlist"), 95 | format!("{:?}", e), 96 | )); 97 | } 98 | } 99 | 100 | let mut import_list: Option = None; 101 | if ui.button("Import Bots").clicked() { 102 | import_list = Some(PlayerType::Bot); 103 | } 104 | if ui.button("Import Cheaters").clicked() { 105 | import_list = Some(PlayerType::Cheater); 106 | } 107 | if ui.button("Import Suspicious").clicked() { 108 | import_list = Some(PlayerType::Suspicious); 109 | } 110 | 111 | if let Some(player_type) = import_list { 112 | if let Some(pb) = rfd::FileDialog::new().set_directory("cfg").pick_file() { 113 | let dir = match pb.strip_prefix(std::env::current_dir().unwrap()) { 114 | Ok(pb) => pb.to_string_lossy().to_string(), 115 | Err(_) => pb.to_string_lossy().to_string(), 116 | }; 117 | 118 | match state 119 | .player_checker 120 | .read_from_steamid_list(&dir, player_type) 121 | { 122 | Ok(_) => { 123 | log::info!( 124 | "{}", 125 | format!( 126 | "Added {} as a steamid list", 127 | &dir.split('/').last().unwrap() 128 | ) 129 | ); 130 | } 131 | Err(e) => { 132 | log::error!("Failed to add steamid list: {}", format!("{}", e)); 133 | } 134 | } 135 | } 136 | } 137 | 138 | if ui.button("Import regex list").clicked() { 139 | if let Some(pb) = rfd::FileDialog::new().set_directory("cfg").pick_file() { 140 | if let Err(e) = state.player_checker.read_regex_list(pb) { 141 | log::error!("Failed to import regexes: {:?}", e); 142 | } 143 | } 144 | } 145 | }); 146 | 147 | // Saved Data 148 | ui.menu_button("Saved Data", |ui| { 149 | if ui.button("Regexes").clicked() { 150 | state.new_persistent_windows.push(view_regexes_window()); 151 | } 152 | 153 | if ui.button("Saved Players").clicked() { 154 | state.new_persistent_windows.push(saved_players_window()); 155 | } 156 | }); 157 | 158 | if ui.button("Recent players").clicked() { 159 | state 160 | .new_persistent_windows 161 | .push(player_windows::recent_players_window()); 162 | } 163 | 164 | if ui.button("Chat settings").clicked() { 165 | state.new_persistent_windows.push(view_chat_window()); 166 | } 167 | 168 | if ui.button("Check for updates").clicked() && state.latest_version.is_none() { 169 | state.latest_version = Some(VersionResponse::request_latest_version()); 170 | state.force_latest_version = true; 171 | } 172 | 173 | if ui.button("Steam API").clicked() { 174 | state 175 | .new_persistent_windows 176 | .push(steamapi::create_set_api_key_window( 177 | state.settings.steamapi_key.clone(), 178 | )); 179 | } 180 | }); 181 | }); 182 | } 183 | 184 | pub fn render_settings(ui: &mut Ui, state: &mut State) { 185 | egui::ScrollArea::vertical().show(ui, |ui| { 186 | ui.heading("Settings"); 187 | 188 | ui.horizontal(|ui| { 189 | ui.label("User: "); 190 | ui.text_edit_singleline(&mut state.settings.user); 191 | }); 192 | 193 | ui.horizontal(|ui| { 194 | ui.label("RCon Password: "); 195 | if ui.text_edit_singleline(&mut state.settings.rcon_password).changed() { 196 | state.io.send(IORequest::UpdateRconPassword(state.settings.rcon_password.clone())); 197 | } 198 | }); 199 | 200 | ui.horizontal(|ui| { 201 | ui.add( 202 | egui::DragValue::new(&mut state.settings.refresh_period) 203 | .speed(0.1) 204 | .clamp_range(RangeInclusive::new(0.5, 60.0)), 205 | ); 206 | ui.label("Refresh Period").on_hover_text("Time between refreshing the server information."); 207 | }); 208 | 209 | ui.checkbox(&mut state.settings.paused, "Pause actions").on_hover_text("Prevents the program from calling any votekicks or sending chat messages."); 210 | ui.checkbox(&mut state.settings.launch_tf2, "Launch TF2").on_hover_text("Launch TF2 when this program is started."); 211 | ui.checkbox(&mut state.settings.close_on_disconnect, "Close with TF2").on_hover_text("Close this program automatically when it disconnects from TF2."); 212 | 213 | ui.add(Separator::default().spacing(20.0)); 214 | ui.heading("Kicking"); 215 | 216 | ui.checkbox(&mut state.settings.kick_bots, "Kick Bots").on_hover_text("Automatically attempt to call votekicks on bots."); 217 | ui.checkbox(&mut state.settings.kick_cheaters, "Kick Cheaters").on_hover_text("Automatically attempt to call votekicks on cheaters."); 218 | 219 | ui.horizontal(|ui| { 220 | ui.add_enabled(state.settings.kick_bots || state.settings.kick_cheaters, 221 | egui::DragValue::new(&mut state.settings.kick_period) 222 | .speed(0.1) 223 | .clamp_range(RangeInclusive::new(0.5, 60.0)), 224 | ); 225 | ui.add_enabled(state.settings.kick_bots || state.settings.kick_cheaters, 226 | Label::new("Kick Period")).on_hover_text("Time between attempting to kick bots or cheaters."); 227 | }); 228 | 229 | ui.add(Separator::default().spacing(20.0)); 230 | ui.heading("Chat Messages"); 231 | 232 | ui.checkbox(&mut state.settings.announce_bots, "Announce Bots").on_hover_text("Send a chat message indicating Bots joining the server."); 233 | ui.checkbox(&mut state.settings.announce_cheaters, "Announce Cheaters").on_hover_text("Send a chat message indicating cheaters joining the server."); 234 | ui.checkbox(&mut state.settings.announce_namesteal, "Announce Name-stealing").on_hover_text("Send a chat message when an account's name is changed to imitate another player (This is not affected by the chat message period)."); 235 | ui.checkbox(&mut state.settings.dont_announce_common_names, "Ignore Bots with common names").on_hover_text("Don't announce bots who's name matches saved regexes, to avoid announcing well-known bots (e.g. DoesHotter, m4gic)."); 236 | 237 | ui.horizontal(|ui| { 238 | ui.add_enabled(state.settings.announce_bots || state.settings.announce_cheaters, 239 | egui::DragValue::new(&mut state.settings.alert_period) 240 | .speed(0.1) 241 | .clamp_range(RangeInclusive::new(0.5, 60.0)), 242 | ); 243 | ui.add_enabled(state.settings.announce_bots || state.settings.announce_cheaters, 244 | Label::new("Chat Message Period")).on_hover_text("Time between sending chat messages."); 245 | }); 246 | 247 | ui.add(Separator::default().spacing(20.0)); 248 | ui.heading("Bot Detection"); 249 | 250 | ui.checkbox(&mut state.settings.mark_name_stealers, "Mark accounts with a stolen name as bots") 251 | .on_hover_text("Accounts that change their name to another account's name will be automatically marked as a name-stealing bot."); 252 | }); 253 | } 254 | 255 | pub fn render_chat(ui: &mut Ui, state: &mut State) { 256 | egui::ScrollArea::vertical().show_rows( 257 | ui, 258 | ui.text_style_height(&egui::TextStyle::Body), 259 | state.server.get_chat().len(), 260 | |ui, range| { 261 | let messages = state.server.get_chat(); 262 | for i in range { 263 | let msg = &messages[messages.len() - i - 1]; 264 | 265 | ui.horizontal(|ui| { 266 | if let Some(steamid) = &msg.steamid { 267 | let mut name = RichText::new(&msg.player_name); 268 | if steamid == &state.settings.user { 269 | name = name.color(Color32::GREEN); 270 | } else if let Some(p) = state.player_checker.check_player_steamid(steamid) { 271 | name = name.color(p.player_type.color(ui)); 272 | } 273 | if ui.selectable_label(false, name).clicked() { 274 | let record = if let Some(player) = 275 | state.server.get_players().get(steamid) 276 | { 277 | player.get_record() 278 | } else if let Some(player) = state.player_checker.players.get(steamid) { 279 | player.clone() 280 | } else { 281 | PlayerRecord { 282 | steamid: steamid.clone(), 283 | player_type: PlayerType::Player, 284 | notes: String::new(), 285 | } 286 | }; 287 | state 288 | .new_persistent_windows 289 | .push(edit_player_window(record)); 290 | } 291 | } else { 292 | ui.label(&msg.player_name); 293 | } 294 | 295 | ui.label(format!(": {}", msg.message)); 296 | }); 297 | } 298 | }, 299 | ); 300 | } 301 | 302 | pub fn render_kills(ui: &mut Ui, state: &mut State) { 303 | egui::ScrollArea::vertical().show_rows( 304 | ui, 305 | ui.text_style_height(&egui::TextStyle::Body), 306 | state.server.get_kills().len(), 307 | |ui, range| { 308 | let kills = state.server.get_kills(); 309 | for i in range { 310 | let kill = &kills[kills.len() - i - 1]; 311 | 312 | ui.horizontal(|ui| { 313 | if let Some(steamid) = &kill.killer_steamid { 314 | let mut name = RichText::new(&kill.killer_name); 315 | if steamid == &state.settings.user { 316 | name = name.color(Color32::GREEN); 317 | } else if let Some(p) = state.player_checker.check_player_steamid(steamid) { 318 | name = name.color(p.player_type.color(ui)); 319 | } 320 | if ui.selectable_label(false, name).clicked() { 321 | // Open player editor on click 322 | let record = if let Some(player) = 323 | state.server.get_players().get(steamid) 324 | { 325 | player.get_record() 326 | } else if let Some(player) = state.player_checker.players.get(steamid) { 327 | player.clone() 328 | } else { 329 | PlayerRecord { 330 | steamid: steamid.clone(), 331 | player_type: PlayerType::Player, 332 | notes: String::new(), 333 | } 334 | }; 335 | state 336 | .new_persistent_windows 337 | .push(edit_player_window(record)); 338 | } 339 | } else { 340 | ui.label(&kill.killer_name); 341 | } 342 | 343 | ui.label(" killed "); 344 | 345 | if let Some(steamid) = &kill.victim_steamid { 346 | let mut name = RichText::new(&kill.victim_name); 347 | if steamid == &state.settings.user { 348 | name = name.color(Color32::GREEN); 349 | } else if let Some(p) = state.player_checker.check_player_steamid(steamid) { 350 | name = name.color(p.player_type.color(ui)); 351 | } 352 | if ui.selectable_label(false, name).clicked() { 353 | // Open player editor on click 354 | let record = if let Some(player) = 355 | state.server.get_players().get(steamid) 356 | { 357 | player.get_record() 358 | } else if let Some(player) = state.player_checker.players.get(steamid) { 359 | player.clone() 360 | } else { 361 | PlayerRecord { 362 | steamid: steamid.clone(), 363 | player_type: PlayerType::Player, 364 | notes: String::new(), 365 | } 366 | }; 367 | state 368 | .new_persistent_windows 369 | .push(edit_player_window(record)); 370 | } 371 | } else { 372 | ui.label(&kill.killer_name); 373 | } 374 | 375 | ui.label(format!("with")); 376 | let mut text = RichText::new(&kill.weapon); 377 | if kill.crit { 378 | text = text.color(Color32::YELLOW); 379 | } 380 | ui.label(text); 381 | }); 382 | } 383 | }, 384 | ); 385 | } 386 | 387 | // Make a selectable label which copies it's text to the clipboard on click 388 | fn copy_label(text: &str, ui: &mut Ui) { 389 | let lab = ui.selectable_label(false, text); 390 | if lab.clicked() { 391 | let ctx: Result> = ClipboardProvider::new(); 392 | if let Ok(mut ctx) = ctx { 393 | ctx.set_contents(text.to_string()).ok(); 394 | } 395 | } 396 | lab.on_hover_text("Copy"); 397 | } 398 | 399 | // u32 -> minutes:seconds 400 | pub fn format_time(time: u32) -> String { 401 | format!("{:2}:{:02}", time / 60, time % 60) 402 | } 403 | 404 | pub const TRUNC_LEN: usize = 40; 405 | 406 | /// Truncates a &str 407 | pub fn truncate(s: &str, max_chars: usize) -> &str { 408 | match s.char_indices().nth(max_chars) { 409 | None => s, 410 | Some((idx, _)) => &s[..idx], 411 | } 412 | } 413 | 414 | pub fn render_players(ui: &mut Ui, state: &mut State) { 415 | if let Err(e) = &state.log_open { 416 | ui.label(&format!("Could not open log file: {}", e)); 417 | ui.label("Have you set your TF2 directory properly? (It should be the one inside \"common\")\n\n"); 418 | ui.label("Instructions:"); 419 | ui.horizontal(|ui| { 420 | ui.label("1. Add"); 421 | copy_label("-condebug -conclearlog -usercon", ui); 422 | ui.label("to your TF2 launch options and start the game."); 423 | }); 424 | 425 | ui.horizontal(|ui| { 426 | ui.label("2. Click"); 427 | if ui.button("Set your TF2 directory").clicked() { 428 | if let Some(pb) = rfd::FileDialog::new().pick_folder() { 429 | let dir = match pb.strip_prefix(std::env::current_dir().unwrap()) { 430 | Ok(pb) => pb.to_string_lossy().to_string(), 431 | Err(_) => pb.to_string_lossy().to_string(), 432 | }; 433 | state.settings.tf2_directory = dir; 434 | state.io.send(IORequest::UpdateDirectory( 435 | state.settings.tf2_directory.clone(), 436 | )); 437 | } 438 | } 439 | ui.label("and navigate to your Team Fortress 2 folder"); 440 | }); 441 | ui.label("3. Start the program and enjoy the game!\n\n"); 442 | ui.label("Note: If you have set your TF2 directory but are still seeing this message, ensure you have added the launch options and launched the game before trying again."); 443 | ui.add_space(15.0); 444 | ui.label("If you have set your TF2 directory and the appropriate launch settings, try launching the game and reopening this application."); 445 | } else { 446 | match state.is_connected() { 447 | // Connected and good 448 | Ok(false) => { 449 | ui.label("Connecting..."); 450 | } 451 | Ok(true) => { 452 | if state.server.get_players().is_empty() { 453 | ui.label("Not currently connected to a server."); 454 | } else { 455 | render_players_internal(ui, state); 456 | } 457 | } 458 | // RCON couldn't connect 459 | Err(e) => { 460 | match e { 461 | // Wrong password 462 | rcon::Error::Auth => { 463 | ui.heading("Failed to authorise RCON - Password incorrect"); 464 | 465 | ui.horizontal(|ui| { 466 | ui.label("Run "); 467 | copy_label(&format!("rcon_password {}", &state.settings.rcon_password), ui); 468 | ui.label("in your TF2 console, and make sure it is in your autoexec.cfg file."); 469 | }); 470 | } 471 | // Connection issue 472 | _ => { 473 | ui.heading("Could not connect to TF2:"); 474 | 475 | ui.label(""); 476 | ui.label("Is TF2 running?"); 477 | ui.horizontal(|ui| { 478 | ui.label("Does your autoexec.cfg file contain"); 479 | copy_label("net_start", ui); 480 | ui.label("?"); 481 | }); 482 | ui.horizontal(|ui| { 483 | ui.label("Does your TF2 launch option include"); 484 | copy_label("-usercon", ui); 485 | ui.label("?"); 486 | }); 487 | } 488 | } 489 | } 490 | } 491 | } 492 | } 493 | 494 | // Ui for a player 495 | fn render_players_internal(ui: &mut Ui, state: &mut State) { 496 | egui::ScrollArea::vertical().show(ui, |ui| { 497 | let mut remaining_players = Vec::new(); 498 | let mut action: Option<(UserAction, &Player)> = None; 499 | let width = (ui.available_width() - 5.0) / 2.0; 500 | 501 | ui.columns(2, |cols| { 502 | // Headings 503 | cols[0].horizontal(|ui| { 504 | ui.set_width(width); 505 | ui.colored_label(Color32::WHITE, "Player Name"); 506 | 507 | ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| { 508 | ui.horizontal(|ui| { 509 | ui.label(" "); 510 | ui.colored_label(Color32::WHITE, "Time"); 511 | ui.colored_label(Color32::WHITE, "Info"); 512 | }); 513 | }); 514 | }); 515 | 516 | cols[1].horizontal(|ui| { 517 | ui.set_width(width); 518 | ui.colored_label(Color32::WHITE, "Player Name"); 519 | 520 | ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| { 521 | ui.horizontal(|ui| { 522 | ui.label(" "); 523 | ui.colored_label(Color32::WHITE, "Time"); 524 | ui.colored_label(Color32::WHITE, "Info"); 525 | }); 526 | }); 527 | }); 528 | 529 | // Render players 530 | let mut playerlist: Vec<&Player> = state.server.get_players().values().collect(); 531 | playerlist.sort_by(|a, b| b.time.cmp(&a.time)); 532 | 533 | for player in playerlist { 534 | let team_ui = match player.team { 535 | Team::Invaders => &mut cols[0], 536 | Team::Defenders => &mut cols[1], 537 | Team::None => { 538 | remaining_players.push(player); 539 | continue; 540 | } 541 | }; 542 | 543 | team_ui.horizontal(|ui| { 544 | ui.set_width(width); 545 | 546 | if let Some(returned_action) = player.render_player( 547 | ui, 548 | &state.settings.user, 549 | true, 550 | !state.settings.steamapi_key.is_empty(), 551 | state.server.get_player_party_color(player), 552 | ) { 553 | action = Some((returned_action, player)); 554 | } 555 | }); 556 | } 557 | }); 558 | 559 | // Render players with no team 560 | if !remaining_players.is_empty() { 561 | ui.separator(); 562 | for player in remaining_players { 563 | ui.horizontal(|ui| { 564 | if let Some(returned_action) = player.render_player( 565 | ui, 566 | &state.settings.user, 567 | true, 568 | !state.settings.steamapi_key.is_empty(), 569 | state.server.get_player_party_color(player), 570 | ) { 571 | action = Some((returned_action, player)); 572 | } 573 | }); 574 | } 575 | } 576 | 577 | // Do whatever action the user requested from the UI 578 | if let Some((action, player)) = action { 579 | match action { 580 | UserAction::Update(record) => { 581 | state.server.update_player_from_record(record.clone()); 582 | state.player_checker.update_player_record(record); 583 | } 584 | UserAction::Kick(reason) => { 585 | state 586 | .io 587 | .send(IORequest::RunCommand(CommandManager::kick_player_command( 588 | &player.userid, 589 | reason, 590 | ))); 591 | } 592 | UserAction::GetProfile(steamid32) => { 593 | state.steamapi_request_sender.send(steamid32).ok(); 594 | } 595 | UserAction::OpenWindow(window) => { 596 | state.new_persistent_windows.push(window); 597 | } 598 | } 599 | } 600 | }); 601 | } 602 | 603 | fn create_dialog_box(title: String, text: String) -> PersistentWindow { 604 | PersistentWindow::new(Box::new(move |id, _, ctx, _| { 605 | let mut open = true; 606 | 607 | egui::Window::new(&title) 608 | .id(Id::new(id)) 609 | .open(&mut open) 610 | .collapsible(false) 611 | .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) 612 | .show(ctx, |ui| { 613 | ui.label(&text); 614 | }); 615 | 616 | open 617 | })) 618 | } 619 | -------------------------------------------------------------------------------- /src/gui/chat_window.rs: -------------------------------------------------------------------------------- 1 | use egui::{Id, Separator}; 2 | use wgpu_app::utils::persistent_window::PersistentWindow; 3 | 4 | use crate::state::State; 5 | 6 | pub fn view_chat_window() -> PersistentWindow { 7 | PersistentWindow::new(Box::new(move |id, _windows, ctx, state| { 8 | let mut open = true; 9 | 10 | egui::Window::new("Chat settings") 11 | .id(Id::new(id)) 12 | .open(&mut open) 13 | .show(ctx, |ui| { 14 | ui.heading("Joining"); 15 | 16 | ui.horizontal(|ui| { 17 | ui.label("Message (bots)") 18 | .on_hover_text("when *bots* are joining"); 19 | ui.text_edit_singleline(&mut state.settings.message_bots); 20 | }); 21 | ui.horizontal(|ui| { 22 | ui.label("Message (cheaters)") 23 | .on_hover_text("When *cheaters* are joining"); 24 | ui.text_edit_singleline(&mut state.settings.message_cheaters); 25 | }); 26 | ui.horizontal(|ui| { 27 | ui.label("Message (both)") 28 | .on_hover_text("When *both bots and cheaters* are joining"); 29 | ui.text_edit_singleline(&mut state.settings.message_both); 30 | }); 31 | 32 | ui.add(Separator::default().spacing(20.0)); 33 | ui.heading("Team"); 34 | 35 | ui.horizontal(|ui| { 36 | ui.label("Message (same team)") 37 | .on_hover_text("When a bot/cheater joins *your* team"); 38 | ui.text_edit_singleline(&mut state.settings.message_same_team); 39 | }); 40 | ui.horizontal(|ui| { 41 | ui.label("Message (enemy team)").on_hover_text( 42 | "When a bot/cheater joins the *enemy* team", 43 | ); 44 | ui.text_edit_singleline(&mut state.settings.message_enemy_team); 45 | }); 46 | ui.horizontal(|ui| { 47 | ui.label("Message (both teams)") 48 | .on_hover_text("When bots/cheaters join *both* teams"); 49 | ui.text_edit_singleline(&mut state.settings.message_both_teams); 50 | }); 51 | ui.horizontal(|ui| { 52 | ui.label("Message (default)") 53 | .on_hover_text("When a bot/cheater joins your game (for when your UserID is not provided or the bot does not have a team)"); 54 | ui.text_edit_singleline(&mut state.settings.message_default); 55 | }); 56 | 57 | ui.add(Separator::default().spacing(20.0)); 58 | ui.heading("Example message:"); 59 | ui.label(format!("{} {} m4gic", state.settings.message_bots.trim(), state.settings.message_same_team.trim())); 60 | }); 61 | 62 | open 63 | })) 64 | } 65 | -------------------------------------------------------------------------------- /src/gui/player_windows.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | use clipboard::{ClipboardContext, ClipboardProvider}; 4 | use egui::{ComboBox, Id, Label, RichText, SelectableLabel, Ui, Vec2}; 5 | use wgpu_app::utils::persistent_window::PersistentWindow; 6 | 7 | use crate::{ 8 | player_checker::PlayerRecord, 9 | server::player::{Player, PlayerType, UserAction}, 10 | state::State, 11 | }; 12 | 13 | /// Window that shows all steam accounts currently saved in the playerlist.json file 14 | pub fn saved_players_window() -> PersistentWindow { 15 | enum Action { 16 | Delete(String), 17 | Edit(String), 18 | } 19 | 20 | let mut filter: Option = None; 21 | let mut search = String::new(); 22 | 23 | PersistentWindow::new(Box::new(move |id, windows, ctx, state| { 24 | let mut open = true; 25 | let mut action: Option = None; 26 | 27 | egui::Window::new("Saved Players") 28 | .id(Id::new(id)) 29 | .open(&mut open) 30 | .show(ctx, |ui| { 31 | ui.vertical_centered(|ui| { 32 | if ui.button("Add Player").clicked() { 33 | windows.push(edit_player_window(PlayerRecord { 34 | steamid: String::new(), 35 | player_type: PlayerType::Player, 36 | notes: String::new(), 37 | })); 38 | } 39 | ui.separator(); 40 | 41 | // Filter 42 | ui.horizontal(|ui| { 43 | let text = match filter { 44 | Some(filter) => { 45 | RichText::new(format!("{:?}", filter)).color(filter.color(ui)) 46 | } 47 | None => RichText::new("None"), 48 | }; 49 | 50 | ui.label("Filter"); 51 | 52 | egui::ComboBox::new("Saved Players", "") 53 | .selected_text(text) 54 | .show_ui(ui, |ui| { 55 | ui.selectable_value(&mut filter, None, "None"); 56 | ui.selectable_value( 57 | &mut filter, 58 | Some(PlayerType::Player), 59 | PlayerType::Player.rich_text(), 60 | ); 61 | ui.selectable_value( 62 | &mut filter, 63 | Some(PlayerType::Bot), 64 | PlayerType::Bot.rich_text(), 65 | ); 66 | ui.selectable_value( 67 | &mut filter, 68 | Some(PlayerType::Cheater), 69 | PlayerType::Cheater.rich_text(), 70 | ); 71 | ui.selectable_value( 72 | &mut filter, 73 | Some(PlayerType::Suspicious), 74 | PlayerType::Suspicious.rich_text(), 75 | ); 76 | }); 77 | 78 | // Search 79 | ui.add_space(20.0); 80 | ui.label("Search"); 81 | ui.text_edit_singleline(&mut search); 82 | }); 83 | ui.separator(); 84 | 85 | // Actual player area 86 | let mut players: Vec<&mut PlayerRecord> = 87 | state.player_checker.players.values_mut().collect(); 88 | players.retain(|p| { 89 | if let Some(filter) = filter { 90 | if p.player_type != filter { 91 | return false; 92 | } 93 | } 94 | if !p.steamid.contains(&search) && !p.notes.contains(&search) { 95 | return false; 96 | } 97 | true 98 | }); 99 | 100 | let len = players.len(); 101 | egui::ScrollArea::vertical().show_rows( 102 | ui, 103 | ui.text_style_height(&egui::TextStyle::Body), 104 | len, 105 | |ui, range| { 106 | for i in range { 107 | let p: &mut PlayerRecord = players[len - i - 1]; 108 | 109 | ui.horizontal(|ui| { 110 | if ui.button("Delete").clicked() { 111 | action = Some(Action::Delete(p.steamid.clone())); 112 | } 113 | if ui.button("Edit").clicked() { 114 | action = Some(Action::Edit(p.steamid.clone())); 115 | } 116 | 117 | ui.add_sized( 118 | Vec2::new(50.0, 20.0), 119 | Label::new( 120 | RichText::new(format!("{:?}", p.player_type)) 121 | .color(p.player_type.color(ui)), 122 | ), 123 | ); 124 | 125 | let steamid_response = ui.add_sized( 126 | Vec2::new(100.0, 20.0), 127 | SelectableLabel::new(false, &p.steamid), 128 | ); 129 | if steamid_response.clicked() { 130 | let ctx: Result> = 131 | ClipboardContext::new(); 132 | if let Ok(mut ctx) = ctx { 133 | ctx.set_contents(p.steamid.clone()).ok(); 134 | } 135 | } 136 | steamid_response.on_hover_text("Click to copy"); 137 | ui.label(&p.notes); 138 | ui.add_space(ui.available_width()); 139 | }); 140 | } 141 | }, 142 | ); 143 | }); 144 | }); 145 | 146 | if let Some(Action::Delete(steamid)) = action { 147 | state.player_checker.players.remove(&steamid); 148 | state.server.remove_player(&steamid); 149 | } else if let Some(Action::Edit(steamid)) = action { 150 | windows.push(edit_player_window( 151 | state.player_checker.players.get(&steamid).unwrap().clone(), 152 | )); 153 | } 154 | 155 | open 156 | })) 157 | } 158 | 159 | /// Edit a player record 160 | pub fn edit_player_window(mut record: PlayerRecord) -> PersistentWindow { 161 | PersistentWindow::new(Box::new(move |id, _, gui_ctx, state| { 162 | let mut open = true; 163 | let mut saved = false; 164 | 165 | egui::Window::new(format!("Editing Player {}", record.steamid)) 166 | .id(Id::new(id)) 167 | .open(&mut open) 168 | .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) 169 | .collapsible(false) 170 | .show(gui_ctx, |ui| { 171 | ui.horizontal(|ui| { 172 | ui.label("SteamID3") 173 | .on_hover_text("SteamID3 has the format U:1:xxxxxxx"); 174 | ui.text_edit_singleline(&mut record.steamid); 175 | }); 176 | 177 | ui.horizontal(|ui| { 178 | ui.label("Player Type"); 179 | player_type_combobox("Editing Player", &mut record.player_type, ui); 180 | }); 181 | 182 | ui.text_edit_multiline(&mut record.notes); 183 | if ui.button("Save").clicked() { 184 | saved = true; 185 | state.player_checker.update_player_record(record.clone()); 186 | 187 | // Update current server record 188 | state.server.update_player_from_record(record.clone()); 189 | } 190 | 191 | // Render player info if they are in the server 192 | if let Some(player) = state.server.get_players().get(&record.steamid) { 193 | player.render_account_info(ui, None); 194 | } 195 | }); 196 | 197 | open & !saved 198 | })) 199 | } 200 | 201 | /// Show a list of players that were recently connected to the server 202 | pub fn recent_players_window() -> PersistentWindow { 203 | PersistentWindow::new(Box::new(move |id, windows, gui_ctx, state| { 204 | let mut open = true; 205 | 206 | egui::Window::new("Recent Players") 207 | .id(Id::new(id)) 208 | .open(&mut open) 209 | .collapsible(true) 210 | .show(gui_ctx, |ui| { 211 | egui::ScrollArea::vertical().show_rows( 212 | ui, 213 | ui.text_style_height(&egui::TextStyle::Body), 214 | state.server.get_previous_players().inner().len(), 215 | |ui, range| { 216 | let width = 500.0; 217 | ui.set_width(width); 218 | 219 | // Render players 220 | let mut action: Option<(UserAction, &Player)> = None; 221 | for i in range { 222 | let player = &state.server.get_previous_players().inner() 223 | [state.server.get_previous_players().inner().len() - i - 1]; 224 | 225 | ui.horizontal(|ui| { 226 | ui.set_width(width); 227 | 228 | if let Some(returned_action) = player.render_player( 229 | ui, 230 | &state.settings.user, 231 | false, 232 | !state.settings.steamapi_key.is_empty(), 233 | None 234 | ) { 235 | action = Some((returned_action, player)); 236 | } 237 | }); 238 | } 239 | 240 | // Do whatever action the user requested from the UI 241 | if let Some((action, _)) = action { 242 | match action { 243 | UserAction::Update(record) => { 244 | state.server.update_player_from_record(record.clone()); 245 | state.player_checker.update_player_record(record); 246 | } 247 | UserAction::Kick(_) => { 248 | log::error!( 249 | "Was able to kick from the recent players window??" 250 | ); 251 | } 252 | UserAction::GetProfile(steamid32) => { 253 | state.steamapi_request_sender.send(steamid32).ok(); 254 | } 255 | UserAction::OpenWindow(window) => { 256 | windows.push(window); 257 | } 258 | } 259 | } 260 | }, 261 | ); 262 | }); 263 | open 264 | })) 265 | } 266 | 267 | /// Creates a dropdown combobox to select a player type, returns true if the value was changed 268 | pub fn player_type_combobox(id: &str, player_type: &mut PlayerType, ui: &mut Ui) -> bool { 269 | let mut changed = false; 270 | ComboBox::from_id_source(id) 271 | .selected_text(player_type.rich_text()) 272 | .show_ui(ui, |ui| { 273 | changed |= ui 274 | .selectable_value( 275 | player_type, 276 | PlayerType::Player, 277 | PlayerType::Player.rich_text(), 278 | ) 279 | .clicked(); 280 | changed |= ui 281 | .selectable_value(player_type, PlayerType::Bot, PlayerType::Bot.rich_text()) 282 | .clicked(); 283 | changed |= ui 284 | .selectable_value( 285 | player_type, 286 | PlayerType::Cheater, 287 | PlayerType::Cheater.rich_text(), 288 | ) 289 | .clicked(); 290 | changed |= ui 291 | .selectable_value( 292 | player_type, 293 | PlayerType::Suspicious, 294 | PlayerType::Suspicious.rich_text(), 295 | ) 296 | .clicked(); 297 | }); 298 | changed 299 | } 300 | 301 | /// Creates a dialog window where the user can edit and save the notes for a specific account 302 | pub fn create_edit_notes_window(mut record: PlayerRecord) -> PersistentWindow { 303 | PersistentWindow::new(Box::new(move |id, _, gui_ctx, state| { 304 | let mut open = true; 305 | let mut saved = false; 306 | egui::Window::new(format!("Edit notes for {}", &record.steamid)) 307 | .id(Id::new(id)) 308 | .open(&mut open) 309 | .show(gui_ctx, |ui| { 310 | ui.vertical_centered(|ui| { 311 | ui.text_edit_multiline(&mut record.notes); 312 | ui.horizontal(|ui| { 313 | if ui.button("Save").clicked() { 314 | saved = true; 315 | state.server.update_player_from_record(record.clone()); 316 | state.player_checker.update_player_record(record.clone()); 317 | } 318 | }); 319 | }); 320 | }); 321 | open & !saved 322 | })) 323 | } 324 | -------------------------------------------------------------------------------- /src/gui/regex_windows.rs: -------------------------------------------------------------------------------- 1 | use egui::Id; 2 | use regex::Regex; 3 | use wgpu_app::utils::persistent_window::PersistentWindow; 4 | 5 | use crate::state::State; 6 | 7 | use super::create_dialog_box; 8 | 9 | pub fn new_regex_window(mut regex: String) -> PersistentWindow { 10 | PersistentWindow::new(Box::new(move |id, windows, gui_ctx, state| { 11 | let mut open = true; 12 | 13 | let mut exported = false; 14 | egui::Window::new("New Regex") 15 | .id(Id::new(id)) 16 | .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) 17 | .open(&mut open) 18 | .collapsible(false) 19 | .show(gui_ctx, |ui| { 20 | ui.vertical_centered(|ui| { 21 | ui.text_edit_singleline(&mut regex); 22 | if ui.button("Confirm").clicked() { 23 | let reg = Regex::new(®ex); 24 | match reg { 25 | Ok(reg) => { 26 | state.player_checker.bots_regx.push(reg); 27 | exported = true; 28 | } 29 | Err(e) => { 30 | windows.push(create_dialog_box( 31 | "Invalid Regex".to_string(), 32 | format!("{}", e), 33 | )); 34 | } 35 | } 36 | } 37 | }); 38 | }); 39 | 40 | open & !exported 41 | })) 42 | } 43 | 44 | pub fn view_regexes_window() -> PersistentWindow { 45 | enum Action { 46 | Delete(usize), 47 | Edit(usize), 48 | } 49 | 50 | PersistentWindow::new(Box::new(move |id, windows, gui_ctx, state| { 51 | let mut open = true; 52 | 53 | // Saved Regexes window 54 | egui::Window::new("Saved Regexes") 55 | .id(Id::new(id)) 56 | .collapsible(false) 57 | .open(&mut open) 58 | .show(gui_ctx, |ui| { 59 | let mut action: Option = None; 60 | ui.vertical_centered(|ui| { 61 | // Add new regex button 62 | if ui.button("Add Regex").clicked() { 63 | windows.push(new_regex_window(String::new())); 64 | } 65 | ui.separator(); 66 | 67 | // List of regexes 68 | egui::ScrollArea::vertical().show(ui, |ui| { 69 | for (i, regex) in state.player_checker.bots_regx.iter().enumerate().rev() { 70 | ui.horizontal(|ui| { 71 | ui.label(regex.as_str()); 72 | 73 | ui.with_layout( 74 | egui::Layout::right_to_left(egui::Align::TOP), 75 | |ui| { 76 | if ui.button("Delete").clicked() { 77 | action = Some(Action::Delete(i)); 78 | } 79 | if ui.button("Edit").clicked() { 80 | action = Some(Action::Edit(i)); 81 | } 82 | }, 83 | ); 84 | }); 85 | } 86 | }); 87 | 88 | // Delete or edit regex 89 | if let Some(Action::Delete(i)) = action { 90 | state.player_checker.bots_regx.remove(i); 91 | } 92 | if let Some(Action::Edit(i)) = action { 93 | windows.push(edit_regex_window( 94 | state.player_checker.bots_regx[i].to_string(), 95 | i, 96 | state.player_checker.bots_regx.len(), 97 | )); 98 | } 99 | }); 100 | }); 101 | 102 | open 103 | })) 104 | } 105 | 106 | pub fn edit_regex_window(mut regex: String, i: usize, len: usize) -> PersistentWindow { 107 | PersistentWindow::new(Box::new(move |id, windows, gui_ctx, state| { 108 | let mut open = true; 109 | let mut saved = false; 110 | 111 | egui::Window::new("Edit regex") 112 | .id(Id::new(id)) 113 | .open(&mut open) 114 | .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) 115 | .collapsible(false) 116 | .show(gui_ctx, |ui| { 117 | if i >= len || len != state.player_checker.bots_regx.len() { 118 | saved = true; 119 | return; 120 | } 121 | 122 | ui.vertical_centered(|ui| { 123 | ui.text_edit_singleline(&mut regex); 124 | // Attempt to Save regex 125 | if ui.button("Save").clicked() { 126 | match Regex::new(®ex) { 127 | Ok(mut r) => { 128 | saved = true; 129 | std::mem::swap(&mut r, &mut state.player_checker.bots_regx[i]); 130 | } 131 | Err(e) => { 132 | windows.push(create_dialog_box( 133 | "Invalid Regex".to_string(), 134 | format!("{}", e), 135 | )); 136 | } 137 | } 138 | } 139 | }); 140 | }); 141 | 142 | open & !saved 143 | })) 144 | } 145 | -------------------------------------------------------------------------------- /src/io.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use crossbeam_channel::Receiver; 4 | use crossbeam_channel::RecvTimeoutError; 5 | use crossbeam_channel::Sender; 6 | use regex::Regex; 7 | 8 | use regexes::LobbyLine; 9 | use regexes::StatusLine; 10 | use regexes::REGEX_LOBBY; 11 | use regexes::REGEX_STATUS; 12 | 13 | use crate::settings; 14 | use crate::settings::Settings; 15 | 16 | use self::command_manager::CommandManager; 17 | use self::logwatcher::LogWatcher; 18 | use self::regexes::ChatMessage; 19 | use self::regexes::PlayerKill; 20 | use self::regexes::REGEX_CHAT; 21 | use self::regexes::REGEX_KILL; 22 | 23 | pub mod command_manager; 24 | pub mod logwatcher; 25 | pub mod regexes; 26 | 27 | /// Holds stuff to communicate with the IO thread, send [IORequest]s via the IOManager to do things like run commands in the game, etc 28 | pub struct IOManager { 29 | sender: Sender, 30 | receiver: Receiver, 31 | } 32 | 33 | struct IOThread { 34 | sender: Sender, 35 | receiver: Receiver, 36 | 37 | command_manager: CommandManager, 38 | log_watcher: Option, 39 | 40 | tf2_directory: String, 41 | 42 | regex_status: Regex, 43 | regex_lobby: Regex, 44 | regex_chat: Regex, 45 | regex_kill: Regex, 46 | } 47 | 48 | /// Request an action to be done on the IO thread, such as update state, run a command in-game, etc 49 | pub enum IORequest { 50 | UpdateDirectory(String), 51 | UpdateRconPassword(String), 52 | RunCommand(String), 53 | } 54 | 55 | /// A message from the IO thread that something has happened, like a status output which needs to be handled. 56 | pub enum IOResponse { 57 | NoLogFile(std::io::Error), 58 | LogFileOpened, 59 | NoRCON(rcon::Error), 60 | RCONConnected, 61 | Status(StatusLine), 62 | Lobby(LobbyLine), 63 | Chat(ChatMessage), 64 | Kill(PlayerKill), 65 | } 66 | 67 | impl IOManager { 68 | /// Start a new thread for IO and return a Manager containing message channels for it. 69 | pub fn start(settings: &Settings) -> IOManager { 70 | let (msend, trecv) = crossbeam_channel::unbounded(); 71 | let (tsend, mrecv) = crossbeam_channel::unbounded(); 72 | log::debug!("Spawning IO thread"); 73 | 74 | let dir = settings.tf2_directory.clone(); 75 | let pwd = settings.rcon_password.clone(); 76 | 77 | // Thread to do stuff on 78 | std::thread::spawn(move || { 79 | log::debug!("IO Thread running"); 80 | let mut io = IOThread::new(tsend, trecv, dir, pwd); 81 | 82 | io.reopen_log(); 83 | io.reconnect_rcon(); 84 | 85 | loop { 86 | io.handle_messages(); 87 | io.handle_log(); 88 | } 89 | }); 90 | 91 | IOManager { 92 | sender: msend, 93 | receiver: mrecv, 94 | } 95 | } 96 | 97 | /// Send a message to the IO thread 98 | pub fn send(&mut self, msg: IORequest) { 99 | self.sender.send(msg).expect("Sending message to IO thread"); 100 | } 101 | 102 | /// Receive a message from the IO thread, returns none if there are no messages waiting. 103 | pub fn recv(&mut self) -> Option { 104 | match self.receiver.try_recv() { 105 | Ok(resp) => Some(resp), 106 | Err(crossbeam_channel::TryRecvError::Empty) => None, 107 | Err(_) => panic!("Lost connection to IO thread"), 108 | } 109 | } 110 | } 111 | 112 | impl IOThread { 113 | fn new( 114 | send: Sender, 115 | recv: Receiver, 116 | directory: String, 117 | password: String, 118 | ) -> IOThread { 119 | let command_manager = CommandManager::new(password); 120 | 121 | IOThread { 122 | sender: send, 123 | receiver: recv, 124 | command_manager, 125 | log_watcher: None, 126 | tf2_directory: directory, 127 | 128 | regex_status: Regex::new(REGEX_STATUS).unwrap(), 129 | regex_lobby: Regex::new(REGEX_LOBBY).unwrap(), 130 | regex_chat: Regex::new(REGEX_CHAT).unwrap(), 131 | regex_kill: Regex::new(REGEX_KILL).unwrap(), 132 | } 133 | } 134 | 135 | /// Deal with all of the queued messages 136 | fn handle_messages(&mut self) { 137 | loop { 138 | match self.next_message() { 139 | None => { 140 | break; 141 | } 142 | Some(IORequest::UpdateDirectory(dir)) => { 143 | self.tf2_directory = dir; 144 | self.reopen_log(); 145 | } 146 | Some(IORequest::UpdateRconPassword(pwd)) => { 147 | if let Err(e) = self.command_manager.set_password(pwd) { 148 | self.send_message(IOResponse::NoRCON(e)); 149 | } 150 | } 151 | Some(IORequest::RunCommand(cmd)) => self.handle_command(&cmd), 152 | } 153 | } 154 | } 155 | 156 | /// Parse all of the new log entries that have been written 157 | fn handle_log(&mut self) { 158 | if self.log_watcher.as_ref().is_none() { 159 | return; 160 | } 161 | 162 | while let Some(line) = self.log_watcher.as_mut().unwrap().next_line() { 163 | // Match status 164 | if let Some(caps) = self.regex_status.captures(&line) { 165 | let status_line = StatusLine::parse(caps); 166 | self.send_message(IOResponse::Status(status_line)); 167 | continue; 168 | } 169 | // Match chat message 170 | if let Some(caps) = self.regex_chat.captures(&line) { 171 | let chat = ChatMessage::parse(caps); 172 | self.send_message(IOResponse::Chat(chat)); 173 | continue; 174 | } 175 | // Match player kills 176 | if let Some(caps) = self.regex_kill.captures(&line) { 177 | let kill = PlayerKill::parse(caps); 178 | self.send_message(IOResponse::Kill(kill)); 179 | continue; 180 | } 181 | } 182 | } 183 | 184 | /// Attempt to reopen the log file with the currently set directory. 185 | /// If the log file fails to be opened, an [IOResponse::NoLogFile] is sent back to the main thread and [Self::log_watcher] is set to [None] 186 | fn reopen_log(&mut self) { 187 | match LogWatcher::use_directory(&self.tf2_directory) { 188 | Ok(lw) => { 189 | log::debug!("Successfully opened log file"); 190 | self.log_watcher = Some(lw); 191 | } 192 | Err(e) => { 193 | log::error!("Failed to open log file: {:?}", e); 194 | self.send_message(IOResponse::NoLogFile(e)); 195 | } 196 | } 197 | } 198 | 199 | /// Attempt to reconnect to TF2 rcon is it's currently disconnected 200 | fn reconnect_rcon(&mut self) -> bool { 201 | if self.command_manager.is_connected() { 202 | self.send_message(IOResponse::RCONConnected); 203 | return true; 204 | } 205 | 206 | if let Err(e) = self.command_manager.try_connect() { 207 | self.send_message(IOResponse::NoRCON(e)); 208 | return false; 209 | } 210 | 211 | self.send_message(IOResponse::RCONConnected); 212 | true 213 | } 214 | 215 | /// Run a command and handle the response from it 216 | fn handle_command(&mut self, command: &str) { 217 | match self.command_manager.run_command(command) { 218 | Err(e) => { 219 | self.send_message(IOResponse::NoRCON(e)); 220 | } 221 | Ok(resp) => { 222 | self.send_message(IOResponse::RCONConnected); 223 | for l in resp.lines() { 224 | // Match lobby command 225 | if let Some(caps) = self.regex_lobby.captures(l) { 226 | let lobby_line = LobbyLine::parse(&caps); 227 | self.send_message(IOResponse::Lobby(lobby_line)); 228 | continue; 229 | } 230 | } 231 | } 232 | } 233 | } 234 | 235 | /// Get the next queued message or None. 236 | fn next_message(&mut self) -> Option { 237 | match self.receiver.recv_timeout(Duration::from_millis(50)) { 238 | Ok(request) => Some(request), 239 | Err(RecvTimeoutError::Timeout) => None, 240 | Err(RecvTimeoutError::Disconnected) => { 241 | panic!("Lost connection to main thread, shutting down.") 242 | } 243 | } 244 | } 245 | 246 | /// Send a message back to the main thread 247 | fn send_message(&mut self, msg: IOResponse) { 248 | if let Err(e) = self.sender.send(msg) { 249 | panic!("Failed to talk to main thread: {:?}", e); 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/io/command_manager.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use rcon::Connection; 4 | use tokio::{net::TcpStream, runtime::Runtime}; 5 | 6 | #[derive(Debug)] 7 | pub enum KickReason { 8 | None, 9 | Idle, 10 | Cheating, 11 | Scamming, 12 | } 13 | 14 | impl Display for KickReason { 15 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 16 | f.write_str(match self { 17 | KickReason::None => "other", 18 | KickReason::Idle => "idle", 19 | KickReason::Cheating => "cheating", 20 | KickReason::Scamming => "scamming", 21 | }) 22 | } 23 | } 24 | 25 | pub struct CommandManager { 26 | runtime: Runtime, 27 | rcon: Option>, 28 | password: String, 29 | } 30 | 31 | pub const CMD_STATUS: &str = "status"; 32 | pub const CMD_TF_LOBBY_DEBUG: &str = "tf_lobby_debug"; 33 | 34 | impl CommandManager { 35 | pub fn new(password: String) -> CommandManager { 36 | let runtime = Runtime::new().unwrap(); 37 | CommandManager { 38 | runtime, 39 | rcon: None, 40 | password, 41 | } 42 | } 43 | 44 | pub fn set_password(&mut self, password: String) -> rcon::Result<()> { 45 | self.password = password; 46 | self.try_connect() 47 | } 48 | 49 | pub fn is_connected(&self) -> bool { 50 | self.rcon.is_some() 51 | } 52 | 53 | pub fn try_connect(&mut self) -> Result<(), rcon::Error> { 54 | log::debug!("Attempting to reconnect to RCon"); 55 | let mut connected = Ok(()); 56 | self.runtime.block_on(async { 57 | match Connection::connect("127.0.0.1:27015", &self.password).await { 58 | Ok(con) => { 59 | self.rcon = Some(con); 60 | } 61 | Err(e) => { 62 | self.rcon = None; 63 | connected = Err(e); 64 | } 65 | } 66 | }); 67 | 68 | match &connected { 69 | Ok(_) => log::debug!("RCon successfully connected"), 70 | Err(e) => log::debug!("RCon failed to connect: {:?}", e), 71 | } 72 | 73 | connected 74 | } 75 | 76 | pub fn run_command(&mut self, command: &str) -> rcon::Result { 77 | let mut out = None; 78 | if self.rcon.is_none() { 79 | self.try_connect()?; 80 | } 81 | 82 | log::debug!("Running command \"{}\"", command); 83 | self.runtime.block_on(async { 84 | if let Some(rcon) = &mut self.rcon { 85 | out = Some(rcon.cmd(command).await); 86 | } 87 | }); 88 | 89 | if out.as_ref().unwrap().is_err() { 90 | self.rcon = None; 91 | } 92 | 93 | out.unwrap() 94 | } 95 | 96 | pub fn kick_player_command(player_userid: &str, reason: KickReason) -> String { 97 | format!("callvote kick \"{} {}\"", player_userid, reason) 98 | } 99 | 100 | pub fn send_chat_command(message: &str) -> String { 101 | format!("say \"{}\"", message) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/io/logwatcher.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io; 3 | use std::io::prelude::*; 4 | use std::io::BufReader; 5 | use std::io::SeekFrom; 6 | use std::time::SystemTime; 7 | 8 | pub struct LogWatcher { 9 | // created: SystemTime, 10 | filename: String, 11 | pos: u64, 12 | reader: BufReader, 13 | last_activity: SystemTime, 14 | } 15 | 16 | impl LogWatcher { 17 | // Try to open this TF2 directory 18 | pub fn use_directory(dir: &str) -> Result { 19 | LogWatcher::register(&format!("{}/tf/console.log", dir)) 20 | } 21 | 22 | /// Internally called by [use_directory] 23 | pub fn register(filename: &str) -> Result { 24 | let f = match File::open(filename) { 25 | Ok(x) => { 26 | log::debug!("Successfully opened file {}", filename); 27 | x 28 | } 29 | Err(err) => { 30 | log::error!("Failed to open file {}: {}", filename, err); 31 | return Err(err); 32 | } 33 | }; 34 | 35 | let metadata = match f.metadata() { 36 | Ok(x) => x, 37 | Err(err) => { 38 | log::error!("Failed to get file metadata: {}", err); 39 | return Err(err); 40 | } 41 | }; 42 | 43 | let mut reader = BufReader::new(f); 44 | let pos = metadata.len(); 45 | if let Err(e) = reader.seek(SeekFrom::Start(pos)) { 46 | log::error!("Failed to seek in file: {}", e); 47 | } 48 | Ok(LogWatcher { 49 | filename: filename.to_string(), 50 | pos, 51 | reader, 52 | last_activity: SystemTime::now(), 53 | }) 54 | } 55 | 56 | pub fn next_line(&mut self) -> Option { 57 | let mut line = String::new(); 58 | let resp = self.reader.read_line(&mut line); 59 | 60 | match resp { 61 | Ok(len) => { 62 | // Get next line 63 | if len > 0 { 64 | self.pos += len as u64; 65 | self.reader.seek(SeekFrom::Start(self.pos)).unwrap(); 66 | self.last_activity = SystemTime::now(); 67 | return Some(line.replace('\n', "")); 68 | } 69 | 70 | // Check if file has been shortened 71 | if self.reader.get_ref().metadata().unwrap().len() < self.pos { 72 | log::warn!("Console.log file was reset"); 73 | self.pos = self.reader.get_ref().metadata().unwrap().len(); 74 | self.last_activity = SystemTime::now(); 75 | } 76 | 77 | // Reopen the log file if nothing has happened for long enough in case the file has been replaced. 78 | let time = SystemTime::now().duration_since(self.last_activity); 79 | if time.unwrap().as_secs() > 10 { 80 | let f = match File::open(&self.filename) { 81 | Ok(x) => x, 82 | Err(_) => return None, 83 | }; 84 | 85 | let metadata = match f.metadata() { 86 | Ok(x) => x, 87 | Err(_) => return None, 88 | }; 89 | 90 | let mut reader = BufReader::new(f); 91 | let pos = metadata.len(); 92 | reader.seek(SeekFrom::Start(pos)).unwrap(); 93 | 94 | self.pos = pos; 95 | self.reader = reader; 96 | self.last_activity = SystemTime::now(); 97 | return None; 98 | } 99 | 100 | self.reader.seek(SeekFrom::Start(self.pos)).unwrap(); 101 | return None; 102 | } 103 | Err(err) => { 104 | log::error!("Logwatcher error: {}", err); 105 | } 106 | } 107 | 108 | None 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/io/regexes.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_upper_case_globals)] 2 | #![allow(unused_variables)] 3 | 4 | use regex::{Captures, Regex}; 5 | 6 | use crate::{ 7 | player_checker::PlayerChecker, 8 | server::{player::*, Server}, 9 | }; 10 | 11 | use super::{command_manager::CommandManager, settings::Settings}; 12 | 13 | pub struct LogMatcher { 14 | pub r: Regex, 15 | pub f: fn( 16 | serv: &mut Server, 17 | str: &str, 18 | caps: Captures, 19 | set: &Settings, 20 | bot_checker: &mut PlayerChecker, 21 | cmd: &mut CommandManager, 22 | ), 23 | } 24 | 25 | impl LogMatcher { 26 | pub fn new( 27 | r: Regex, 28 | f: fn( 29 | serv: &mut Server, 30 | str: &str, 31 | caps: Captures, 32 | set: &Settings, 33 | bot_checker: &mut PlayerChecker, 34 | cmd: &mut CommandManager, 35 | ), 36 | ) -> LogMatcher { 37 | LogMatcher { r, f } 38 | } 39 | } 40 | 41 | /* 42 | Useful commands: 43 | status 44 | tf_lobby_debug 45 | tf_party_debug //Not sure if this is actually useful, not really necessary 46 | 47 | callvote kick 48 | vote option<1/2> // Can't really use 49 | 50 | */ 51 | 52 | /// Player killed someone 53 | /// Matches: 54 | /// 0: Killer 55 | /// 1: Victim 56 | /// 2: Weapon 57 | /// 3: Crit? 58 | pub const REGEX_KILL: &str = r#"^(.*)\skilled\s(.*)\swith\s(.*)\.(\s\(crit\))?$"#; 59 | pub struct PlayerKill { 60 | pub killer_name: String, 61 | pub killer_steamid: Option, 62 | pub victim_name: String, 63 | pub victim_steamid: Option, 64 | pub weapon: String, 65 | pub crit: bool, 66 | } 67 | 68 | impl PlayerKill { 69 | pub fn parse(caps: Captures) -> PlayerKill { 70 | PlayerKill { 71 | killer_name: caps[1].to_string(), 72 | killer_steamid: None, 73 | victim_name: caps[2].to_string(), 74 | victim_steamid: None, 75 | weapon: caps[3].to_string(), 76 | crit: caps.get(4).is_some(), 77 | } 78 | } 79 | } 80 | 81 | /// Chat message 82 | /// Matches: 83 | /// 0: Player 84 | /// 1: Message 85 | pub const REGEX_CHAT: &str = r#"^(?:\*DEAD\*)?(?:\(TEAM\))?\s?(.*)\s:\s\s(.*)$"#; 86 | pub struct ChatMessage { 87 | pub player_name: String, 88 | pub steamid: Option, 89 | pub message: String, 90 | } 91 | 92 | impl ChatMessage { 93 | pub fn parse(caps: Captures) -> ChatMessage { 94 | ChatMessage { 95 | player_name: caps[1].to_string(), 96 | steamid: None, 97 | message: caps[2].to_string(), 98 | } 99 | } 100 | } 101 | 102 | // Reads lines from output of the "status" command 103 | // Includes players on server, player name, state, steamid, time connected 104 | // If no player exists on the server with a steamid from here, it creates a new player and adds it to the list 105 | pub const REGEX_STATUS: &str = 106 | r#"^#\s*(\d+)\s"(.*)"\s+\[(U:\d:\d+)\]\s+(\d*:?\d\d:\d\d)\s+\d+\s*\d+\s*(\w+).*$"#; 107 | 108 | pub struct StatusLine { 109 | pub userid: String, 110 | pub name: String, 111 | pub steamid: String, 112 | pub time: u32, 113 | pub state: PlayerState, 114 | } 115 | 116 | impl StatusLine { 117 | pub fn parse(caps: Captures) -> StatusLine { 118 | let mut player_state = PlayerState::Spawning; 119 | if caps[5].eq("active") { 120 | player_state = PlayerState::Active; 121 | } 122 | 123 | StatusLine { 124 | userid: caps[1].to_string(), 125 | name: caps[2].to_string(), 126 | steamid: caps[3].to_string(), 127 | time: get_time(&caps[4]).unwrap_or(0), 128 | state: player_state, 129 | } 130 | } 131 | } 132 | 133 | // Converts a given string time (e.g. 57:48 or 1:14:46) as an integer number of seconds 134 | fn get_time(input: &str) -> Option { 135 | let mut t: u32 = 0; 136 | 137 | let splits: Vec<&str> = input.split(':').collect(); 138 | let n = splits.len(); 139 | 140 | for (i, v) in splits.iter().enumerate() { 141 | // let dt: u32 = v.parse::().expect(&format!("Had trouble parsing {} as u32", v)); 142 | let dt = v.parse::(); 143 | 144 | if dt.is_err() { 145 | return None; 146 | } 147 | 148 | t += 60u32.pow((n - i - 1) as u32) * dt.unwrap(); 149 | } 150 | 151 | Some(t) 152 | } 153 | 154 | // Reads lines from output of the "tf_lobby_debug" command 155 | // Includes the team of players on the server 156 | // NOTE: Teams are stored as INVADERS/DEFENDERS and does not swap when Red/Blu swaps so it cannot 157 | // be used to reliably check which team the user is on, it can only check relative to the user (same/opposite team) 158 | pub const REGEX_LOBBY: &str = 159 | r#"^ Member\[(\d+)] \[(U:\d:\d+)] team = TF_GC_TEAM_(\w+) type = MATCH_PLAYER\s*$"#; 160 | 161 | pub struct LobbyLine { 162 | pub steamid: String, 163 | pub team: Team, 164 | } 165 | 166 | impl LobbyLine { 167 | pub fn parse(caps: &Captures) -> LobbyLine { 168 | let mut team = Team::None; 169 | match &caps[3] { 170 | "INVADERS" => team = Team::Invaders, 171 | "DEFENDERS" => team = Team::Defenders, 172 | _ => {} 173 | } 174 | 175 | LobbyLine { 176 | steamid: caps[2].to_string(), 177 | team, 178 | } 179 | } 180 | } 181 | 182 | const INVIS_CHARS: &[char] = &[ 183 | '\u{00a0}', 184 | '\u{00ad}', 185 | '\u{034f}', 186 | '\u{061c}', 187 | '\u{115f}', 188 | '\u{1160}', 189 | '\u{17b4}', 190 | '\u{17b5}', 191 | '\u{180e}', 192 | '\u{2000}', 193 | '\u{2001}', 194 | '\u{2002}', 195 | '\u{2003}', 196 | '\u{2004}', 197 | '\u{2005}', 198 | '\u{2006}', 199 | '\u{2007}', 200 | '\u{2008}', 201 | '\u{2009}', 202 | '\u{200a}', 203 | '\u{200b}', 204 | '\u{200c}', 205 | '\u{200d}', 206 | '\u{200e}', 207 | '\u{200f}', 208 | '\u{202f}', 209 | '\u{205f}', 210 | '\u{2060}', 211 | '\u{2061}', 212 | '\u{2062}', 213 | '\u{2063}', 214 | '\u{2064}', 215 | '\u{206a}', 216 | '\u{206b}', 217 | '\u{206c}', 218 | '\u{206d}', 219 | '\u{206e}', 220 | '\u{206f}', 221 | '\u{3000}', 222 | '\u{2800}', 223 | '\u{3164}', 224 | '\u{feff}', 225 | '\u{ffa0}', 226 | '\u{1d159}', 227 | '\u{1d173}', 228 | '\u{1d174}', 229 | '\u{1d175}', 230 | '\u{1d176}', 231 | '\u{1d177}', 232 | '\u{1d178}', 233 | '\u{1d179}', 234 | '\u{1d17a}', 235 | ]; 236 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![feature(hash_extract_if)] 2 | 3 | extern crate chrono; 4 | extern crate env_logger; 5 | extern crate rfd; 6 | extern crate serde; 7 | extern crate steam_api; 8 | 9 | pub mod gui; 10 | pub mod io; 11 | pub mod player_checker; 12 | pub mod ringbuffer; 13 | pub mod server; 14 | pub mod settings; 15 | pub mod state; 16 | pub mod steamapi; 17 | pub mod timer; 18 | pub mod version; 19 | 20 | use chrono::{DateTime, Local}; 21 | use crossbeam_channel::TryRecvError; 22 | use egui::{Align2, Vec2}; 23 | use egui_dock::{DockArea, Tree}; 24 | use egui_winit::{ 25 | egui, 26 | winit::{ 27 | dpi::{PhysicalPosition, PhysicalSize}, 28 | window::{Icon, WindowBuilder}, 29 | }, 30 | }; 31 | use gui::GuiTab; 32 | use image::{EncodableLayout, ImageFormat}; 33 | 34 | use player_checker::{PLAYER_LIST, REGEX_LIST}; 35 | use server::{player::PlayerType, *}; 36 | use state::State; 37 | use std::{io::Cursor, time::SystemTime}; 38 | use version::VersionResponse; 39 | use wgpu_app::utils::persistent_window::{PersistentWindow, PersistentWindowManager}; 40 | 41 | fn main() { 42 | env_logger::Builder::from_default_env() 43 | .filter_module("wgpu_core", log::LevelFilter::Warn) 44 | .filter_module("wgpu_hal", log::LevelFilter::Warn) 45 | .filter_module("naga::front", log::LevelFilter::Warn) 46 | .filter_module("naga", log::LevelFilter::Warn) 47 | .init(); 48 | 49 | let app = TF2BotKicker::new(); 50 | 51 | let inner_size = PhysicalSize::new( 52 | app.state.settings.window.width, 53 | app.state.settings.window.height, 54 | ); 55 | let outer_pos = PhysicalPosition::new(app.state.settings.window.x, app.state.settings.window.y); 56 | 57 | let mut logo = image::io::Reader::new(Cursor::new(include_bytes!("../images/logo.png"))); 58 | logo.set_format(ImageFormat::Png); 59 | 60 | let wb = WindowBuilder::new() 61 | .with_window_icon(Some( 62 | Icon::from_rgba( 63 | logo.decode().unwrap().into_rgba8().as_bytes().to_vec(), 64 | 512, 65 | 512, 66 | ) 67 | .unwrap(), 68 | )) 69 | .with_title("TF2 Bot Kicker by Bash09") 70 | .with_resizable(true) 71 | .with_inner_size(inner_size) 72 | .with_position(outer_pos); 73 | wgpu_app::run(app, wb); 74 | } 75 | 76 | pub struct TF2BotKicker { 77 | state: State, 78 | windows: PersistentWindowManager, 79 | gui_tree: Tree, 80 | } 81 | 82 | impl Default for TF2BotKicker { 83 | fn default() -> Self { 84 | Self::new() 85 | } 86 | } 87 | 88 | impl TF2BotKicker { 89 | // Create the application 90 | pub fn new() -> TF2BotKicker { 91 | let state = State::new(); 92 | let gui_tree = state.settings.saved_dock.clone(); 93 | 94 | Self { 95 | state, 96 | windows: PersistentWindowManager::new(), 97 | gui_tree, 98 | } 99 | } 100 | } 101 | 102 | impl wgpu_app::Application for TF2BotKicker { 103 | fn init(&mut self, _ctx: &mut wgpu_app::context::Context) { 104 | self.state.refresh_timer.reset(); 105 | self.state.kick_timer.reset(); 106 | self.state.alert_timer.reset(); 107 | 108 | self.state.latest_version = Some(VersionResponse::request_latest_version()); 109 | if !self.state.settings.ignore_no_api_key && self.state.settings.steamapi_key.is_empty() { 110 | self.windows 111 | .push(steamapi::create_set_api_key_window(String::new())); 112 | } 113 | 114 | // Try to run TF2 if set to 115 | if self.state.settings.launch_tf2 { 116 | #[cfg(target_os = "windows")] 117 | let command = "C:/Program Files (x86)/Steam/steam.exe"; 118 | #[cfg(not(target_os = "windows"))] 119 | let command = "steam"; 120 | 121 | if let Err(e) = std::process::Command::new(command) 122 | .arg("steam://rungameid/440") 123 | .spawn() 124 | { 125 | self.windows 126 | .push(PersistentWindow::new(Box::new(move |id, _, ctx, _| { 127 | let mut open = true; 128 | egui::Window::new("Failed to launch TF2") 129 | .id(egui::Id::new(id)) 130 | .open(&mut open) 131 | .show(ctx, |ui| { 132 | ui.label(&format!("{:?}", e)); 133 | }); 134 | open 135 | }))); 136 | } 137 | } 138 | } 139 | 140 | fn update( 141 | &mut self, 142 | _t: &wgpu_app::Timer, 143 | ctx: &mut wgpu_app::context::Context, 144 | ) -> Result<(), wgpu::SurfaceError> { 145 | let TF2BotKicker { 146 | state, 147 | windows, 148 | gui_tree, 149 | } = self; 150 | 151 | // Check latest version 152 | if let Some(latest) = &mut state.latest_version { 153 | match latest.try_recv() { 154 | Ok(Ok(latest)) => { 155 | log::debug!( 156 | "Got latest version of application, current: {}, latest: {}", 157 | version::VERSION, 158 | latest.version 159 | ); 160 | 161 | if latest.version != version::VERSION 162 | && (latest.version != state.settings.ignore_version 163 | || state.force_latest_version) 164 | { 165 | windows.push(latest.to_persistent_window()); 166 | state.force_latest_version = false; 167 | } else if state.force_latest_version { 168 | windows.push(PersistentWindow::new(Box::new(|_, _, ctx, _| { 169 | let mut open = true; 170 | egui::Window::new("No updates available") 171 | .collapsible(false) 172 | .resizable(false) 173 | .open(&mut open) 174 | .anchor(Align2::CENTER_CENTER, Vec2::new(0.0, 0.0)) 175 | .show(ctx, |ui| { 176 | ui.label("You already have the latest version."); 177 | }); 178 | open 179 | }))); 180 | } 181 | 182 | state.latest_version = None; 183 | } 184 | Ok(Err(e)) => { 185 | log::error!("Error getting latest version: {:?}", e); 186 | state.latest_version = None; 187 | } 188 | Err(TryRecvError::Disconnected) => { 189 | log::error!("Error getting latest version, other thread did not respond"); 190 | state.latest_version = None; 191 | } 192 | Err(TryRecvError::Empty) => {} 193 | } 194 | } 195 | 196 | // Handle incoming messages from IO thread 197 | state.handle_messages(); 198 | 199 | // Send steamid requests if an API key is set 200 | if state.settings.steamapi_key.is_empty() { 201 | state.server.pending_lookup.clear(); 202 | } 203 | while let Some(steamid64) = state.server.pending_lookup.pop() { 204 | state.steamapi_request_sender.send(steamid64).ok(); 205 | } 206 | 207 | // Handle finished steamid requests 208 | while let Ok((info, img, steamid)) = state.steamapi_request_receiver.try_recv() { 209 | if let Some(p) = state 210 | .server 211 | .get_player_mut(&player::steamid_64_to_32(&steamid).unwrap_or_default()) 212 | { 213 | p.account_info = info; 214 | p.profile_image = img; 215 | } 216 | } 217 | 218 | let refresh = state.refresh_timer.go(state.settings.refresh_period); 219 | 220 | if refresh.is_none() { 221 | return Ok(()); 222 | } 223 | 224 | state.kick_timer.go(state.settings.kick_period); 225 | state.alert_timer.go(state.settings.alert_period); 226 | 227 | // Refresh server 228 | if state.refresh_timer.update() { 229 | state.refresh(); 230 | 231 | // Close if TF2 has been closed and we want to close now 232 | if state.has_connected() 233 | && !state.is_connected().is_ok() 234 | && state.settings.close_on_disconnect 235 | { 236 | log::debug!("Lost connection from TF2, closing program."); 237 | self.close(ctx); 238 | std::process::exit(0); 239 | } 240 | 241 | let system_time = SystemTime::now(); 242 | let datetime: DateTime = system_time.into(); 243 | log::debug!("{}", format!("Refreshed ({})", datetime.format("%T"))); 244 | } 245 | 246 | // Kick Bots and Cheaters 247 | if !state.settings.paused { 248 | if state.kick_timer.update() { 249 | if state.settings.kick_bots { 250 | log::debug!("Attempting to kick bots"); 251 | state.server.kick_players_of_type( 252 | &state.settings, 253 | &mut state.io, 254 | PlayerType::Bot, 255 | ); 256 | } 257 | 258 | if state.settings.kick_cheaters { 259 | log::debug!("Attempting to kick cheaters"); 260 | state.server.kick_players_of_type( 261 | &state.settings, 262 | &mut state.io, 263 | PlayerType::Cheater, 264 | ); 265 | } 266 | } 267 | 268 | if state.alert_timer.update() { 269 | state 270 | .server 271 | .send_chat_messages(&state.settings, &mut state.io); 272 | } 273 | } 274 | 275 | // Render *****************88 276 | let output = ctx.wgpu_state.surface.get_current_texture()?; 277 | ctx.egui.render(&mut ctx.wgpu_state, &output, |gui_ctx| { 278 | gui::render_top_panel(gui_ctx, state, gui_tree); 279 | DockArea::new(gui_tree).show(gui_ctx, state); 280 | 281 | // Get new persistent windows 282 | if !state.new_persistent_windows.is_empty() { 283 | let mut new_windows = Vec::new(); 284 | std::mem::swap(&mut new_windows, &mut state.new_persistent_windows); 285 | for w in new_windows { 286 | windows.push(w); 287 | } 288 | } 289 | windows.render(state, gui_ctx); 290 | }); 291 | output.present(); 292 | 293 | Ok(()) 294 | } 295 | 296 | fn close(&mut self, ctx: &wgpu_app::context::Context) { 297 | if let Err(e) = self.state.player_checker.save_players(PLAYER_LIST) { 298 | log::error!("Failed to save players: {:?}", e); 299 | } 300 | if let Err(e) = self.state.player_checker.save_regex(REGEX_LIST) { 301 | log::error!("Failed to save regexes: {:?}", e); 302 | } 303 | 304 | let size = ctx.wgpu_state.window.inner_size(); 305 | let position = ctx.wgpu_state.window.outer_position(); 306 | 307 | let settings = &mut self.state.settings; 308 | settings.window.width = size.width; 309 | settings.window.height = size.height; 310 | if let Ok(pos) = position { 311 | settings.window.x = pos.x; 312 | settings.window.y = pos.y; 313 | } 314 | settings.saved_dock = self.gui_tree.clone(); 315 | 316 | if let Err(e) = settings.export() { 317 | log::error!("Failed to save settings: {:?}", e); 318 | } 319 | } 320 | 321 | fn handle_event( 322 | &mut self, 323 | _: &mut wgpu_app::context::Context, 324 | _: &egui_winit::winit::event::Event<()>, 325 | ) { 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /src/player_checker.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use std::collections::HashMap; 4 | use std::error::Error; 5 | use std::fs::{File, OpenOptions}; 6 | use std::io::{LineWriter, Read, Write}; 7 | use std::path::Path; 8 | 9 | use regex::Regex; 10 | use serde::Serialize; 11 | use serde_json::Value; 12 | 13 | use crate::server::player::{PlayerType, Steamid32}; 14 | 15 | use super::player::Player; 16 | 17 | pub const REGEX_LIST: &str = "cfg/regx.txt"; 18 | pub const PLAYER_LIST: &str = "cfg/playerlist.json"; 19 | 20 | #[derive(Debug, Serialize, Clone)] 21 | pub struct PlayerRecord { 22 | pub steamid: String, 23 | pub player_type: PlayerType, 24 | pub notes: String, 25 | } 26 | 27 | pub struct PlayerChecker { 28 | pub bots_regx: Vec, 29 | pub players: HashMap, 30 | } 31 | 32 | impl Default for PlayerChecker { 33 | fn default() -> Self { 34 | Self::new() 35 | } 36 | } 37 | 38 | impl PlayerChecker { 39 | pub fn new() -> PlayerChecker { 40 | PlayerChecker { 41 | bots_regx: Vec::new(), 42 | 43 | players: HashMap::new(), 44 | } 45 | } 46 | 47 | /// Marks a player as a bot based on their name compared to a list of regexes. 48 | /// If the name matches a bot regex the player will be marked as a bot and 49 | /// a note appended to them indicating the regex that caught them. 50 | /// 51 | /// Returns true if a regex was matched and false otherwise. 52 | pub fn check_player_name(&mut self, name: &str) -> Option<&Regex> { 53 | self.bots_regx 54 | .iter() 55 | .find(|®x| regx.captures(name).is_some()) 56 | } 57 | 58 | /// Loads a player's record from the persistent record if it exists and restores 59 | /// their data. e.g. marking the player as a bot or cheater or just 60 | pub fn check_player_steamid(&self, steamid: &Steamid32) -> Option { 61 | self.players.get(steamid).cloned() 62 | } 63 | 64 | /// Inserts the player into the saved record of players 65 | pub fn update_player(&mut self, player: &Player) { 66 | self.update_player_record(player.get_record()); 67 | } 68 | 69 | /// Inserts the player's record into the saved records 70 | pub fn update_player_record(&mut self, player: PlayerRecord) { 71 | if player.player_type == PlayerType::Player && player.notes.is_empty() { 72 | self.players.remove(&player.steamid); 73 | } else { 74 | self.players.insert(player.steamid.clone(), player); 75 | } 76 | } 77 | 78 | /// Import all players' steamID from the provided file as a particular player type 79 | pub fn read_from_steamid_list( 80 | &mut self, 81 | filename: &str, 82 | as_player_type: PlayerType, 83 | ) -> Result<(), std::io::Error> { 84 | let reg = Regex::new(r#"\[?(?PU:\d:\d+)\]?"#).unwrap(); 85 | 86 | let mut file = File::open(filename)?; 87 | 88 | let mut contents: String = String::new(); 89 | file.read_to_string(&mut contents)?; 90 | 91 | for m in reg.find_iter(&contents) { 92 | match reg.captures(m.as_str()) { 93 | None => {} 94 | Some(c) => { 95 | let steamid = c["uuid"].to_string(); 96 | 97 | if self.players.contains_key(&steamid) { 98 | continue; 99 | } else { 100 | let record = PlayerRecord { 101 | steamid, 102 | player_type: as_player_type, 103 | notes: format!("Imported from {} as {:?}", filename, as_player_type), 104 | }; 105 | self.players.insert(record.steamid.clone(), record); 106 | } 107 | } 108 | } 109 | } 110 | 111 | Ok(()) 112 | } 113 | 114 | /// Read a list of regexes to match bots against from a file 115 | pub fn read_regex_list>( 116 | &mut self, 117 | filename: P, 118 | ) -> Result<(), Box> { 119 | let mut list: Vec = Vec::new(); 120 | 121 | let mut file = File::open(filename)?; 122 | 123 | let mut contents: String = String::new(); 124 | file.read_to_string(&mut contents)?; 125 | 126 | for line in contents.lines() { 127 | let txt = line.trim(); 128 | if txt.is_empty() { 129 | continue; 130 | } 131 | match Regex::new(txt) { 132 | Ok(regx) => list.push(regx), 133 | Err(e) => { 134 | log::error!("Error reading regex: {}", e); 135 | } 136 | } 137 | } 138 | 139 | self.bots_regx.append(&mut list); 140 | Ok(()) 141 | } 142 | 143 | pub fn save_regex>(&self, file: P) -> std::io::Result<()> { 144 | let file = OpenOptions::new() 145 | .write(true) 146 | .append(false) 147 | .create(true) 148 | .open(file)?; 149 | let mut writer = LineWriter::new(file); 150 | for r in &self.bots_regx { 151 | writer.write_all(r.as_str().as_bytes())?; 152 | writer.write_all("\n".as_bytes())?; 153 | } 154 | writer.flush()?; 155 | 156 | Ok(()) 157 | } 158 | 159 | pub fn import_players(&mut self) -> Result<(), Box> { 160 | if let Some(pb) = rfd::FileDialog::new().pick_file() { 161 | self.read_players(pb)?; 162 | } 163 | 164 | Ok(()) 165 | } 166 | 167 | /// Save the current player record to a file 168 | pub fn save_players>(&self, file: P) -> std::io::Result<()> { 169 | let players: Vec<&PlayerRecord> = self.players.values().collect(); 170 | 171 | match serde_json::to_string(&players) { 172 | Ok(contents) => std::fs::write(file, contents)?, 173 | Err(e) => { 174 | log::error!("Failed to serialize players: {:?}", e); 175 | } 176 | } 177 | 178 | Ok(()) 179 | } 180 | 181 | pub fn read_players>(&mut self, file: P) -> Result<(), Box> { 182 | let contents = std::fs::read_to_string(file)?; 183 | let json: Value = serde_json::from_str(&contents)?; 184 | 185 | for p in json.as_array().unwrap_or(&vec![]) { 186 | let steamid = p["steamid"].as_str().unwrap_or(""); 187 | let player_type = p["player_type"].as_str().unwrap_or(""); 188 | let notes = p["notes"].as_str().unwrap_or(""); 189 | 190 | if steamid.is_empty() { 191 | continue; 192 | } 193 | let player_type = match player_type { 194 | "Player" => PlayerType::Player, 195 | "Bot" => PlayerType::Bot, 196 | "Cheater" => PlayerType::Cheater, 197 | "Suspicious" => PlayerType::Suspicious, 198 | _ => { 199 | log::error!("Unexpected playertype: {}", player_type); 200 | continue; 201 | } 202 | }; 203 | 204 | let record = PlayerRecord { 205 | steamid: steamid.to_string(), 206 | player_type, 207 | notes: notes.to_string(), 208 | }; 209 | 210 | self.players.insert(steamid.to_string(), record); 211 | } 212 | 213 | Ok(()) 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/ringbuffer.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | 3 | #[derive(Debug)] 4 | pub struct RingBuffer { 5 | capacity: usize, 6 | inner: VecDeque, 7 | } 8 | 9 | impl RingBuffer { 10 | pub fn new(capacity: usize) -> Self { 11 | Self { 12 | capacity, 13 | inner: VecDeque::new(), 14 | } 15 | } 16 | 17 | pub fn push(&mut self, item: T) { 18 | if self.inner.len() >= self.capacity { 19 | self.inner.pop_front(); 20 | self.inner.push_back(item); 21 | debug_assert!(self.inner.len() <= self.capacity); 22 | } else { 23 | self.inner.push_back(item); 24 | } 25 | } 26 | 27 | pub fn pop(&mut self) -> Option { 28 | self.inner.pop_front() 29 | } 30 | 31 | pub fn inner(&self) -> &VecDeque { 32 | &self.inner 33 | } 34 | 35 | pub fn inner_mut(&mut self) -> &mut VecDeque { 36 | &mut self.inner 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/server.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use std::collections::HashMap; 4 | 5 | pub mod player; 6 | use player::Player; 7 | use player::PlayerState; 8 | 9 | pub mod parties; 10 | use parties::Parties; 11 | 12 | use crate::io::command_manager::CommandManager; 13 | use crate::io::command_manager::KickReason; 14 | use crate::io::regexes::ChatMessage; 15 | use crate::io::regexes::PlayerKill; 16 | use crate::io::IOManager; 17 | use crate::io::IORequest; 18 | use crate::player_checker::PlayerRecord; 19 | use crate::ringbuffer::RingBuffer; 20 | 21 | use self::player::PlayerType; 22 | use self::player::Steamid32; 23 | use self::player::Team; 24 | 25 | use super::settings::Settings; 26 | 27 | pub const COM_STATUS: &str = "status"; 28 | pub const COM_LOBBY: &str = "tf_lobby_debug"; 29 | const RINGBUFFER_LEN: usize = 48; 30 | pub const ACCOUNTED_LIMIT: u8 = 2; 31 | 32 | pub struct Server { 33 | players: HashMap, 34 | chat: Vec, 35 | deathlog: Vec, 36 | pub new_connections: Vec, 37 | pub pending_lookup: Vec, 38 | previous_players: RingBuffer, 39 | parties: Parties, 40 | } 41 | 42 | impl Server { 43 | pub fn new() -> Server { 44 | Server { 45 | players: HashMap::with_capacity(24), 46 | chat: Vec::new(), 47 | deathlog: Vec::new(), 48 | new_connections: Vec::new(), 49 | pending_lookup: Vec::new(), 50 | previous_players: RingBuffer::new(RINGBUFFER_LEN), 51 | parties: Parties::new(), 52 | } 53 | } 54 | 55 | pub fn clear(&mut self) { 56 | let mut players: HashMap = HashMap::new(); 57 | std::mem::swap(&mut players, &mut self.players); 58 | 59 | 'outer: for p in players.into_values() { 60 | for prev in self.previous_players.inner() { 61 | if p.steamid32 == prev.steamid32 { 62 | continue 'outer; 63 | } 64 | } 65 | self.previous_players.push(p); 66 | } 67 | 68 | self.new_connections.clear(); 69 | self.parties.clear(); 70 | } 71 | 72 | pub fn get_players(&self) -> &HashMap { 73 | &self.players 74 | } 75 | 76 | pub fn get_previous_players(&self) -> &RingBuffer { 77 | &self.previous_players 78 | } 79 | 80 | pub fn get_player_party_color(&self, p: &Player) -> Option { 81 | self.parties.get_player_party_color(p) 82 | } 83 | 84 | pub fn get_player_mut(&mut self, steamid: &Steamid32) -> Option<&mut Player> { 85 | self.players.get_mut(steamid) 86 | } 87 | 88 | pub fn add_player(&mut self, player: Player) { 89 | self.players.insert(player.steamid32.clone(), player); 90 | } 91 | 92 | pub fn remove_player(&mut self, steamid32: &Steamid32) { 93 | if let Some(player) = self.players.remove(steamid32) { 94 | for prev in self.previous_players.inner() { 95 | if prev.steamid32 == player.steamid32 { 96 | return; 97 | } 98 | } 99 | self.previous_players.push(player); 100 | } 101 | } 102 | 103 | pub fn get_bots(&self) -> Vec<&Player> { 104 | let mut bots: Vec<&Player> = Vec::new(); 105 | 106 | for p in self.players.values() { 107 | if p.player_type == PlayerType::Bot { 108 | bots.push(p); 109 | } 110 | } 111 | 112 | bots 113 | } 114 | 115 | /// Updating any existing copies of a player (in current players or recent players) to match 116 | /// the provided PlayerRecord 117 | pub fn update_player_from_record(&mut self, record: PlayerRecord) { 118 | for p in self.previous_players.inner_mut() { 119 | if p.steamid32 == record.steamid { 120 | p.player_type = record.player_type; 121 | p.notes = record.notes.clone(); 122 | } 123 | } 124 | 125 | if let Some(p) = self.players.get_mut(&record.steamid) { 126 | p.player_type = record.player_type; 127 | p.notes = record.notes; 128 | } 129 | } 130 | 131 | /// Call a votekick on any players detected as bots. 132 | /// If userid is set in cfg/settings.cfg then it will only attempt to call vote on bots in the same team 133 | /// There is no way of knowing if a vote is in progress or the user is on cooldown so votes will still be attempted 134 | pub fn kick_players_of_type( 135 | &mut self, 136 | settings: &Settings, 137 | io: &mut IOManager, 138 | player_type: PlayerType, 139 | ) { 140 | if !settings.kick_bots { 141 | return; 142 | } 143 | 144 | // Don't attempt to kick if too early 145 | if let Some(user) = self.players.get(&settings.user) { 146 | if user.time < 120 { 147 | return; 148 | } 149 | } 150 | 151 | for p in self.players.values() { 152 | if p.state != PlayerState::Active || p.player_type != player_type { 153 | continue; 154 | } 155 | match self.players.get(&settings.user) { 156 | Some(user) => { 157 | if user.team == p.team { 158 | io.send(IORequest::RunCommand(CommandManager::kick_player_command( 159 | &p.userid, 160 | KickReason::Cheating, 161 | ))); 162 | } 163 | } 164 | None => { 165 | io.send(IORequest::RunCommand(CommandManager::kick_player_command( 166 | &p.userid, 167 | KickReason::Cheating, 168 | ))); 169 | } 170 | } 171 | } 172 | } 173 | 174 | /// Update local info on server players 175 | pub fn refresh(&mut self) { 176 | log::debug!("Refreshing server."); 177 | 178 | for p in self.players.values_mut() { 179 | p.accounted += 1; 180 | } 181 | 182 | self.parties.update(&self.players); 183 | } 184 | 185 | /// Remove players who aren't present on the server anymore 186 | /// (This method will be called automatically in a rexes command) 187 | pub fn prune(&mut self) { 188 | 'outer: for (_, p) in self.players.extract_if(|_, v| { 189 | if v.accounted > ACCOUNTED_LIMIT && v.player_type == PlayerType::Bot { 190 | log::info!("Bot disconnected: {}", v.name); 191 | } 192 | if v.accounted > ACCOUNTED_LIMIT { 193 | log::debug!("Player Pruned: {}", v.name); 194 | } 195 | 196 | v.accounted > ACCOUNTED_LIMIT 197 | }) { 198 | log::info!("Pruning player {}", &p.name); 199 | for prev in self.previous_players.inner() { 200 | if p.steamid32 == prev.steamid32 { 201 | continue 'outer; 202 | } 203 | } 204 | self.previous_players.push(p); 205 | } 206 | } 207 | 208 | pub fn add_chat(&mut self, chat: ChatMessage) { 209 | self.chat.push(chat); 210 | } 211 | 212 | pub fn add_kill(&mut self, kill: PlayerKill) { 213 | self.deathlog.push(kill); 214 | } 215 | 216 | pub fn get_chat(&self) -> &Vec { 217 | &self.chat 218 | } 219 | 220 | pub fn get_kills(&self) -> &Vec { 221 | &self.deathlog 222 | } 223 | 224 | pub fn send_chat_messages(&mut self, settings: &Settings, io: &mut IOManager) { 225 | let mut message = String::new(); 226 | 227 | let mut bots = false; 228 | let mut cheaters = false; 229 | let mut names: Vec<&str> = Vec::new(); 230 | 231 | let mut invaders = false; 232 | let mut defenders = false; 233 | 234 | // Remove accounts we don't want to announce, record the details of accounts we want to 235 | // announce now, and leave the rest for later 236 | self.new_connections.retain(|p| { 237 | if let Some(p) = self.players.get(p) { 238 | // Make sure it's a bot or cheater 239 | if !(settings.announce_bots && p.player_type == PlayerType::Bot 240 | || settings.announce_cheaters && p.player_type == PlayerType::Cheater) 241 | { 242 | return false; 243 | } 244 | 245 | // Don't announce common names 246 | if settings.dont_announce_common_names && p.common_name { 247 | return false; 248 | } 249 | 250 | // Ignore accounts that haven't been assigned a team yet 251 | if p.team == Team::None { 252 | return true; 253 | } 254 | 255 | // Record details of account for announcement 256 | bots |= p.player_type == PlayerType::Bot; 257 | cheaters |= p.player_type == PlayerType::Cheater; 258 | invaders |= p.team == Team::Invaders; 259 | defenders |= p.team == Team::Defenders; 260 | names.push(&p.name); 261 | } 262 | false 263 | }); 264 | 265 | if names.is_empty() { 266 | return; 267 | } 268 | 269 | if bots && cheaters { 270 | message.push_str(&format!("{} ", settings.message_both.trim())); 271 | } else if bots { 272 | message.push_str(&format!("{} ", settings.message_bots.trim())); 273 | } else if cheaters { 274 | message.push_str(&format!("{} ", settings.message_cheaters.trim())); 275 | } 276 | 277 | // Team 278 | match self.players.get(&settings.user) { 279 | Some(user) => { 280 | if (invaders && defenders) || user.team == Team::None { 281 | message.push_str(&format!("{} ", settings.message_both_teams.trim())); 282 | } else if (invaders && user.team == Team::Invaders) 283 | || (defenders && user.team == Team::Defenders) 284 | { 285 | message.push_str(&format!("{} ", settings.message_same_team.trim())); 286 | } else if (invaders && user.team == Team::Defenders) 287 | || (defenders && user.team == Team::Invaders) 288 | { 289 | message.push_str(&format!("{} ", settings.message_enemy_team.trim())); 290 | } else { 291 | message.push_str(&format!("{} ", settings.message_default.trim())); 292 | log::error!("Announcing bot that doesn't have a team."); 293 | } 294 | } 295 | None => { 296 | message.push_str(&format!("{} ", settings.message_default.trim())); 297 | } 298 | } 299 | 300 | // Player names 301 | let mut account_peekable = names.iter().peekable(); 302 | while let Some(name) = account_peekable.next() { 303 | message.push_str(name); 304 | 305 | if account_peekable.peek().is_some() { 306 | message.push_str(", "); 307 | } else { 308 | message.push('.'); 309 | } 310 | } 311 | 312 | // Send message 313 | io.send(IORequest::RunCommand(CommandManager::send_chat_command( 314 | &message, 315 | ))); 316 | } 317 | 318 | /// Create and add a demo player to the server list to test with 319 | pub fn add_demo_player(&mut self, name: String, steamid32: String, team: Team) { 320 | let player = player::create_demo_player(name, steamid32, team); 321 | self.players.insert(player.steamid32.clone(), player); 322 | } 323 | } 324 | 325 | impl Default for Server { 326 | fn default() -> Self { 327 | Self::new() 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /src/server/parties.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet, VecDeque}; 2 | 3 | use crate::player::{steamid_64_to_32, Player, Steamid32}; 4 | 5 | // taken from https://sashamaps.net/docs/resources/20-colors/ 6 | const COLOR_PALETTE: [egui::Color32; 21] = [ 7 | egui::Color32::from_rgb(230, 25, 75), 8 | egui::Color32::from_rgb(60, 180, 75), 9 | egui::Color32::from_rgb(255, 225, 25), 10 | egui::Color32::from_rgb(0, 130, 200), 11 | egui::Color32::from_rgb(245, 130, 48), 12 | egui::Color32::from_rgb(145, 30, 180), 13 | egui::Color32::from_rgb(70, 240, 240), 14 | egui::Color32::from_rgb(240, 50, 230), 15 | egui::Color32::from_rgb(210, 245, 60), 16 | egui::Color32::from_rgb(250, 190, 212), 17 | egui::Color32::from_rgb(0, 128, 128), 18 | egui::Color32::from_rgb(220, 190, 255), 19 | egui::Color32::from_rgb(170, 110, 40), 20 | egui::Color32::from_rgb(255, 250, 200), 21 | egui::Color32::from_rgb(128, 0, 0), 22 | egui::Color32::from_rgb(170, 255, 195), 23 | egui::Color32::from_rgb(128, 128, 0), 24 | egui::Color32::from_rgb(255, 215, 180), 25 | egui::Color32::from_rgb(0, 0, 128), 26 | egui::Color32::from_rgb(128, 128, 128), 27 | egui::Color32::from_rgb(255, 255, 255), 28 | ]; 29 | 30 | /// Structure used to determine which players in the current server are friends 31 | pub struct Parties{ 32 | players: Vec, 33 | friend_connections: HashMap>, 34 | 35 | parties: Vec>, 36 | } 37 | 38 | impl Parties { 39 | pub fn new() -> Parties { 40 | Parties{ 41 | players: Vec::new(), 42 | friend_connections: HashMap::new(), 43 | parties: Vec::new(), 44 | } 45 | } 46 | 47 | pub fn clear(&mut self) { 48 | self.players.clear(); 49 | self.friend_connections.clear(); 50 | self.parties.clear(); 51 | } 52 | 53 | /// Updates the internal graph of players 54 | pub fn update(&mut self, player_map: &HashMap){ 55 | // Copy over the players 56 | self.players.clear(); 57 | for p in player_map.keys() { 58 | self.players.push(p.clone()); 59 | } 60 | 61 | self.friend_connections.clear(); 62 | // Get friends of each player and add them to the connection map 63 | for p in player_map.values() { 64 | if let Some(Ok(acif)) = &p.account_info { 65 | if let Some(Ok(friends)) = &acif.friends { 66 | for f in friends { 67 | let id = steamid_64_to_32(&f.steamid).unwrap(); 68 | if self.players.contains(&id){ 69 | self.add_friend(&p.steamid32, &id); 70 | } 71 | } 72 | } 73 | } 74 | } 75 | 76 | self.find_parties(); 77 | } 78 | 79 | /// Returns color to represent this player's party 80 | pub fn get_player_party_color(&self, p: &Player) -> Option { 81 | for i in 0..self.parties.len(){ 82 | let party = self.parties.get(i).unwrap(); 83 | if party.contains(&p.steamid32){ 84 | return Some(COLOR_PALETTE[(i%COLOR_PALETTE.len()) as usize]); 85 | } 86 | } 87 | None 88 | } 89 | 90 | /// Determines the connected components of the player graph (aka. the friend groups) 91 | fn find_parties(&mut self){ 92 | self.parties.clear(); 93 | if self.players.is_empty() { 94 | return; 95 | } 96 | 97 | let mut remaining_players = self.players.clone(); // Vec to keep track of unhandled players 98 | let mut queue: VecDeque = VecDeque::new(); // Queue for processing connected players 99 | 100 | // Perform a BFS over the graph to find the components and save them as parties 101 | while !remaining_players.is_empty() { 102 | // Start a new party and add an unhandled player to the queue 103 | queue.push_back(remaining_players.first().unwrap().clone()); 104 | let mut party: HashSet = HashSet::new(); 105 | 106 | while !queue.is_empty() { 107 | let p = queue.pop_front().unwrap(); 108 | party.insert(p.clone()); 109 | remaining_players.retain(|rp|*rp != p); 110 | 111 | if let Some(friends) = self.friend_connections.get(&p) { 112 | // Only push players not in the party into the queue 113 | friends.iter().filter(|f|!party.contains(*f)).for_each(|f|queue.push_back(f.clone())); 114 | } 115 | } 116 | // Solo players are not in a party 117 | if party.len() > 1{ 118 | self.parties.push(party); 119 | } 120 | } 121 | } 122 | 123 | /// Utility function to add bidirectional friend connections, so private accounts can also be accounted for as long as one of their friends has a public account 124 | fn add_friend(&mut self, user: &String, friend: &String){ 125 | if let Some(set) = self.friend_connections.get_mut(user){ 126 | set.insert(friend.clone()); 127 | } else { 128 | self.friend_connections.insert(user.clone(), HashSet::from([friend.clone()])); 129 | } 130 | 131 | if let Some(set) = self.friend_connections.get_mut(friend){ 132 | set.insert(user.clone()); 133 | } else { 134 | self.friend_connections.insert(friend.clone(), HashSet::from([user.clone()])); 135 | } 136 | } 137 | } -------------------------------------------------------------------------------- /src/server/player.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | 3 | use chrono::{NaiveDateTime, Utc}; 4 | use clipboard::{ClipboardContext, ClipboardProvider}; 5 | use egui::{Color32, Label, RichText, Ui, Vec2}; 6 | use egui_extras::RetainedImage; 7 | use serde::Serialize; 8 | use wgpu_app::utils::persistent_window::PersistentWindow; 9 | 10 | const ORANGE: Color32 = Color32::from_rgb(255, 165, 0); 11 | 12 | use crate::{ 13 | gui::{ 14 | format_time, 15 | player_windows::{create_edit_notes_window, player_type_combobox}, 16 | regex_windows::new_regex_window, 17 | truncate, TRUNC_LEN, 18 | }, 19 | io::command_manager::KickReason, 20 | player_checker::PlayerRecord, 21 | state::State, 22 | steamapi::AccountInfo, 23 | }; 24 | 25 | pub type Steamid64 = String; 26 | pub type Steamid32 = String; 27 | 28 | #[derive(PartialEq, Eq, Clone, Copy)] 29 | pub enum Team { 30 | Defenders, 31 | Invaders, 32 | None, 33 | } 34 | 35 | #[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize)] 36 | pub enum PlayerType { 37 | Player, 38 | Bot, 39 | Cheater, 40 | Suspicious, 41 | } 42 | 43 | #[derive(PartialEq, Eq)] 44 | pub enum PlayerState { 45 | Spawning, 46 | Active, 47 | } 48 | 49 | /// An action on a player initiated by the user through the UI 50 | pub enum UserAction { 51 | Update(PlayerRecord), 52 | Kick(KickReason), 53 | GetProfile(Steamid64), 54 | OpenWindow(PersistentWindow), 55 | } 56 | 57 | pub struct Player { 58 | pub userid: String, 59 | pub name: String, 60 | pub steamid32: Steamid32, 61 | pub steamid64: Steamid64, 62 | pub time: u32, 63 | pub team: Team, 64 | pub state: PlayerState, 65 | pub player_type: PlayerType, 66 | pub notes: String, 67 | 68 | pub accounted: u8, 69 | pub stolen_name: bool, 70 | pub common_name: bool, 71 | 72 | pub account_info: Option>, 73 | pub profile_image: Option, 74 | } 75 | 76 | impl PartialEq for Player { 77 | fn eq(&self, other: &Self) -> bool { 78 | self.steamid32 == other.steamid32 79 | } 80 | } 81 | 82 | impl std::fmt::Display for Player { 83 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 84 | write!( 85 | f, 86 | "{} - {}, \tUID: {}, SteamID: {}, State: {}, Type: {:?}", 87 | self.team, self.name, self.userid, self.steamid32, self.state, self.player_type 88 | ) 89 | } 90 | } 91 | 92 | impl Player { 93 | pub fn get_export_steamid(&self) -> String { 94 | format!("[{}] - {}", &self.steamid32, &self.name) 95 | } 96 | 97 | pub fn get_export_regex(&self) -> String { 98 | regex::escape(&self.name) 99 | } 100 | 101 | pub fn get_record(&self) -> PlayerRecord { 102 | PlayerRecord { 103 | steamid: self.steamid32.clone(), 104 | player_type: self.player_type, 105 | notes: self.notes.clone(), 106 | } 107 | } 108 | 109 | /// Renders an editable summary of a player 110 | /// `allow_kick` enables a button in the context menu to call a votekick on the player 111 | /// `allow_steamapi` enables a button to request the user's steam account info and displays 112 | /// that info when hovering the player's name 113 | pub fn render_player( 114 | &self, 115 | ui: &mut Ui, 116 | user: &str, 117 | allow_kick: bool, 118 | allow_steamapi: bool, 119 | party_color: Option, 120 | ) -> Option { 121 | static mut CONTEXT_MENU_OPEN: Option = None; 122 | 123 | let mut ui_action: Option = None; 124 | 125 | // Player type 126 | let mut new_type = self.player_type; 127 | if player_type_combobox(&self.steamid32, &mut new_type, ui) { 128 | let mut record = self.get_record(); 129 | record.player_type = new_type; 130 | ui_action = Some(UserAction::Update(record)); 131 | } 132 | 133 | // Player name 134 | let text = if self.steamid32 == user { 135 | egui::RichText::new(truncate(&self.name, TRUNC_LEN)).color(Color32::GREEN) 136 | } else if self.player_type == PlayerType::Bot || self.player_type == PlayerType::Cheater { 137 | egui::RichText::new(truncate(&self.name, TRUNC_LEN)).color(self.player_type.color(ui)) 138 | } else if self.stolen_name { 139 | egui::RichText::new(truncate(&self.name, TRUNC_LEN)).color(Color32::YELLOW) 140 | } else { 141 | egui::RichText::new(truncate(&self.name, TRUNC_LEN)) 142 | }; 143 | 144 | // Player name button styling 145 | ui.style_mut().visuals.widgets.inactive.bg_fill = ui.style().visuals.window_fill(); 146 | 147 | // Player actions context menu 148 | let mut menu_open = false; 149 | let header = ui.menu_button(text, |ui| { 150 | // Don't show the hover ui if a menu is open, otherwise it can overlap the 151 | // currently open manu and be annoying 152 | menu_open = true; 153 | 154 | // Workaround to prevent opening a menu button then hovering a different 155 | // one changing the source of the menu 156 | // This is unsafe because I am using a static mut object inside the method, 157 | // which cases race conditions across threads, however since the UI will 158 | // only ever be manipulated from 1 thread I think it is safe. 159 | unsafe { 160 | match &CONTEXT_MENU_OPEN { 161 | None => { 162 | CONTEXT_MENU_OPEN = Some(self.steamid32.clone()); 163 | } 164 | Some(id) => { 165 | if id != &self.steamid32 { 166 | CONTEXT_MENU_OPEN = None; 167 | ui.close_menu(); 168 | return; 169 | } 170 | } 171 | } 172 | } 173 | 174 | if ui.button("Copy SteamID32").clicked() { 175 | let ctx: Result> = 176 | ClipboardProvider::new(); 177 | ctx.unwrap().set_contents(self.steamid32.clone()).unwrap(); 178 | log::info!("{}", format!("Copied \"{}\"", self.steamid32)); 179 | } 180 | 181 | if ui.button("Copy SteamID64").clicked() { 182 | let ctx: Result> = 183 | ClipboardProvider::new(); 184 | ctx.unwrap().set_contents(self.steamid64.clone()).unwrap(); 185 | log::info!("{}", format!("Copied \"{}\"", self.steamid64)); 186 | } 187 | 188 | if ui.button("Copy Name").clicked() { 189 | let ctx: Result> = 190 | ClipboardProvider::new(); 191 | ctx.unwrap().set_contents(self.name.clone()).unwrap(); 192 | log::info!("{}", format!("Copied \"{}\"", self.name)); 193 | } 194 | 195 | // Copy SteamID and Name buttons 196 | if ui.button("Edit Notes").clicked() { 197 | ui_action = Some(UserAction::OpenWindow(create_edit_notes_window( 198 | self.get_record(), 199 | ))); 200 | } 201 | 202 | if allow_steamapi { 203 | let refresh_button = ui.button("Refresh profile info"); 204 | if refresh_button.clicked() { 205 | ui_action = Some(UserAction::GetProfile(self.steamid64.clone())); 206 | } 207 | } 208 | 209 | ui.hyperlink_to( 210 | "Visit profile", 211 | format!("https://steamcommunity.com/profiles/{}", &self.steamid64), 212 | ); 213 | 214 | // Other actions button 215 | if allow_kick 216 | || self.player_type == PlayerType::Bot 217 | || self.player_type == PlayerType::Cheater 218 | { 219 | // Call votekick button 220 | if allow_kick { 221 | ui.menu_button(RichText::new("Call votekick").color(Color32::RED), |ui| { 222 | let mut reason: Option = None; 223 | if ui.button("No reason").clicked() { 224 | reason = Some(KickReason::None); 225 | } 226 | if ui.button("Idle").clicked() { 227 | reason = Some(KickReason::Idle); 228 | } 229 | if ui.button("Cheating").clicked() { 230 | reason = Some(KickReason::Cheating); 231 | } 232 | if ui.button("Scamming").clicked() { 233 | reason = Some(KickReason::Scamming); 234 | } 235 | 236 | if let Some(reason) = reason { 237 | ui_action = Some(UserAction::Kick(reason)); 238 | } 239 | }); 240 | } 241 | 242 | // Save Name button 243 | if self.player_type == PlayerType::Bot || self.player_type == PlayerType::Cheater { 244 | let but = ui.button(RichText::new("Save Name").color(Color32::RED)); 245 | if but.clicked() { 246 | ui_action = Some(UserAction::OpenWindow(new_regex_window( 247 | self.get_export_regex(), 248 | ))); 249 | } 250 | but.on_hover_text( 251 | RichText::new("Players with this name will always be recognized as a bot") 252 | .color(Color32::RED), 253 | ); 254 | } 255 | } 256 | }); 257 | 258 | // Close context menu 259 | if header.response.clicked_elsewhere() { 260 | unsafe { 261 | CONTEXT_MENU_OPEN = None; 262 | } 263 | } 264 | 265 | // Don't show the hover ui if a menu is open, otherwise it can overlap the 266 | // currently open manu and be annoying. Only show the hover menu if there are 267 | // steam details (arrived or outstanding doesn't matter) or there is 268 | // information to show (i.e. notes or stolen name notification) 269 | if (allow_steamapi || !self.notes.is_empty() || self.stolen_name) && !menu_open { 270 | header.response.on_hover_ui(|ui| { 271 | self.render_account_info(ui, party_color); 272 | self.render_notes(ui); 273 | }); 274 | } 275 | 276 | // Party Indicator 277 | if let Some(col_party) = party_color { 278 | ui.label(RichText::new("■").color(col_party)); 279 | } 280 | 281 | // Cheater, Bot and Joining labels 282 | ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| { 283 | ui.add_space(15.0); 284 | 285 | // Time 286 | ui.label(&format_time(self.time)); 287 | 288 | // Notes indicator 289 | if !self.notes.is_empty() { 290 | ui.label("☑"); 291 | } 292 | 293 | // VAC and game bans, young account, or couldn't fetch profile 294 | if let Some(Ok(info)) = &self.account_info { 295 | if info.bans.VACBanned { 296 | ui.label(RichText::new("V").color(Color32::RED)); 297 | } 298 | if info.bans.NumberOfGameBans > 0 { 299 | ui.label(RichText::new("G").color(Color32::RED)); 300 | } 301 | if let Some(time) = info.summary.timecreated { 302 | let age = Utc::now() 303 | .naive_local() 304 | .signed_duration_since(NaiveDateTime::from_timestamp(time as i64, 0)); 305 | if age.num_days() < (70) { 306 | ui.label(RichText::new("Y").color(Color32::RED)); 307 | } else if age.num_days() < (365) { 308 | ui.label(RichText::new("Y").color(ORANGE)); 309 | } 310 | } 311 | if info.summary.communityvisibilitystate == 1 { 312 | ui.label(RichText::new("P").color(Color32::RED)); 313 | } else if info.summary.communityvisibilitystate == 2 { 314 | ui.label(RichText::new("F").color(Color32::YELLOW)); 315 | } 316 | } else if let Some(Err(_)) = &self.account_info { 317 | ui.label(RichText::new("N").color(ORANGE)); 318 | } 319 | 320 | // Cheater / Bot / Joining 321 | if self.player_type != PlayerType::Player { 322 | ui.add(Label::new(self.player_type.rich_text())); 323 | } 324 | if self.state == PlayerState::Spawning { 325 | ui.add(Label::new(RichText::new("Joining").color(Color32::YELLOW))); 326 | } 327 | }); 328 | 329 | ui_action 330 | } 331 | 332 | /// Renders a view of the player's steam account info 333 | pub fn render_account_info(&self, ui: &mut Ui, party_color: Option) { 334 | if let Some(info_request) = &self.account_info { 335 | match info_request { 336 | Ok(info) => { 337 | let AccountInfo { 338 | summary, 339 | bans, 340 | friends: _, 341 | } = info; 342 | 343 | ui.horizontal(|ui| { 344 | if let Some(profile_img) = &self.profile_image { 345 | profile_img.show_size(ui, Vec2::new(64.0, 64.0)); 346 | } 347 | 348 | ui.vertical(|ui| { 349 | ui.label(&summary.personaname); 350 | ui.horizontal(|ui| { 351 | ui.label("Profile: "); 352 | ui.label(match summary.communityvisibilitystate { 353 | 1 => RichText::new("Private").color(Color32::RED), 354 | 2 => RichText::new("Friends-only").color(Color32::YELLOW), 355 | 3 => RichText::new("Public").color(Color32::GREEN), 356 | _ => RichText::new("Invalid value"), 357 | }); 358 | }); 359 | 360 | if let Some(time) = summary.timecreated { 361 | let age = Utc::now().naive_local().signed_duration_since( 362 | NaiveDateTime::from_timestamp(time as i64, 0), 363 | ); 364 | let years = age.num_days() / 365; 365 | let days = age.num_days() - years * 365; 366 | 367 | if years > 0 { 368 | ui.label(&format!( 369 | "Account Age: {} years, {} days", 370 | years, days 371 | )); 372 | } else { 373 | ui.label(&format!("Account Age: {} days", days)); 374 | } 375 | 376 | if age.num_days() < (70) { 377 | ui.label( 378 | RichText::new("(Very) Young account").color(Color32::RED), 379 | ); 380 | } else if age.num_days() < (365) { 381 | ui.label( 382 | RichText::new("Young account") 383 | .color(Color32::from_rgb(255, 165, 0)), 384 | ); 385 | } 386 | } 387 | 388 | if bans.VACBanned { 389 | ui.label( 390 | RichText::new(format!( 391 | "This player has VAC bans: {}", 392 | bans.NumberOfVACBans 393 | )) 394 | .color(Color32::RED), 395 | ); 396 | } 397 | 398 | if bans.NumberOfGameBans > 0 { 399 | ui.label( 400 | RichText::new(format!( 401 | "This player has Game bans: {}", 402 | bans.NumberOfGameBans 403 | )) 404 | .color(Color32::RED), 405 | ); 406 | } 407 | 408 | if bans.VACBanned || bans.NumberOfGameBans > 0 { 409 | ui.label( 410 | RichText::new(format!( 411 | "Days since last ban: {}", 412 | bans.DaysSinceLastBan 413 | )) 414 | .color(Color32::RED), 415 | ); 416 | } 417 | 418 | if let Some(c) = party_color { 419 | ui.label( 420 | RichText::new("■ This player has friends in the server") 421 | .color(c), 422 | ); 423 | } 424 | }); 425 | }); 426 | } 427 | Err(e) => { 428 | let string = format!("{}", e); 429 | ui.label(RichText::new("No profile could be retrieved").color(ORANGE)); 430 | if string.contains("missing field `profilestate`") { 431 | ui.label("Profile may not be set up."); 432 | } 433 | ui.label(&format!("{}", e)); 434 | } 435 | } 436 | ui.add_space(10.0); 437 | } 438 | } 439 | 440 | /// Render any notes saved for this account 441 | pub fn render_notes(&self, ui: &mut Ui) { 442 | if self.stolen_name || !self.notes.is_empty() { 443 | if self.stolen_name { 444 | ui.label( 445 | RichText::new("A player with this name is already on the server.") 446 | .color(Color32::YELLOW), 447 | ); 448 | } 449 | if !self.notes.is_empty() { 450 | ui.label(&self.notes); 451 | } 452 | } 453 | } 454 | } 455 | 456 | pub fn create_demo_player(name: String, steamid32: String, team: Team) -> Player { 457 | let steamid64 = steamid_32_to_64(&steamid32).unwrap_or_default(); 458 | 459 | Player { 460 | userid: String::from("0"), 461 | name, 462 | steamid32, 463 | steamid64, 464 | time: 69, 465 | team, 466 | state: PlayerState::Active, 467 | player_type: PlayerType::Player, 468 | notes: String::new(), 469 | 470 | accounted: 0, 471 | stolen_name: false, 472 | common_name: false, 473 | 474 | account_info: None, 475 | profile_image: None, 476 | } 477 | } 478 | 479 | impl std::fmt::Display for PlayerState { 480 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 481 | let out: &str = match self { 482 | PlayerState::Active => "Active ", 483 | PlayerState::Spawning => "Spawning", 484 | }; 485 | write!(f, "{}", out) 486 | } 487 | } 488 | 489 | impl PlayerType { 490 | pub fn color(&self, ui: &Ui) -> Color32 { 491 | use PlayerType::*; 492 | match self { 493 | Player => ui.visuals().text_color(), 494 | Bot => Color32::RED, 495 | Cheater => Color32::from_rgb(255, 165, 0), 496 | Suspicious => Color32::LIGHT_RED, 497 | } 498 | } 499 | 500 | pub fn rich_text(&self) -> RichText { 501 | use PlayerType::*; 502 | match self { 503 | Player => RichText::new("Player"), 504 | Bot => RichText::new("Bot").color(Color32::RED), 505 | Cheater => RichText::new("Cheater").color(Color32::from_rgb(255, 165, 0)), 506 | Suspicious => RichText::new("Suspicious").color(Color32::LIGHT_RED), 507 | } 508 | } 509 | } 510 | 511 | impl std::fmt::Display for Team { 512 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 513 | let out: &str = match self { 514 | Team::Defenders => "DEF ", 515 | Team::Invaders => "INV ", 516 | Team::None => "NONE", 517 | }; 518 | write!(f, "{}", out) 519 | } 520 | } 521 | 522 | /// Convert a steamid32 (U:0:1234567) to a steamid64 (76561197960265728) 523 | pub fn steamid_32_to_64(steamid32: &Steamid32) -> Option { 524 | let segments: Vec<&str> = steamid32.split(':').collect(); 525 | 526 | let id32: u64 = if let Ok(id32) = segments.get(2)?.parse() { 527 | id32 528 | } else { 529 | return None; 530 | }; 531 | 532 | Some(format!("{}", id32 + 76561197960265728)) 533 | } 534 | 535 | /// Convert a steamid64 (76561197960265728) to a steamid32 (U:0:1234567) 536 | pub fn steamid_64_to_32(steamid64: &Steamid64) -> Result { 537 | let id64: u64 = steamid64.parse()?; 538 | Ok(format!("U:1:{}", id64 - 76561197960265728)) 539 | } 540 | -------------------------------------------------------------------------------- /src/settings.rs: -------------------------------------------------------------------------------- 1 | use egui_dock::{NodeIndex, Tree}; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use serde_json::Value; 5 | 6 | use crate::gui::GuiTab; 7 | 8 | #[derive(Serialize, Deserialize, Debug)] 9 | pub struct WindowState { 10 | pub width: u32, 11 | pub height: u32, 12 | pub x: i32, 13 | pub y: i32, 14 | } 15 | 16 | #[derive(Serialize, Deserialize, Debug)] 17 | pub struct Settings { 18 | pub window: WindowState, 19 | 20 | pub user: String, 21 | pub steamapi_key: String, 22 | 23 | pub announce_bots: bool, 24 | pub announce_cheaters: bool, 25 | pub announce_namesteal: bool, 26 | pub dont_announce_common_names: bool, 27 | 28 | pub message_bots: String, 29 | pub message_cheaters: String, 30 | pub message_both: String, 31 | 32 | pub message_same_team: String, 33 | pub message_enemy_team: String, 34 | pub message_both_teams: String, 35 | pub message_default: String, 36 | 37 | pub kick_bots: bool, 38 | pub kick_cheaters: bool, 39 | 40 | pub refresh_period: f32, 41 | pub kick_period: f32, 42 | pub alert_period: f32, 43 | 44 | pub paused: bool, 45 | 46 | pub rcon_password: String, 47 | pub tf2_directory: String, 48 | 49 | pub mark_name_stealers: bool, 50 | 51 | pub ignore_version: String, 52 | pub ignore_no_api_key: bool, 53 | 54 | pub launch_tf2: bool, 55 | pub close_on_disconnect: bool, 56 | pub saved_dock: Tree, 57 | } 58 | 59 | impl Settings { 60 | pub fn new() -> Settings { 61 | let mut gui_tree = Tree::new(vec![GuiTab::Players]); 62 | gui_tree.split_left(NodeIndex::root(), 0.2, vec![GuiTab::Settings]); 63 | 64 | Settings { 65 | window: WindowState { 66 | width: 1100, 67 | height: 500, 68 | x: 200, 69 | y: 200, 70 | }, 71 | 72 | user: String::from("U:1:XXXXXXX"), 73 | steamapi_key: String::new(), 74 | 75 | announce_bots: false, 76 | announce_cheaters: false, 77 | announce_namesteal: true, 78 | dont_announce_common_names: true, 79 | 80 | message_bots: String::from("Bots joining"), 81 | message_cheaters: String::from("Cheaters joining"), 82 | message_both: String::from("Bots and Cheaters joining"), 83 | 84 | message_same_team: String::from("our team:"), 85 | message_enemy_team: String::from("the enemy team:"), 86 | message_both_teams: String::from("both teams:"), 87 | message_default: String::from("the server:"), 88 | 89 | kick_bots: true, 90 | kick_cheaters: false, 91 | 92 | refresh_period: 10.0, 93 | kick_period: 10.0, 94 | alert_period: 20.0, 95 | 96 | paused: false, 97 | 98 | rcon_password: String::from("tf2bk"), 99 | tf2_directory: String::new(), 100 | 101 | mark_name_stealers: true, 102 | ignore_version: String::new(), 103 | ignore_no_api_key: false, 104 | 105 | launch_tf2: false, 106 | close_on_disconnect: false, 107 | saved_dock: gui_tree, 108 | } 109 | } 110 | 111 | /// Attempts to import settings from a file, returning an error if there is no file or it could not be read and interpretted 112 | /// 113 | /// A default settings instance is created and each setting overridden individually if it can be read from the JSON object 114 | /// and ignored if not. This is to make the importer resilient to version changes such as when a new version introduces 115 | /// a new setting or changes/removes and old one and the struct cannot be directly deserialised from the JSON anymore. 116 | pub fn import(file: &str) -> Result> { 117 | let contents = std::fs::read_to_string(file)?; 118 | let json: serde_json::Value = serde_json::from_str(&contents)?; 119 | 120 | let mut set = Settings::new(); 121 | 122 | if let Value::Object(window) = &json["window"] { 123 | if let Some(width) = window["width"].as_i64() { 124 | set.window.width = width.try_into().unwrap_or(set.window.width); 125 | } 126 | if let Some(height) = window["height"].as_i64() { 127 | set.window.height = height.try_into().unwrap_or(set.window.height); 128 | } 129 | if let Some(x) = window["x"].as_i64() { 130 | set.window.x = x.try_into().unwrap_or(set.window.x); 131 | } 132 | if let Some(y) = window["y"].as_i64() { 133 | set.window.y = y.try_into().unwrap_or(set.window.y); 134 | } 135 | } 136 | 137 | set.user = json["user"].as_str().unwrap_or(&set.user).to_string(); 138 | set.steamapi_key = json["steamapi_key"] 139 | .as_str() 140 | .unwrap_or(&set.steamapi_key) 141 | .to_string(); 142 | 143 | set.announce_bots = json["announce_bots"].as_bool().unwrap_or(set.announce_bots); 144 | set.announce_cheaters = json["announce_cheaters"] 145 | .as_bool() 146 | .unwrap_or(set.announce_cheaters); 147 | set.announce_namesteal = json["announce_namesteal"] 148 | .as_bool() 149 | .unwrap_or(set.announce_namesteal); 150 | set.dont_announce_common_names = json["dont_announce_common_names"] 151 | .as_bool() 152 | .unwrap_or(set.dont_announce_common_names); 153 | 154 | set.message_bots = json["message_bots"] 155 | .as_str() 156 | .unwrap_or(&set.message_bots) 157 | .to_string(); 158 | set.message_cheaters = json["message_cheaters"] 159 | .as_str() 160 | .unwrap_or(&set.message_cheaters) 161 | .to_string(); 162 | set.message_both = json["message_both"] 163 | .as_str() 164 | .unwrap_or(&set.message_both) 165 | .to_string(); 166 | 167 | set.message_same_team = json["message_same_team"] 168 | .as_str() 169 | .unwrap_or(&set.message_same_team) 170 | .to_string(); 171 | set.message_enemy_team = json["message_enemy_team"] 172 | .as_str() 173 | .unwrap_or(&set.message_enemy_team) 174 | .to_string(); 175 | set.message_both_teams = json["message_both_teams"] 176 | .as_str() 177 | .unwrap_or(&set.message_both_teams) 178 | .to_string(); 179 | set.message_default = json["message_default"] 180 | .as_str() 181 | .unwrap_or(&set.message_default) 182 | .to_string(); 183 | 184 | set.kick_bots = json["kick_bots"].as_bool().unwrap_or(set.kick_bots); 185 | set.kick_cheaters = json["kick_cheaters"].as_bool().unwrap_or(set.kick_cheaters); 186 | 187 | set.refresh_period = json["refresh_period"] 188 | .as_f64() 189 | .map(|val| val as f32) 190 | .unwrap_or(set.refresh_period); 191 | set.kick_period = json["kick_period"] 192 | .as_f64() 193 | .map(|val| val as f32) 194 | .unwrap_or(set.kick_period); 195 | set.alert_period = json["alert_period"] 196 | .as_f64() 197 | .map(|val| val as f32) 198 | .unwrap_or(set.alert_period); 199 | 200 | set.paused = json["paused"].as_bool().unwrap_or(set.paused); 201 | 202 | set.rcon_password = json["rcon_password"] 203 | .as_str() 204 | .unwrap_or(&set.rcon_password) 205 | .to_string(); 206 | set.tf2_directory = json["tf2_directory"] 207 | .as_str() 208 | .unwrap_or(&set.tf2_directory) 209 | .to_string(); 210 | set.ignore_version = json["ignore_version"] 211 | .as_str() 212 | .unwrap_or(&set.ignore_version) 213 | .to_string(); 214 | 215 | set.mark_name_stealers = json["mark_name_stealers"] 216 | .as_bool() 217 | .unwrap_or(set.mark_name_stealers); 218 | 219 | set.ignore_no_api_key = json["ignore_no_api_key"] 220 | .as_bool() 221 | .unwrap_or(set.ignore_no_api_key); 222 | 223 | set.launch_tf2 = json["launch_tf2"].as_bool().unwrap_or(set.launch_tf2); 224 | set.close_on_disconnect = json["close_on_disconnect"] 225 | .as_bool() 226 | .unwrap_or(set.close_on_disconnect); 227 | 228 | set.saved_dock = Tree::::deserialize(&json["saved_dock"]).unwrap_or(set.saved_dock); 229 | 230 | Ok(set) 231 | } 232 | 233 | /// Directly serializes the object to JSON and attempts to write it to the specified file. 234 | pub fn export(&self) -> Result<(), Box> { 235 | let _new_dir = std::fs::create_dir("cfg"); 236 | match serde_json::to_string(self) { 237 | Ok(contents) => match std::fs::write("cfg/settings.json", contents) { 238 | Ok(_) => Ok(()), 239 | Err(e) => Err(Box::new(e)), 240 | }, 241 | Err(e) => Err(Box::new(e)), 242 | } 243 | } 244 | } 245 | 246 | impl Default for Settings { 247 | fn default() -> Self { 248 | Self::new() 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/state.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | use crossbeam_channel::{Receiver, Sender}; 4 | use wgpu_app::utils::persistent_window::PersistentWindow; 5 | 6 | use crate::{ 7 | gui, 8 | io::{ 9 | command_manager, 10 | regexes::{ChatMessage, LobbyLine, PlayerKill, StatusLine}, 11 | IOManager, IORequest, IOResponse, 12 | }, 13 | player_checker::{PlayerChecker, PLAYER_LIST, REGEX_LIST}, 14 | server::{ 15 | player::{steamid_32_to_64, Player, PlayerType, Team}, 16 | Server, 17 | }, 18 | settings::Settings, 19 | steamapi::{self, AccountInfoReceiver}, 20 | timer::Timer, 21 | version::VersionResponse, 22 | }; 23 | 24 | pub struct State { 25 | pub refresh_timer: Timer, 26 | pub alert_timer: Timer, 27 | pub kick_timer: Timer, 28 | 29 | pub settings: Settings, 30 | pub server: Server, 31 | pub player_checker: PlayerChecker, 32 | 33 | pub latest_version: Option>>>, 34 | pub force_latest_version: bool, 35 | 36 | pub steamapi_request_sender: Sender, 37 | pub steamapi_request_receiver: AccountInfoReceiver, 38 | 39 | has_connected: bool, 40 | is_connected: Result, 41 | pub log_open: Result, 42 | 43 | pub io: IOManager, 44 | 45 | pub ui_context_menu_open: Option, 46 | pub new_persistent_windows: Vec>, 47 | } 48 | 49 | impl Default for State { 50 | fn default() -> Self { 51 | Self::new() 52 | } 53 | } 54 | 55 | impl egui_dock::TabViewer for State { 56 | type Tab = gui::GuiTab; 57 | 58 | fn ui(&mut self, ui: &mut egui::Ui, tab: &mut gui::GuiTab) { 59 | match tab { 60 | gui::GuiTab::Settings => gui::render_settings(ui, self), 61 | gui::GuiTab::Players => gui::render_players(ui, self), 62 | gui::GuiTab::ChatLog => gui::render_chat(ui, self), 63 | gui::GuiTab::DeathLog => gui::render_kills(ui, self), 64 | } 65 | } 66 | 67 | fn title(&mut self, tab: &mut gui::GuiTab) -> egui::WidgetText { 68 | match tab { 69 | gui::GuiTab::Settings => "Settings", 70 | gui::GuiTab::Players => "Players", 71 | gui::GuiTab::ChatLog => "Chat", 72 | gui::GuiTab::DeathLog => "Kills", 73 | } 74 | .into() 75 | } 76 | } 77 | 78 | impl State { 79 | pub fn new() -> State { 80 | let settings: Settings; 81 | 82 | // Attempt to load settings, create new default settings if it can't load an existing file 83 | let set = Settings::import("cfg/settings.json"); 84 | 85 | if let Ok(set) = set { 86 | settings = set; 87 | } else { 88 | settings = Settings::new(); 89 | log::warn!( 90 | "{}", 91 | format!("Error loading settings: {}", set.unwrap_err()) 92 | ); 93 | } 94 | 95 | // Create player checker and load any regexes and players saved 96 | let mut player_checker = PlayerChecker::new(); 97 | match player_checker.read_players(PLAYER_LIST) { 98 | Ok(()) => { 99 | log::info!("Loaded playerlist"); 100 | } 101 | Err(e) => { 102 | log::error!("Failed to read playlist: {:?}", e); 103 | } 104 | } 105 | match player_checker.read_regex_list(REGEX_LIST) { 106 | Ok(_) => {} 107 | Err(e) => { 108 | log::error!("{}", format!("Error loading {}: {}", REGEX_LIST, e)); 109 | } 110 | } 111 | 112 | let (steamapi_request_sender, steamapi_request_receiver) = 113 | steamapi::create_api_thread(settings.steamapi_key.clone()); 114 | 115 | let server = Server::new(); 116 | let io = IOManager::start(&settings); 117 | 118 | State { 119 | refresh_timer: Timer::new(), 120 | alert_timer: Timer::new(), 121 | kick_timer: Timer::new(), 122 | 123 | settings, 124 | server, 125 | 126 | player_checker, 127 | latest_version: None, 128 | force_latest_version: false, 129 | 130 | steamapi_request_sender, 131 | steamapi_request_receiver, 132 | 133 | has_connected: false, 134 | is_connected: Ok(false), 135 | log_open: Ok(false), 136 | 137 | io, 138 | 139 | ui_context_menu_open: None, 140 | new_persistent_windows: Vec::new(), 141 | } 142 | } 143 | 144 | pub fn has_connected(&self) -> bool { 145 | self.has_connected 146 | } 147 | 148 | pub fn is_connected(&self) -> &Result { 149 | &self.is_connected 150 | } 151 | 152 | /// Begins a refresh on the local server state, any players unaccounted for since the last time this function was called will be removed. 153 | pub fn refresh(&mut self) { 154 | self.server.prune(); 155 | 156 | // Run status and tf_lobby_debug commands 157 | self.io.send(IORequest::RunCommand( 158 | command_manager::CMD_STATUS.to_string(), 159 | )); 160 | self.io.send(IORequest::RunCommand( 161 | command_manager::CMD_TF_LOBBY_DEBUG.to_string(), 162 | )); 163 | 164 | self.server.refresh(); 165 | } 166 | 167 | pub fn handle_messages(&mut self) { 168 | while let Some(resp) = self.io.recv() { 169 | match resp { 170 | IOResponse::NoLogFile(e) => self.log_open = Err(e), 171 | IOResponse::LogFileOpened => self.log_open = Ok(true), 172 | IOResponse::NoRCON(e) => { 173 | self.is_connected = Err(e); 174 | 175 | if self.has_connected && self.settings.close_on_disconnect { 176 | log::info!("Connection to TF2 has been lost, closing."); 177 | std::process::exit(0); 178 | } 179 | } 180 | IOResponse::RCONConnected => { 181 | self.is_connected = Ok(true); 182 | self.has_connected = true; 183 | } 184 | IOResponse::Status(status) => self.handle_status(status), 185 | IOResponse::Lobby(lobby) => self.handle_lobby(lobby), 186 | IOResponse::Chat(chat) => self.handle_chat(chat), 187 | IOResponse::Kill(kill) => self.handle_kill(kill), 188 | } 189 | } 190 | } 191 | 192 | fn handle_status(&mut self, status: StatusLine) { 193 | let steamid64 = steamid_32_to_64(&status.steamid).unwrap_or_default(); 194 | if steamid64.is_empty() { 195 | log::error!( 196 | "Could not convert steamid32 to steamid64: {}", 197 | status.steamid 198 | ); 199 | } 200 | 201 | // // Check for name stealing 202 | // let mut stolen_name = false; 203 | // for (k, p) in server.get_players() { 204 | // if steamid32 == p.steamid32 || time > p.time { 205 | // continue; 206 | // } 207 | // stolen_name |= name == p.name; 208 | // } 209 | 210 | // Update existing player 211 | if let Some(p) = self.server.get_player_mut(&status.steamid) { 212 | p.userid = status.userid; 213 | p.time = status.time; 214 | p.state = status.state; 215 | p.accounted = 0; 216 | 217 | // p.stolen_name = stolen_name; 218 | // if p.name != name { 219 | // log::debug!("Different name! {}, {}", &p.name, &name); 220 | // p.name = name; 221 | 222 | // // Handle name stealing 223 | // if p.stolen_name && settings.announce_namesteal { 224 | // cmd.send_chat(&format!("A bot has stolen {}'s name.", &p.name)); 225 | // } 226 | // if p.stolen_name && settings.mark_name_stealers && p.player_type == PlayerType::Player { 227 | // p.player_type = PlayerType::Bot; 228 | 229 | // if !p.notes.is_empty() { 230 | // p.notes.push('\n'); 231 | // } 232 | // p.notes 233 | // .push_str("Automatically marked as name-stealing bot."); 234 | // player_checker.update_player(p); 235 | // } 236 | // } 237 | } else { 238 | // Create a new player entry 239 | let mut p = Player { 240 | userid: status.userid, 241 | name: status.name, 242 | steamid32: status.steamid, 243 | steamid64, 244 | time: status.time, 245 | team: Team::None, 246 | state: status.state, 247 | player_type: PlayerType::Player, 248 | notes: String::new(), 249 | accounted: 0, 250 | stolen_name: false, 251 | // stolen_name, 252 | common_name: false, 253 | account_info: None, 254 | profile_image: None, 255 | }; 256 | 257 | self.server.pending_lookup.push(p.steamid64.clone()); 258 | 259 | // Lookup player 260 | if let Some(record) = self.player_checker.check_player_steamid(&p.steamid32) { 261 | p.player_type = record.player_type; 262 | p.notes = record.notes; 263 | log::info!("Known {:?} joining: {}", p.player_type, &p.name); 264 | 265 | if self.player_checker.check_player_name(&p.name).is_some() { 266 | p.common_name = true; 267 | } 268 | } 269 | 270 | // Check player name 271 | if let Some(regx) = self.player_checker.check_player_name(&p.name) { 272 | p.player_type = PlayerType::Bot; 273 | p.common_name = true; 274 | if p.notes.is_empty() { 275 | p.notes = format!("Matched regex {}", regx.as_str()); 276 | } 277 | 278 | self.player_checker.update_player(&p); 279 | log::info!("Unknown {:?} joining: {}", p.player_type, p.name); 280 | } 281 | 282 | // // Handle name stealing 283 | // if stolen_name && settings.announce_namesteal && p.time < settings.refresh_period as u32 { 284 | // cmd.send_chat(&format!("A bot has stolen {}'s name.", &p.name)); 285 | // } 286 | // if p.stolen_name && settings.mark_name_stealers && p.player_type == PlayerType::Player { 287 | // p.player_type = PlayerType::Bot; 288 | 289 | // if !p.notes.is_empty() { 290 | // p.notes.push('\n'); 291 | // } 292 | // p.notes 293 | // .push_str("Automatically marked as name-stealing bot."); 294 | // player_checker.update_player(&p); 295 | // } 296 | 297 | if p.time <= (self.settings.refresh_period * 1.5).ceil() as u32 { 298 | self.server.new_connections.push(p.steamid32.clone()); 299 | } 300 | 301 | self.server.add_player(p); 302 | } 303 | } 304 | 305 | fn handle_lobby(&mut self, lobby: LobbyLine) { 306 | if let Some(p) = self.server.get_player_mut(&lobby.steamid) { 307 | p.team = lobby.team; 308 | p.accounted = 0; 309 | } 310 | } 311 | 312 | fn handle_chat(&mut self, mut chat: ChatMessage) { 313 | log::info!( 314 | "Got chat message from {}: {}", 315 | chat.player_name, 316 | chat.message 317 | ); 318 | 319 | if let Some((k, _)) = self 320 | .server 321 | .get_players() 322 | .iter() 323 | .find(|(_, v)| v.name == chat.player_name) 324 | { 325 | chat.steamid = Some(k.clone()); 326 | } 327 | 328 | self.server.add_chat(chat); 329 | } 330 | 331 | fn handle_kill(&mut self, mut kill: PlayerKill) { 332 | log::info!( 333 | "{} killed {} with {}{}", 334 | kill.killer_name, 335 | kill.victim_name, 336 | kill.weapon, 337 | if kill.crit { " (crit)" } else { "" } 338 | ); 339 | 340 | if let Some((k, _)) = self 341 | .server 342 | .get_players() 343 | .iter() 344 | .find(|(_, v)| v.name == kill.killer_name) 345 | { 346 | kill.killer_steamid = Some(k.clone()); 347 | } else { 348 | log::error!( 349 | "Player {} could not be found when processing kill.", 350 | kill.killer_name 351 | ); 352 | } 353 | if let Some((k, _)) = self 354 | .server 355 | .get_players() 356 | .iter() 357 | .find(|(_, v)| v.name == kill.victim_name) 358 | { 359 | kill.victim_steamid = Some(k.clone()); 360 | } else { 361 | log::error!( 362 | "Player {} could not be found when processing kill.", 363 | kill.victim_name 364 | ); 365 | } 366 | 367 | self.server.add_kill(kill); 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /src/steamapi.rs: -------------------------------------------------------------------------------- 1 | use std::thread; 2 | 3 | use crossbeam_channel::{Sender, Receiver, unbounded}; 4 | use egui::Id; 5 | use egui_extras::RetainedImage; 6 | use steam_api::structs::{summaries, friends, bans}; 7 | use wgpu_app::utils::persistent_window::PersistentWindow; 8 | 9 | use crate::state::State; 10 | 11 | pub struct AccountInfo { 12 | pub summary: summaries::User, 13 | pub bans: bans::User, 14 | pub friends: Option, reqwest::Error>>, 15 | } 16 | 17 | pub type AccountInfoReceiver = Receiver<(Option>, Option, String)>; 18 | pub type AccountInfoSender = Sender<(Option>, Option, String)>; 19 | 20 | pub fn create_api_thread(key: String) -> (Sender, AccountInfoReceiver) { 21 | 22 | let (request_s, request_r): (Sender, Receiver) = unbounded(); 23 | let (response_s, response_r): (AccountInfoSender, AccountInfoReceiver) = unbounded(); 24 | 25 | // Spawn thread to watch requests 26 | thread::spawn(move || { 27 | let key = key; 28 | 29 | thread::scope(|s| { 30 | loop { 31 | match request_r.recv() { 32 | Err(_) => { 33 | log::warn!("Disconnected from main thread, killing api thread."); 34 | break; 35 | }, 36 | Ok(steamid) => { 37 | 38 | // On receiving a request, dispatch it on a new thread. 39 | s.spawn(|| { 40 | 41 | // Summary 42 | let summary = steam_api::get_player_summaries(&steamid, &key).map(|mut summaries| { 43 | if summaries.is_empty() { 44 | log::error!("Steam account summary returned empty"); 45 | response_s.send((None, None, steamid.clone())).unwrap(); 46 | } 47 | summaries.remove(0) 48 | }); 49 | if let Err(e) = summary { 50 | response_s.send((Some(Err(e)), None, steamid)).unwrap(); 51 | return; 52 | } 53 | let summary = summary.unwrap(); 54 | 55 | // Bans 56 | let bans = steam_api::get_player_bans(&steamid, &key).map(|mut bans| { 57 | if bans.is_empty() { 58 | log::error!("Steam account bans returned empty"); 59 | response_s.send((None, None, steamid.clone())).unwrap(); 60 | } 61 | bans.remove(0) 62 | }); 63 | if let Err(e) = bans { 64 | response_s.send((Some(Err(e)), None, steamid)).unwrap(); 65 | return; 66 | } 67 | let bans = bans.unwrap(); 68 | 69 | // Friends 70 | let friends = if summary.communityvisibilitystate == 3 { 71 | Some(steam_api::get_friends_list(&steamid, &key)) 72 | } else { 73 | None 74 | }; 75 | 76 | let info = AccountInfo { 77 | summary, 78 | bans, 79 | friends, 80 | }; 81 | 82 | // Profile image 83 | let img = if let Ok(img_response) = reqwest::blocking::get(&info.summary.avatarmedium) { 84 | if let Ok(img) = RetainedImage::from_image_bytes(&info.summary.steamid, &img_response.bytes().unwrap_or_default()) { 85 | Some(img) 86 | } else { 87 | None 88 | } 89 | } else { 90 | None 91 | }; 92 | 93 | response_s.send((Some(Ok(info)), img, steamid)).unwrap(); 94 | }); 95 | }, 96 | } 97 | } 98 | }); 99 | }); 100 | 101 | (request_s, response_r) 102 | } 103 | 104 | pub fn create_set_api_key_window(mut key: String) -> PersistentWindow { 105 | PersistentWindow::new(Box::new(move |id, _, gui_ctx, state| { 106 | let mut open = true; 107 | let mut saved = false; 108 | 109 | egui::Window::new("Steam Web API key") 110 | .id(Id::new(id)) 111 | .open(&mut open) 112 | .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) 113 | .collapsible(false) 114 | .resizable(false) 115 | .show(gui_ctx, |ui| { 116 | 117 | ui.label("Adding a Steam Web API key allows the app to look up profile information about players. This provides a link to their profile and lets you view names, profile pictures, VAC and game bans, and sometimes account age."); 118 | ui.separator(); 119 | 120 | ui.horizontal(|ui| { 121 | ui.label("Get your own Steam Web API key"); 122 | ui.hyperlink_to("here", "https://steamcommunity.com/dev/apikey"); 123 | }); 124 | 125 | ui.text_edit_singleline(&mut key); 126 | 127 | if key.is_empty() { 128 | ui.checkbox(&mut state.settings.ignore_no_api_key, "Don't remind me."); 129 | } 130 | 131 | if ui.button("Apply").clicked() { 132 | saved = true; 133 | 134 | state.settings.steamapi_key = key.clone(); 135 | (state.steamapi_request_sender, state.steamapi_request_receiver) = create_api_thread(key.clone()); 136 | 137 | for p in state.server.get_players().values() { 138 | state.steamapi_request_sender.send(p.steamid64.clone()).ok(); 139 | } 140 | } 141 | }); 142 | 143 | open && !saved 144 | })) 145 | } 146 | -------------------------------------------------------------------------------- /src/timer.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use std::time::Instant; 4 | 5 | pub struct Timer { 6 | last: Instant, 7 | 8 | duration: f32, 9 | update: bool, 10 | 11 | last_delta: f32, 12 | } 13 | 14 | impl Default for Timer { 15 | fn default() -> Self { 16 | Self::new() 17 | } 18 | } 19 | 20 | impl Timer { 21 | pub fn new() -> Timer { 22 | Timer { 23 | last: Instant::now(), 24 | 25 | duration: 0.0, 26 | update: false, 27 | 28 | last_delta: 0.0, 29 | } 30 | } 31 | 32 | pub fn reset(&mut self) { 33 | self.last = Instant::now(); 34 | self.duration = 0.0; 35 | } 36 | 37 | pub fn go(&mut self, duration: f32) -> Option { 38 | let now = self.last.elapsed(); 39 | let delta = (now.as_micros() as f32) / 1_000_000.0; 40 | 41 | if delta < 0.001 { 42 | return None; 43 | } 44 | 45 | self.update = false; 46 | self.duration += delta; 47 | if self.duration > duration { 48 | self.duration = 0.0; 49 | self.update = true; 50 | } 51 | 52 | self.last_delta = delta; 53 | self.last = Instant::now(); 54 | Some(delta) 55 | } 56 | 57 | #[allow(dead_code)] 58 | pub fn delta(&self) -> f32 { 59 | self.last_delta 60 | } 61 | 62 | pub fn update(&self) -> bool { 63 | self.update 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/version.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | use egui::{Align2, Id, Vec2}; 4 | use serde_json::Value; 5 | use wgpu_app::utils::persistent_window::PersistentWindow; 6 | 7 | use crate::state::State; 8 | 9 | pub const VERSION: &str = "v1.3.0"; 10 | 11 | pub struct VersionResponse { 12 | pub version: String, 13 | pub downloads: Vec, 14 | } 15 | 16 | impl VersionResponse { 17 | pub fn request_latest_version( 18 | ) -> crossbeam_channel::Receiver>> { 19 | let (tx, rx) = crossbeam_channel::unbounded(); 20 | 21 | std::thread::spawn(move || { 22 | let runtime = tokio::runtime::Builder::new_current_thread() 23 | .enable_io() 24 | .build() 25 | .unwrap(); 26 | 27 | runtime.block_on(async { 28 | tx.send(VersionResponse::get_latest_version().await) 29 | .unwrap(); 30 | }); 31 | }); 32 | 33 | rx 34 | } 35 | 36 | async fn get_latest_version() -> Result> { 37 | let release = match reqwest::Client::new() 38 | .get("https://api.github.com/repos/Bash-09/tf2-bot-kicker-gui/releases/latest") 39 | .header("User-Agent", "tf2-bot-kicker-gui") 40 | .send() 41 | .await 42 | { 43 | Ok(it) => it, 44 | Err(err) => return Err(Box::new(err)), 45 | }; 46 | 47 | let text = match release.text().await { 48 | Ok(it) => it, 49 | Err(err) => return Err(Box::new(err)), 50 | }; 51 | let json: Value = match serde_json::from_str(&text) { 52 | Ok(it) => it, 53 | Err(err) => return Err(Box::new(err)), 54 | }; 55 | 56 | let version; 57 | if let Some(Value::String(v)) = json.get("tag_name") { 58 | version = v.to_string(); 59 | } else { 60 | version = "".to_string(); 61 | } 62 | 63 | let mut response = VersionResponse { 64 | version, 65 | downloads: Vec::new(), 66 | }; 67 | 68 | if let Some(Value::Array(assets)) = json.get("assets") { 69 | for a in assets { 70 | if let Some(Value::String(url)) = a.get("browser_download_url") { 71 | response.downloads.push(url.to_string()); 72 | } 73 | } 74 | } 75 | 76 | Ok(response) 77 | } 78 | 79 | pub fn to_persistent_window(self) -> PersistentWindow { 80 | let file_names: Vec = self 81 | .downloads 82 | .iter() 83 | .map(|link| link.split('/').last().unwrap().to_string()) 84 | .collect(); 85 | 86 | PersistentWindow::new(Box::new(move |id, _, ctx, state| { 87 | let mut open = true; 88 | 89 | egui::Window::new("New version available") 90 | .id(Id::new(id)) 91 | .anchor(Align2::CENTER_CENTER, Vec2::new(0.0, 0.0)) 92 | .collapsible(false) 93 | .resizable(false) 94 | .open(&mut open) 95 | .show(ctx, |ui| { 96 | ui.heading(&format!("Current version: {}", VERSION)); 97 | ui.heading(&format!("Latest version: {}", &self.version)); 98 | 99 | let ignored = state.settings.ignore_version == self.version; 100 | let mut ignore = ignored; 101 | ui.checkbox(&mut ignore, "Don't remind me for this version"); 102 | if ignore && !ignored { 103 | state.settings.ignore_version = self.version.clone(); 104 | } else if !ignore && ignored { 105 | state.settings.ignore_version = String::new(); 106 | } 107 | 108 | ui.separator(); 109 | 110 | ui.horizontal(|ui| { 111 | ui.label("Find the latest version on"); 112 | ui.add(egui::Hyperlink::from_label_and_url( 113 | "github", 114 | "https://github.com/Bash-09/tf2-bot-kicker-gui/releases/latest", 115 | )); 116 | }); 117 | ui.add_space(10.0); 118 | 119 | ui.label("Or download it directly:"); 120 | for (i, file) in file_names.iter().enumerate() { 121 | ui.add(egui::Hyperlink::from_label_and_url( 122 | file, 123 | &self.downloads[i], 124 | )); 125 | } 126 | }); 127 | open 128 | })) 129 | } 130 | } 131 | --------------------------------------------------------------------------------