diff --git a/.github/badges/node.svg b/.github/badges/node.svg index d968517..490c822 100644 --- a/.github/badges/node.svg +++ b/.github/badges/node.svg @@ -1,5 +1,5 @@ - - Node game coverage: 14.06% + + Node game coverage: 20.99% @@ -13,8 +13,8 @@ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bf2050d..fc4dd53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,17 @@ Games: - [Valheim](https://store.steampowered.com/app/892970/Valheim/) support. - [The Front](https://store.steampowered.com/app/2285150/The_Front/) support. - [Conan Exiles](https://store.steampowered.com/app/440900/Conan_Exiles/) support. +- [Post Scriptum](https://store.steampowered.com/app/736220/Post_Scriptum/) support. +- [Squad](https://store.steampowered.com/app/393380/Squad/) support. +- [Savage 2](https://savage2.net/) support. +- [Rising World](https://store.steampowered.com/app/324080/Rising_World/) support. +- [ATLAS](https://store.steampowered.com/app/834910/ATLAS/) support. +- [America's Army: Proving Grounds](https://store.steampowered.com/app/203290/Americas_Army_Proving_Grounds/) support. +- [Base Defense](https://store.steampowered.com/app/632730/Base_Defense/) support. +- [Zombie Panic: Source](https://store.steampowered.com/app/17500/Zombie_Panic_Source/) support. - Added a valve protocol query example. +- Made all of Just Cause 2: Multiplayer Response and Player fields public. +- [Mindustry](https://mindustrygame.github.io/) support. Protocols: - Added the unreal2 protocol and its associated games: Darkest Hour, Devastation, Killing Floor, Red Orchestra, Unreal Tournament 2003, Unreal Tournament 2004 (by @Douile). @@ -25,6 +35,9 @@ Game: - - Left 4 Dead: `left4dead` -> `l4d`. - - 7 Days to Die: `7d2d` in definitions and `sd2d` in game declaration -> `sdtd`. - - Quake 3 Arena: `quake3arena` -> `q3a`. +- - Unreal tournament 2003: `ut2003` -> `unrealtournament2003` +- - Unreal tournament 2004: `ut2004` -> `unrealtournament2004` +- - Darkest Hour: Europe '44-'45: `darkesthour` -> `dhe4445` - Minecraft: - - Legacy 1.5 and 1.3 were renamed to 1.4 and beta 1.8 respectively to show the lowest version they support, this change includes Structs, Enum and game id renames, also removed the "v" from the game definition name. - - Moved the Minecraft protocol implementation in the games folder as its proprietary. diff --git a/Cargo.toml b/Cargo.toml index d25d3e8..739a816 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,5 @@ [workspace] -name = "gamedig-workspace" -members = ["crates/cli", "crates/lib"] +members = ["crates/cli", "crates/lib", "crates/id-tests"] # Edition 2021, uses resolver = 2 resolver = "2" diff --git a/GAMES.md b/GAMES.md index 5d7bad6..a2ab39a 100644 --- a/GAMES.md +++ b/GAMES.md @@ -65,12 +65,20 @@ Beware of the `Notes` column, as it contains information about query port offset | Valheim | VALHEIM | Valve | Query Port offset: 1. Does not respond to the A2S rules. | | The Front | THEFRONT | Valve | Responds with wrong values on `name` (gives out a SteamID instead of the server name) and `players_maximum` (always 200). | | Conan Exiles | CONANEXILES | Valve | Does not respond to the players query. | -| Darkest Hour: Europe '44-'45 | DARKESTHOUR | Unreal2 | Query port offset: 1 | -| Devastation | DEVASTATION | Unreal2 | Query port offset: 1 | -| Killing Floor | KILLINGFLOOR | Unreal2 | Query port offset: 1 | -| Red Orchestra | REDORCHESTRA | Unreal2 | Query port offset: 1 | -| Unreal Tournament 2003 | UT2003 | Unreal2 | Query port offset: 1 | -| Unreal Tournament 2004 | UT2004 | Unreal2 | Query port offset: 1 | +| Darkest Hour: Europe '44-'45 | DARKESTHOUR | Unreal2 | Query port offset: 1 | +| Devastation | DEVASTATION | Unreal2 | Query port offset: 1 | +| Killing Floor | KILLINGFLOOR | Unreal2 | Query port offset: 1 | +| Red Orchestra | REDORCHESTRA | Unreal2 | Query port offset: 1 | +| Unreal Tournament 2003 | UT2003 | Unreal2 | Query port offset: 1 | +| Unreal Tournament 2004 | UT2004 | Unreal2 | Query port offset: 1 | +| Post Scriptum | POSTSCRIPTUM | Valve | | +| Squad | SQUAD | Valve | | +| Savage 2 | SAVAGE2 | Proprietary | | +| Rising World | RISINGWORLD | Valve | Query port offset: -1 | +| ATLAS | ATLAS | Valve | Query port offset: 51800 | +| America's Army: Proving Grounds | AAPG | Valve | Query port: 27020. Does not respond to the rules query. | +| Base Defense | BASEDEFENSE | Valve | Query port: 27015. Does not respond to the rules query. | +| Zombie Panic: Source | ZPS | Valve | Query port: 27015. | ## Planned to add support: _ diff --git a/PROTOCOLS.md b/PROTOCOLS.md index 6b1ef0c..ba4d942 100644 --- a/PROTOCOLS.md +++ b/PROTOCOLS.md @@ -1,13 +1,15 @@ A protocol is defined as proprietary if it is being used only for a single scope (or series, like Minecraft). # Supported protocols: -| Name | For | Proprietary? | Documentation reference | Notes | -|----------------|-------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Valve Protocol | Games | No | [Server Queries](https://developer.valvesoftware.com/wiki/Server_queries) | In some cases, the players details query might contain some 0-length named players. Multi-packet decompression not tested. | -| Minecraft | Games | Yes | Java: [List Server Protocol](https://wiki.vg/Server_List_Ping)
Bedrock: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/minecraftbedrock.js) | | -| GameSpy | Games | No | One: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/gamespy1.js) Two: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/gamespy2.js) Three: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/gamespy3.js) | These protocols are not really standardized, gamedig tries to get the most common fields amongst its supported games, if there are parsing problems, use the `query_vars` function. | -| Quake | Games | No | One: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/quake1.js) Two: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/quake2.js) Three: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/quake3.js) | | -| Unreal2 | Games | Yes | [Node-GameDig Source](https://github.com/gamedig/node-gamedig) | Sometimes servers send strings that node-gamedig would treat as latin1 that are UTF-8 encoded, when this happens the remove color code breaks because latin1 decodes the colour sequences differently. Some games provide additional info at the end of the server info packet, this is not currently handled (see the node implementation). Some games use a bot player to denote the team names, this is not currently handled. | +| Name | For | Proprietary? | Documentation reference | Notes | +|----------------------------|-------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Valve Protocol | Games | No | [Server Queries](https://developer.valvesoftware.com/wiki/Server_queries) | In some cases, the players details query might contain some 0-length named players. Multi-packet decompression not tested. | +| Minecraft | Games | Yes | Java: [List Server Protocol](https://wiki.vg/Server_List_Ping)
Bedrock: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/minecraftbedrock.js) | | +| GameSpy | Games | No | One: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/gamespy1.js) Two: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/gamespy2.js) Three: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/gamespy3.js) | These protocols are not really standardized, gamedig tries to get the most common fields amongst its supported games, if there are parsing problems, use the `query_vars` function. | +| Quake | Games | No | One: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/quake1.js) Two: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/quake2.js) Three: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/quake3.js) | | +| Just Cause 2: Multiplayer | Games | Yes | [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/jc2mp.js) | +| Unreal 2 | Games | No | [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/unreal2.js) | Sometimes servers send strings that node-gamedig would treat as latin1 that are UTF-8 encoded, when this happens the remove color code breaks because latin1 decodes the colour sequences differently. Some games provide additional info at the end of the server info packet, this is not currently handled (see the node implementation). Some games use a bot player to denote the team names, this is not currently handled. | +| Savage 2 | Games | Yes | [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/savage2.js) | | ## Planned to add support: _ diff --git a/README.md b/README.md index cda8c46..dddbc11 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Response (note that some games have a different structure): } ``` -Want to see more examples? Checkout the [examples](examples) folder. +Want to see more examples? Checkout the [examples](crates/lib/examples) folder. ## Documentation The documentation is available at [docs.rs](https://docs.rs/gamedig/latest/gamedig/). diff --git a/RESPONSES.md b/RESPONSES.md index 14415fe..a9c3174 100644 --- a/RESPONSES.md +++ b/RESPONSES.md @@ -5,53 +5,57 @@ In the case that a field that performs the same function exists in the current c # Response table -| Field | Generic | GameSpy(1) | GameSpy(2) | GameSpy(3) | Minecraft(Java) | Minecraft(Bedrock) | Valve | Quake | Unreal2 | Proprietary: FFOW | Proprietary: TheShip | Proprietary: JC2MP | -|----------------------|------------------|---------------------------|---------------|---------------------------|-----------------------|--------------------|-----------------------------------|---------------------------|--------------------------------|-------------------|---------------------------|--------------------| -| name | `Option` | `String` | `String` | `String` | | `String` | `String` | `String` | `String` | `String` | `String` | `String` | -| description | `Option` | | | | `String` | | | | | `String` | | `String` | -| game_mode | `Option` | `String` | | `String` | | `Option` | `String` | | `String` | `String` | `String` | | -| game_version | `Option` | `String` | | `String` | `String` | | `String` | `String` | | `String` | `String` | `String` | -| map | `Option` | `String` | `String` | `String` | | `Option` | `String` | `String` | `String` | `String` | `String` | | -| players_maxmimum | `u32` | `u32` | `u32` | `u32` | `u32` | `u32` | `u8` | `u8` | `u32` | `u8` | `u8` | `u32` | -| players_online | `u32` | `u32` | `u32` | `u32` | `u32` | `u32` | `u8` | `u8` | `u32` | `u8` | `u8` | `u32` | -| players_bots | `Option` | | | | | | `u8` | | | | `u8` | | -| has_password | `Option` | `bool` | `bool` | `bool` | | | `bool` | | | `bool` | `bool` | `bool` | -| players_minimum | | `Option` | `Option` | `Option` | | | | | | | | | -| players | | `Vec` | `Vec` | `Vec` | `Option>` | | `Option>` | `Vec

` | `Vec` | | `Vec` | `Vec` | -| tournament | | `bool` | | `bool` | | | | | | | | | -| unused_entries | | `Hashmap` | | `HashMap` | | | | `HashMap` | | | | | -| teams | | | `Vec` | `Vec` | | | | | | | | | -| protocol_version | | | | | `i32` | `String` | `u8` | | | `u8` | `u8` | | -| server_type | | | | | `Server` | `Server` | `Server` | | | | `Server` | | -| rules | | | | | | | `Option>` | | `HashMap>` | | `HashMap` | | -| environment_type | | | | | | | `Environment` | | | `Environment` | | | -| vac_secured | | | | | | | `bool` | | | `bool` | `bool` | | -| map_title | | `Option` | | | | | | | | | | | -| admin_contact | | `Option` | | | | | | | | | | | -| admin_name | | `Option` | | | | | | | | | | | -| favicon | | | | | `Option` | | | | | | | | -| previews_chat | | | | | `Option` | | | | | | | | -| enforces_secure_chat | | | | | `Option` | | | | | | | | -| edition | | | | | | `String` | | | | | | | -| id | | | | | | `String` | | | `String` | | | | -| the_ship | | | | | | | `Option` | | | | | | -| is_mod | | | | | | | `bool` | | | | | | -| extra_data | | | | | | | `Option` | | | | | | -| mod_data | | | | | | | `Option` | | | | | | -| folder | | | | | | | `String` | | | | | | -| appid | | | | | | | `u32` | | | | | | -| active_mod | | | | | | | | | | `String` | | | -| round | | | | | | | | | | `u8` | | | -| rounds_maximum | | | | | | | | | | `u8` | | | -| time_left | | | | | | | | | | `u16` | | | -| port | | | | | | | | | `u32` | | `Option` | | -| steam_id | | | | | | | | | | | `Option` | | -| tv_port | | | | | | | | | | | `Option` | | -| tv_name | | | | | | | | | | | `Option` | | -| keywords | | | | | | | | | | | `Option` | | -| mode | | | | | | | | | | | `u8` | | -| witnesses | | | | | | | | | | | `u8` | | -| duration | | | | | | | | | | | `u8` | | -| query_port | | | | | | | | | `u32` | | | | -| ip | | | | | | | | | `String` | | | | -| mutators | | | | | | | | | `HashSet` | | | | +| Field | Generic | GameSpy(1) | GameSpy(2) | GameSpy(3) | Minecraft(Java) | Minecraft(Bedrock) | Valve | Quake | Unreal2 | Proprietary: FFOW | Proprietary: TheShip | Proprietary: JC2MP | Proprietary: Savage 2 | +|----------------------|----------|------------|------------|------------|-----------------|--------------------|---------------|-----------|------------|-------------------|----------------------|--------------------|-----------------------| +| name | `Option` | `String` | `String` | `String` | | `String` | `String` | `String` | `String` | `String` | `String` | `String` | `String` | +| description | `Option` | | | | `String` | | | | | `String` | | `String` | | +| game_mode | `Option` | `String` | | `String` | | `Option` | `String` | | `String` | `String` | `String` | | `String` | +| game_version | `Option` | `String` | | `String` | `String` | | `String` | `String` | | `String` | `String` | `String` | | +| map | `Option` | `String` | `String` | `String` | | `Option` | `String` | `String` | `String` | `String` | `String` | | `String` | +| players_maxmimum | `u32` | `u32` | `u32` | `u32` | `u32` | `u32` | `u8` | `u8` | `u32` | `u8` | `u8` | `u32` | `u8` | +| players_online | `u32` | `u32` | `u32` | `u32` | `u32` | `u32` | `u8` | `u8` | `u32` | `u8` | `u8` | `u32` | `u8` | +| players_bots | `Option` | | | | | | `u8` | | | | `u8` | | | +| has_password | `Option` | `bool` | `bool` | `bool` | | | `bool` | | | `bool` | `bool` | `bool` | | +| players_minimum | | `Option` | `Option` | `Option` | | | | | | | | | `u8` | +| players | | `Vec` | `Vec` | `Vec` | `Option>` | | `Option>` | `Vec ` | `Vec` | | `Vec` | `Vec` | | +| tournament | | `bool` | | `bool` | | | | | | | | | | +| unused_entries | | `Hashmap` | | `HashMap` | | | | `HashMap` | | | | | | +| teams | | | `Vec` | `Vec` | | | | | | | | | | +| protocol_version | | | | | `i32` | `String` | `u8` | | | `u8` | `u8` | | `String` | +| server_type | | | | | `Server` | `Server` | `Server` | | | | `Server` | | | +| rules | | | | | | | `Option>` | | `HashMap>` | | `HashMap` | | | +| environment_type | | | | | | | `Environment` | | | `Environment` | | | | +| vac_secured | | | | | | | `bool` | | | `bool` | `bool` | | | +| map_title | | `Option` | | | | | | | | | | | | +| admin_contact | | `Option` | | | | | | | | | | | | +| admin_name | | `Option` | | | | | | | | | | | | +| favicon | | | | | `Option` | | | | | | | | | +| previews_chat | | | | | `Option` | | | | | | | | | +| enforces_secure_chat | | | | | `Option` | | | | | | | | | +| edition | | | | | | `String` | | | | | | | | +| id | | | | | | `String` | | | `String` | | | | | +| the_ship | | | | | | | `Option` | | | | | | | +| is_mod | | | | | | | `bool` | | | | | | | +| extra_data | | | | | | | `Option` | | | | | | | +| mod_data | | | | | | | `Option` | | | | | | | +| folder | | | | | | | `String` | | | | | | | +| appid | | | | | | | `u32` | | | | | | | +| active_mod | | | | | | | | | | `String` | | | | +| round | | | | | | | | | | `u8` | | | | +| rounds_maximum | | | | | | | | | | `u8` | | | | +| time_left | | | | | | | | | | `u16` | | | | +| port | | | | | | | | | `u32` | | `Option` | | | +| steam_id | | | | | | | | | | | `Option` | | | +| tv_port | | | | | | | | | | | `Option` | | | +| tv_name | | | | | | | | | | | `Option` | | | +| keywords | | | | | | | | | | | `Option` | | | +| mode | | | | | | | | | | | `u8` | | | +| witnesses | | | | | | | | | | | `u8` | | | +| duration | | | | | | | | | | | `u8` | | | +| query_port | | | | | | | | | `u32` | | | | | +| ip | | | | | | | | | `String` | | | | | +| mutators | | | | | | | | | `HashSet` | | | | | +| next_map | | | | | | | | | | | | | `String` | +| location | | | | | | | | | | | | | `String` | +| level_minimum | | | | | | | | | | | | | `String` | +| time | | | | | | | | | | | | | `String` | diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index b106813..1201674 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -150,6 +150,7 @@ fn resolve_ip_or_domain>(host: T, extra_options: &mut Option, +} + +impl IDFail { + fn new(game_id: String, game_name: String, expected_id: String, rule_stack: Vec) -> Self { + Self { + game_id, + game_name, + expected_id, + rule_stack, + } + } +} + +/// Test a single game against the rules +pub fn test_game_name_rule( + seen_ids: &mut HashMap>, + id: &str, + mut game: GameNameParsed, + is_mod_name: bool, +) -> Vec { + let mut wrong_ids = Vec::new(); + + let mut rule_stack = Vec::new(); + if is_mod_name { + rule_stack.push(IDRule::IfModForQueriesProcessOnlyModName); + } + + let mut suffix = String::new(); + + // A game's identification is a lowercase alphanumeric string will and be forged + // following these rules: + if id.to_lowercase().ne(id) { + wrong_ids.push(IDFail::new( + id.to_owned(), + game.name.to_owned(), + id.to_lowercase(), + vec![IDRule::IDsMustBeLowerCase], + )); + } + + // 5. Roman numbering will be converted to arabic numbering (XIV -> 14). + game.words = { + let mut is_first = true; + game.words + .into_iter() + .map(|w| { + // First word will never be a numeral + if is_first { + is_first = false; + w + } else if let Ok(number) = roman_numeral::RomanNumeral::from_string(&w) { + rule_stack.push(IDRule::ConvertRomanNumeralsToArabic); + number.get_u32().to_string() + } else { + w + } + }) + .collect() + }; + + // 6. Unless numbers are at the end of a name, they will be considered words, + // but digits will always be used instead of the acronym (counter to #2) + // (Left 4 Dead -> l4d) unless they at the start position (7 Days to Die -> + // sdtd), if they are at the end (such as sequel number or the year), always + // append them (Team Fortress 2 -> teamfortress2, Unreal Tournament 2003 -> + // unrealtournament2003). + game.words = game + .words + .into_iter() + .flat_map(|w| { + let n = split_on_switch_between_alpha_numeric(&w); + if n.len() > 1 { + rule_stack.push(IDRule::NumbersAreTheirOwnWord); + } + n + }) + .collect(); + + // If first word is number make text + if !game.words.is_empty() && game.words[0].chars().next().unwrap().is_ascii_digit() { + game.words[0] = number_to_words::number_to_words(game.words[0].parse::().unwrap(), false); + rule_stack.push(IDRule::IfFirstWordNumberNoDigits); + } + + // If last word is number append full number + if let Some(last_word) = game.words.last() { + if last_word.chars().all(|c| c.is_ascii_digit()) { + suffix += &game.words.pop().unwrap(); + rule_stack.push(IDRule::IfLastWordNumberMustBeAppended); + } + } + + let main = if game.words.len() <= 2 { + // 1. Names composed of a maximum of two words (unless #4 applies) will result + // in an id where the words are concatenated (Dead Cells -> deadcells), + // acronyms in the name count as a single word (S.T.A.L.K.E.R. -> stalker). + + rule_stack.push(IDRule::TwoWordsOrLessUseFullWords); + + game.words + .iter() + .map(|w| w.trim_matches('-').to_owned()) + .collect::>() + .join("") + } else { + // 2. Names of more than two words shall be made into an acronym made of the + // initial letters (The Binding of Isaac -> tboi), hypenation composed words + // don't count as a single word, but of how many parts they are made of (Dino + // D-Day, 3 words, so ddd). + + rule_stack.push(IDRule::MoreThanTwoWordsMakeAcronym); + + game.words + .iter() + .map(|w| w.chars().next().unwrap()) + .filter(|c| c.is_alphanumeric()) + .collect() + }; + + let mut expected_id = format!("{}{}", main, suffix).to_lowercase(); + + if let Some(other_game_name_words) = seen_ids.get(&expected_id) { + let mut game_names_same = other_game_name_words.len() == game.words.len(); + // Check all words in game name are the same + if game_names_same { + for i in 0 .. game.words.len() { + if game.words[i].to_lowercase() != other_game_name_words[i].to_lowercase() { + game_names_same = false; + break; + } + } + } + + if game_names_same { + if let Some(year) = game.year { + // 3. If a game has the exact name as a previously existing id's game (Star Wars + // Battlefront 2, the 2005 and 2017 one), append the release year to the + // newer id (2005 would be swbf2 (suppose we already have this one supported) + // and 2017 would be swbf22017). + + rule_stack.push(IDRule::IfIDDuplicateSameGameAppendYearToNewer); + expected_id = format!("{}{}", expected_id, year).to_lowercase(); + } else if let Some(protocol) = game.optional_parts.first() { + // 7. If a game supports multiple protocols, multiple entries will be done for + // said game where the edition/protocol name (first disposable in this order) + // will be appended to the game name (Minecraft is divided by 2 editions, + // Java and Bedrock which will be minecraftjava and minecraftbedrock + // respectively) and one more entry can be added by the base name of the game + // which queries in a group said supported protocols to make generic queries + // easier and disposable. + + rule_stack.push(IDRule::IfIDDuplicateSameGameAppendProtocol); + + // Parse the protocol as a game name so we can remove all non-valid characters + let protocol_parsed = extract_game_parts_from_name(protocol); + + expected_id = format!("{}{}", expected_id, protocol_parsed.words.concat(),); + } + } + } + + // 4. If a new id (Day of Dragons -> dod) results in an id that already exists + // (Day of Defeat -> dod), then the new name should ignore rule #2 (Day of + // Dragons -> dayofdragons). + if seen_ids.contains_key(&expected_id) { + rule_stack.push(IDRule::IfIDDuplicateNoAcronym); + + let main = game + .words + .iter() + .map(|w| w.trim_matches('-').to_owned()) + .collect::>() + .join(""); + + expected_id = format!("{}{}", main, suffix).to_lowercase(); + } + + // 8. If its actually about a mod that adds the ability for queries to be + // performed, process only the mod name. + if !is_mod_name && id != expected_id { + if let Some((_, mod_game)) = game.name.split_once('-') { + let mut result = test_game_name_rule(seen_ids, id, extract_game_parts_from_name(mod_game), true); + + if result.is_empty() { + return result; + } else { + wrong_ids.append(&mut result); + } + } + } + + let duplicate = if seen_ids.insert(expected_id.clone(), game.words).is_some() { + rule_stack.push(IDRule::NoDuplicates); + true + } else { + false + }; + + // Check ID matches + if id != expected_id || duplicate { + wrong_ids.push(IDFail::new( + id.to_owned(), + game.name.to_owned(), + expected_id, + rule_stack, + )); + } + + wrong_ids +} + +#[derive(Clone, Debug)] +pub struct GameNameParsed<'a> { + name: &'a str, + words: Vec, + optional_parts: Vec<&'a str>, + year: Option, +} + +pub fn extract_game_parts_from_name(game: &str) -> GameNameParsed { + // Separate game name into words + // NOTE: we have to leave "-" in to prevent hyphenated prefixes being parsed as + // numerals + let mut optional_game_name_parts = Vec::new(); + + let (game, paren) = extract_bracketed_suffix(game); + + if let Some(paren) = paren { + optional_game_name_parts.push(paren); + } + + let mut number_accumulator: Option = None; + + // Filter map necessary to move out words + #[allow(clippy::unnecessary_filter_map)] + let game_name_words: Vec<_> = game + // First split all text on space or dash + .split_inclusive(&[' ', '-']) + // Remove whitespace surrounding words (leave in dash because it is important information) + .map(|w| w.trim()) + // If a word is entirely surrounded in brackets move it to optional parts + .filter_map(|w| { + if w.starts_with('(') && w.ends_with(')') { + optional_game_name_parts.push(w); + None + } else { + Some(w) + } + }) + // Remove all characters that aren't alphanumeric or dashses + .map(|w| { + w.replace( + |c: char| !c.is_ascii_digit() && !c.is_alphabetic() && c != '-', + "", + ) + }) + // Remove words that are empty (discounting strings that are just dashes) + .filter(|w| !w.trim_matches('-').is_empty()) + // Combine numbers that are seperated by dashes + // e.g. 44-45 = 4445 + // Panics if there is text after number with trailing dash (44-text) + .filter_map(|w| { + if number_accumulator.is_some() { + if let Some(maybe_number) = w.strip_suffix('-') { + if maybe_number.chars().all(|c| c.is_ascii_digit()) { + number_accumulator.as_mut().unwrap().push_str(maybe_number); + return None; + } else { + panic!("Text after number-"); + } + } else if w.chars().all(|c| c.is_ascii_digit()) { + let mut accumulator = number_accumulator.as_ref().unwrap().clone(); + number_accumulator = None; + accumulator.push_str(&w); + return Some(accumulator); + } else { + panic!("Text after number-"); + } + } else if let Some(maybe_number) = w.strip_suffix('-') { + if maybe_number.chars().all(|c| c.is_ascii_digit()) { + number_accumulator = Some(maybe_number.to_string()); + return None; + } + } + + Some(w) + }) + .collect(); + + let mut game_year: Option = None; + for optional_part in &optional_game_name_parts { + if let Some(game_year_text) = optional_part + .strip_prefix('(') + .and_then(|s| s.strip_suffix(')')) + { + if let Ok(year) = game_year_text.parse() { + game_year = Some(year); + break; + } + } else if let Ok(year) = optional_part.parse() { + game_year = Some(year); + break; + } + } + + GameNameParsed { + name: game, + words: game_name_words, + optional_parts: optional_game_name_parts, + year: game_year, + } +} + +/// Iterate game entries and validate the id matches current rules +pub fn test_game_name_rules<'a, I: Iterator>(games: I) -> Vec { + let mut wrong_ids = Vec::with_capacity(games.size_hint().0); + + let mut seen_ids: HashMap> = HashMap::new(); + + // We must sort games by year so that rule 3 is applied correctly + let mut sorted_games: Vec<_> = games + .map(|(id, game)| { + let game = extract_game_parts_from_name(game); + + (id, game) + }) + .collect(); + + sorted_games.sort_by(|(_, a_game), (_, b_game)| { + a_game + .year + .cmp(&b_game.year) + .then(a_game.name.len().cmp(&b_game.name.len())) + }); + + let game_count = sorted_games.len(); + + for (id, game) in sorted_games { + wrong_ids.append(&mut test_game_name_rule(&mut seen_ids, id, game, false)) + } + + if !wrong_ids.is_empty() { + for fail in &wrong_ids { + println!("{:#?}", fail); + } + let percentage = (wrong_ids.len() * 100) / game_count; + println!( + "{} ({}%) IDs didn't match naming rules", + wrong_ids.len(), + percentage + ); + } + + wrong_ids +} + +pub fn test_single_game_rule(id: &str, name: &str) -> Vec { test_game_name_rules(std::iter::once((id, name))) } + +#[cfg(test)] +mod id_tests { + use super::{test_game_name_rules, test_single_game_rule}; + #[test] + fn id_rule_one() { + assert!(test_single_game_rule("testgame", "Test Game").is_empty()); + assert!(test_single_game_rule("testgame", "TestGame").is_empty()); + + assert!(test_single_game_rule("deadcells", "Dead Cells").is_empty()); + assert!(test_single_game_rule("stalker", "S.T.A.L.K.E.R").is_empty()); + } + + #[test] + fn id_rule_two() { + assert!(test_single_game_rule("tgt", "Test Game Three").is_empty()); + assert!(test_single_game_rule("tgt", "Test Game-Three").is_empty()); + + assert!(test_single_game_rule("tboi", "The Binding of Isaac").is_empty()); + assert!(test_single_game_rule("ddd", "Dino D-Day").is_empty()); + } + + #[test] + fn id_rule_three() { + let games = vec![ + ("swb22017", "Star Wars Battlefront 2 (2017)"), + ("swb2", "Star Wars Battlefront 2 (2015)"), + ]; + assert!(test_game_name_rules(games.into_iter()).is_empty()); + } + + #[test] + fn id_rule_four() { + let games = vec![("dod", "Day of Defeat"), ("dayofdragons", "Day of Dragons")]; + assert!(test_game_name_rules(games.into_iter()).is_empty()); + } + + #[test] + fn id_rule_five() { + assert!(test_single_game_rule("gta14", "Grand Theft Auto XIV").is_empty()); + } + + #[test] + fn id_rule_six() { + assert!(test_single_game_rule("l4d", "Left 4 Dead").is_empty()); + assert!(test_single_game_rule("sdtd", "7 Days to Die").is_empty()); + assert!(test_single_game_rule("teamfortress2", "Team Fortress 2").is_empty()); + assert!(test_single_game_rule("unrealtournament2003", "Unreal Tournament 2003").is_empty()); + assert!(test_single_game_rule("dhe4445", "Darkest Hour: Europe '44-'45").is_empty()); + } + + #[test] + fn id_rule_seven() { + let games = vec![ + ("minecraft", "Minecraft"), + ("minecraftjava", "Minecraft (java)"), + ("minecraftbedrock", "Minecraft (bedrock)"), + ]; + assert!(test_game_name_rules(games.into_iter()).is_empty()); + } + + #[test] + fn id_rule_eight() { + assert!(test_single_game_rule("fivem", "Grand Theft Auto V - FiveM (2013)").is_empty()); + assert!(test_single_game_rule("jc3m", "Just Cause 3 - Multiplayer").is_empty()); + } +} diff --git a/crates/id-tests/src/main.rs b/crates/id-tests/src/main.rs new file mode 100644 index 0000000..8dfadaa --- /dev/null +++ b/crates/id-tests/src/main.rs @@ -0,0 +1,31 @@ +#![cfg(feature = "cli")] + +use std::collections::HashMap; + +/// Format for input games (the same as used in node-gamedig/lib/games.js). +type GamesInput = HashMap; + +#[derive(Debug, Clone, PartialEq, serde::Deserialize)] +struct Game { + name: String, +} + +use gamedig_id_tests::test_game_name_rules; + +fn main() { + let games: GamesInput = if let Some(file) = std::env::args_os().skip(1).next() { + let file = std::fs::OpenOptions::new().read(true).open(file).unwrap(); + + serde_json::from_reader(file).unwrap() + } else { + serde_json::from_reader(std::io::stdin().lock()).unwrap() + }; + + let failed = test_game_name_rules( + games + .iter() + .map(|(key, game)| (key.as_str(), game.name.as_str())), + ); + + assert!(failed.is_empty()); +} diff --git a/crates/id-tests/src/utils.rs b/crates/id-tests/src/utils.rs new file mode 100644 index 0000000..71dfe71 --- /dev/null +++ b/crates/id-tests/src/utils.rs @@ -0,0 +1,66 @@ +/// Split a str when characters swap between being digits and not digits. +pub fn split_on_switch_between_alpha_numeric(text: &str) -> Vec { + if text.is_empty() { + return vec![]; + } + + let mut parts = Vec::with_capacity(text.len()); + let mut current = Vec::with_capacity(text.len()); + + let mut iter = text.chars(); + let c = iter.next().unwrap(); + let mut last_was_numeric = c.is_ascii_digit(); + current.push(c); + + for c in iter { + if c.is_ascii_digit() == last_was_numeric { + current.push(c); + } else { + parts.push(current.iter().collect()); + current.clear(); + current.push(c); + last_was_numeric = !last_was_numeric; + } + } + + parts.push(current.into_iter().collect()); + + parts +} + +#[test] +fn split_correctly() { + assert_eq!( + split_on_switch_between_alpha_numeric("2D45A"), + &["2", "D", "45", "A"] + ); +} + +#[test] +fn split_symbol_broken_numbers() { + let game_name = super::extract_game_parts_from_name("Darkest Hour: Europe '44-'45"); + assert_eq!(game_name.words, &["Darkest", "Hour", "Europe", "4445"]); +} + +/// Extract parts at end of string enclosed in brackets. +pub fn extract_bracketed_suffix(text: &str) -> (&str, Option<&str>) { + if let Some(text) = text.strip_suffix(')') { + if let Some((text, extra)) = text.rsplit_once('(') { + return (text, Some(extra)); + } + } + + (text, None) +} + +#[test] +fn extract_brackets_correctly() { + assert_eq!( + extract_bracketed_suffix("no brackets here"), + ("no brackets here", None) + ); + assert_eq!( + extract_bracketed_suffix("Game name (with protocol here)"), + ("Game name ", Some("with protocol here")) + ); +} diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml index 8cc7cdb..15e370f 100644 --- a/crates/lib/Cargo.toml +++ b/crates/lib/Cargo.toml @@ -42,6 +42,9 @@ pcap-file = { version = "2.0", optional = true } pnet_packet = { version = "0.34", optional = true } lazy_static = { version = "1.4", optional = true } +[dev-dependencies] +gamedig-id-tests = { path = "../id-tests", no-default-features = true } + # Examples [[example]] name = "minecraft" diff --git a/crates/lib/examples/generic.rs b/crates/lib/examples/generic.rs index 2b47ffa..d54a0b3 100644 --- a/crates/lib/examples/generic.rs +++ b/crates/lib/examples/generic.rs @@ -1,8 +1,10 @@ use gamedig::{ - protocols::types::{CommonResponse, ExtraRequestSettings, TimeoutSettings}, + protocols::types::CommonResponse, query_with_timeout_and_extra_settings, + ExtraRequestSettings, GDResult, Game, + TimeoutSettings, GAMES, }; diff --git a/crates/lib/examples/valve_protocol_query.rs b/crates/lib/examples/valve_protocol_query.rs index a77b41f..f6ca6ff 100644 --- a/crates/lib/examples/valve_protocol_query.rs +++ b/crates/lib/examples/valve_protocol_query.rs @@ -1,6 +1,6 @@ -use gamedig::protocols::types::TimeoutSettings; use gamedig::protocols::valve; use gamedig::protocols::valve::{Engine, GatheringSettings}; +use gamedig::TimeoutSettings; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::time::Duration; diff --git a/crates/lib/src/buffer.rs b/crates/lib/src/buffer.rs index 420be8b..1a0c0aa 100644 --- a/crates/lib/src/buffer.rs +++ b/crates/lib/src/buffer.rs @@ -361,6 +361,55 @@ impl StringDecoder for Utf8Decoder { } } +/// A decoder for UTF-8 encoded strings prefixed by a single byte denoting the +/// string's length. +/// +/// This decoder uses a single null byte (`0x00`) as the default delimiter. +pub struct Utf8LengthPrefixedDecoder; + +impl StringDecoder for Utf8LengthPrefixedDecoder { + type Delimiter = [u8; 1]; + + const DELIMITER: Self::Delimiter = [0x00]; + + /// Decodes a UTF-8 string from the given data, updating the cursor position + /// accordingly. + fn decode_string(data: &[u8], cursor: &mut usize, delimiter: Self::Delimiter) -> GDResult { + // Find the maximum length of the string + let length = *data + .first() + .ok_or(PacketBad.context("Length of string not found"))?; + + // Find the position of the delimiter in the data. If the delimiter is not + // found, the length is returned. + let position = data + // Create an iterator over the data. + .iter() + .skip(1) + .take(length as usize) + // Find the position of the delimiter + .position(|&b| b == delimiter.as_ref()[0]) + // If the delimiter is not found, use the whole data slice. + .unwrap_or(length as usize); + + // Convert the data until the found position into a UTF-8 string. + let result = std::str::from_utf8( + // Take a slice of data until the position. + &data[1 .. position + 1] + ) + // If the data cannot be converted into a UTF-8 string, return an error + .map_err(|e| PacketBad.context(e))? + // Convert the resulting &str into a String + .to_owned(); + + // Update the cursor position + // The +1 is to skip t length + *cursor += position + 1; + + Ok(result) + } +} + /// A decoder for UTF-16 encoded strings. /// /// This decoder uses a pair of null bytes (`0x00, 0x00`) as the default diff --git a/crates/lib/src/errors/result.rs b/crates/lib/src/errors/result.rs index 4156e26..b7bdef1 100644 --- a/crates/lib/src/errors/result.rs +++ b/crates/lib/src/errors/result.rs @@ -12,7 +12,7 @@ mod tests { #[test] fn test_gdresult_ok() { let result: GDResult = Ok(42); - assert_eq!(result.unwrap(), 42); + assert_eq!(result, Ok(42)); } // Testing Err variant of the GDResult type diff --git a/crates/lib/src/games/definitions.rs b/crates/lib/src/games/definitions.rs index 7f55a65..18c736d 100644 --- a/crates/lib/src/games/definitions.rs +++ b/crates/lib/src/games/definitions.rs @@ -9,7 +9,7 @@ use crate::protocols::valve::GatheringSettings; use phf::{phf_map, Map}; macro_rules! game { - ($name: literal, $default_port: literal, $protocol: expr) => { + ($name: literal, $default_port: expr, $protocol: expr) => { game!( $name, $default_port, @@ -18,7 +18,7 @@ macro_rules! game { ) }; - ($name: literal, $default_port: literal, $protocol: expr, $extra_request_settings: expr) => { + ($name: literal, $default_port: expr, $protocol: expr, $extra_request_settings: expr) => { Game { name: $name, default_port: $default_port, @@ -39,13 +39,24 @@ pub static GAMES: Map<&'static str, Game> = phf_map! { "minecraftlegacy16" => game!("Minecraft (legacy 1.6)", 25565, Protocol::PROPRIETARY(ProprietaryProtocol::Minecraft(Some(Server::Legacy(LegacyGroup::V1_6))))), "minecraftlegacy14" => game!("Minecraft (legacy 1.4)", 25565, Protocol::PROPRIETARY(ProprietaryProtocol::Minecraft(Some(Server::Legacy(LegacyGroup::V1_4))))), "minecraftlegacyb18" => game!("Minecraft (legacy b1.8)", 25565, Protocol::PROPRIETARY(ProprietaryProtocol::Minecraft(Some(Server::Legacy(LegacyGroup::VB1_8))))), + "aapg" => game!("America's Army: Proving Grounds", 27020, Protocol::Valve(Engine::new(203_290)), GatheringSettings { + players: true, + rules: false, + check_app_id: true, + }.into_extra()), "alienswarm" => game!("Alien Swarm", 27015, Protocol::Valve(Engine::new(630))), "aoc" => game!("Age of Chivalry", 27015, Protocol::Valve(Engine::new(17510))), "a2oa" => game!("ARMA 2: Operation Arrowhead", 2304, Protocol::Valve(Engine::new(33930))), "ase" => game!("ARK: Survival Evolved", 27015, Protocol::Valve(Engine::new(346_110))), "asrd" => game!("Alien Swarm: Reactive Drop", 2304, Protocol::Valve(Engine::new(563_560))), + "atlas" => game!("ATLAS", 57561, Protocol::Valve(Engine::new(834_910))), "avorion" => game!("Avorion", 27020, Protocol::Valve(Engine::new(445_220))), "barotrauma" => game!("Barotrauma", 27016, Protocol::Valve(Engine::new(602_960))), + "basedefense" => game!("Base Defense", 27015, Protocol::Valve(Engine::new(632_730)), GatheringSettings { + players: true, + rules: false, + check_app_id: true, + }.into_extra()), "battalion1944" => game!("Battalion 1944", 7780, Protocol::Valve(Engine::new(489_940))), "brainbread2" => game!("BrainBread 2", 27015, Protocol::Valve(Engine::new(346_330))), "battlefield1942" => game!("Battlefield 1942", 23000, Protocol::Gamespy(GameSpyVersion::One)), @@ -81,16 +92,24 @@ pub static GAMES: Map<&'static str, Game> = phf_map! { "l4d2" => game!("Left 4 Dead 2", 27015, Protocol::Valve(Engine::new(550))), "ohd" => game!("Operation: Harsh Doorstop", 27005, Protocol::Valve(Engine::new_with_dedicated(736_590, 950_900))), "onset" => game!("Onset", 7776, Protocol::Valve(Engine::new(1_105_810))), + "postscriptum" => game!("Post Scriptum", 10037, Protocol::Valve(Engine::new(736_220))), "projectzomboid" => game!("Project Zomboid", 16261, Protocol::Valve(Engine::new(108_600))), "quake1" => game!("Quake 1", 27500, Protocol::Quake(QuakeVersion::One)), "quake2" => game!("Quake 2", 27910, Protocol::Quake(QuakeVersion::Two)), "q3a" => game!("Quake 3 Arena", 27960, Protocol::Quake(QuakeVersion::Three)), + "risingworld" => game!("Rising World", 4254, Protocol::Valve(Engine::new(324_080)), GatheringSettings { + players: true, + rules: false, + check_app_id: true, + }.into_extra()), "ror2" => game!("Risk of Rain 2", 27016, Protocol::Valve(Engine::new(632_360))), "rust" => game!("Rust", 27015, Protocol::Valve(Engine::new(252_490))), + "savage2" => game!("Savage 2", 11235, Protocol::PROPRIETARY(ProprietaryProtocol::Savage2)), "sco" => game!("Sven Co-op", 27015, Protocol::Valve(Engine::new_gold_src(false))), "sdtd" => game!("7 Days to Die", 26900, Protocol::Valve(Engine::new(251_570))), "sof2" => game!("Soldier of Fortune 2", 20100, Protocol::Quake(QuakeVersion::Three)), "serioussam" => game!("Serious Sam", 25601, Protocol::Gamespy(GameSpyVersion::One)), + "squad" => game!("Squad", 27165, Protocol::Valve(Engine::new(393_380))), "theforest" => game!("The Forest", 27016, Protocol::Valve(Engine::new(556_450))), "thefront" => game!("The Front", 27015, Protocol::Valve(Engine::new(2_285_150))), "teamfortress2" => game!("Team Fortress 2", 27015, Protocol::Valve(Engine::new(440))), @@ -106,10 +125,12 @@ pub static GAMES: Map<&'static str, Game> = phf_map! { "vrising" => game!("V Rising", 27016, Protocol::Valve(Engine::new(1_604_030))), "jc2m" => game!("Just Cause 2: Multiplayer", 7777, Protocol::PROPRIETARY(ProprietaryProtocol::JC2M)), "warsow" => game!("Warsow", 44400, Protocol::Quake(QuakeVersion::Three)), - "darkesthour" => game!("Darkest Hour: Europe '44-'45 (2008)", 7758, Protocol::Unreal2), + "dhe4445" => game!("Darkest Hour: Europe '44-'45 (2008)", 7758, Protocol::Unreal2), "devastation" => game!("Devastation (2003)", 7778, Protocol::Unreal2), "killingfloor" => game!("Killing Floor", 7708, Protocol::Unreal2), "redorchestra" => game!("Red Orchestra", 7759, Protocol::Unreal2), - "ut2003" => game!("Unreal Tournament 2003", 7758, Protocol::Unreal2), - "ut2004" => game!("Unreal Tournament 2004", 7778, Protocol::Unreal2), + "unrealtournament2003" => game!("Unreal Tournament 2003", 7758, Protocol::Unreal2), + "unrealtournament2004" => game!("Unreal Tournament 2004", 7778, Protocol::Unreal2), + "zps" => game!("Zombie Panic: Source", 27015, Protocol::Valve(Engine::new(17_500))), + "mindustry" => game!("Mindustry", crate::games::mindustry::DEFAULT_PORT, Protocol::PROPRIETARY(ProprietaryProtocol::Mindustry)), }; diff --git a/crates/lib/src/games/ffow/mod.rs b/crates/lib/src/games/ffow/mod.rs new file mode 100644 index 0000000..db37a19 --- /dev/null +++ b/crates/lib/src/games/ffow/mod.rs @@ -0,0 +1,8 @@ +/// The implementation. +/// Reference: [Node-GameGig](https://github.com/gamedig/node-gamedig/blob/master/protocols/ffow.js) +pub mod protocol; +/// All types used by the implementation. +pub mod types; + +pub use protocol::*; +pub use types::*; diff --git a/crates/lib/src/games/ffow.rs b/crates/lib/src/games/ffow/protocol.rs similarity index 50% rename from crates/lib/src/games/ffow.rs rename to crates/lib/src/games/ffow/protocol.rs index 9cbbdfc..9eb7423 100644 --- a/crates/lib/src/games/ffow.rs +++ b/crates/lib/src/games/ffow/protocol.rs @@ -1,64 +1,11 @@ use crate::buffer::{Buffer, Utf8Decoder}; -use crate::protocols::types::{CommonResponse, TimeoutSettings}; +use crate::games::ffow::types::Response; +use crate::protocols::types::TimeoutSettings; use crate::protocols::valve::{Engine, Environment, Server, ValveProtocol}; -use crate::protocols::GenericResponse; use crate::GDResult; use byteorder::LittleEndian; -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; use std::net::{IpAddr, SocketAddr}; -/// The query response. -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct Response { - /// Protocol used by the server. - pub protocol_version: u8, - /// Name of the server. - pub name: String, - /// Map name. - pub active_mod: String, - /// Running game mode. - pub game_mode: String, - /// The version that the server is running on. - pub game_version: String, - /// Description of the server. - pub description: String, - /// Current map. - pub map: String, - /// Number of players on the server. - pub players_online: u8, - /// Maximum number of players the server reports it can hold. - pub players_maximum: u8, - /// Dedicated, NonDedicated or SourceTV - pub server_type: Server, - /// The Operating System that the server is on. - pub environment_type: Environment, - /// Indicates whether the server requires a password. - pub has_password: bool, - /// Indicates whether the server uses VAC. - pub vac_secured: bool, - /// Current round index. - pub round: u8, - /// Maximum amount of rounds. - pub rounds_maximum: u8, - /// Time left for the current round in seconds. - pub time_left: u16, -} - -impl CommonResponse for Response { - fn as_original(&self) -> GenericResponse { GenericResponse::FFOW(self) } - - fn name(&self) -> Option<&str> { Some(&self.name) } - fn game_mode(&self) -> Option<&str> { Some(&self.game_mode) } - fn description(&self) -> Option<&str> { Some(&self.description) } - fn game_version(&self) -> Option<&str> { Some(&self.game_version) } - fn map(&self) -> Option<&str> { Some(&self.map) } - fn has_password(&self) -> Option { Some(self.has_password) } - fn players_maximum(&self) -> u32 { self.players_maximum.into() } - fn players_online(&self) -> u32 { self.players_online.into() } -} - pub fn query(address: &IpAddr, port: Option) -> GDResult { query_with_timeout(address, port, None) } pub fn query_with_timeout( diff --git a/crates/lib/src/games/ffow/types.rs b/crates/lib/src/games/ffow/types.rs new file mode 100644 index 0000000..4a8d852 --- /dev/null +++ b/crates/lib/src/games/ffow/types.rs @@ -0,0 +1,56 @@ +use crate::protocols::types::CommonResponse; +use crate::protocols::valve::{Environment, Server}; +use crate::protocols::GenericResponse; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// The query response. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Response { + /// Protocol used by the server. + pub protocol_version: u8, + /// Name of the server. + pub name: String, + /// Map name. + pub active_mod: String, + /// Running game mode. + pub game_mode: String, + /// The version that the server is running on. + pub game_version: String, + /// Description of the server. + pub description: String, + /// Current map. + pub map: String, + /// Number of players on the server. + pub players_online: u8, + /// Maximum number of players the server reports it can hold. + pub players_maximum: u8, + /// Dedicated, NonDedicated or SourceTV + pub server_type: Server, + /// The Operating System that the server is on. + pub environment_type: Environment, + /// Indicates whether the server requires a password. + pub has_password: bool, + /// Indicates whether the server uses VAC. + pub vac_secured: bool, + /// Current round index. + pub round: u8, + /// Maximum amount of rounds. + pub rounds_maximum: u8, + /// Time left for the current round in seconds. + pub time_left: u16, +} + +impl CommonResponse for Response { + fn as_original(&self) -> GenericResponse { GenericResponse::FFOW(self) } + + fn name(&self) -> Option<&str> { Some(&self.name) } + fn game_mode(&self) -> Option<&str> { Some(&self.game_mode) } + fn description(&self) -> Option<&str> { Some(&self.description) } + fn game_version(&self) -> Option<&str> { Some(&self.game_version) } + fn map(&self) -> Option<&str> { Some(&self.map) } + fn has_password(&self) -> Option { Some(self.has_password) } + fn players_maximum(&self) -> u32 { self.players_maximum.into() } + fn players_online(&self) -> u32 { self.players_online.into() } +} diff --git a/crates/lib/src/games/jc2m.rs b/crates/lib/src/games/jc2m.rs deleted file mode 100644 index 3ad22b2..0000000 --- a/crates/lib/src/games/jc2m.rs +++ /dev/null @@ -1,129 +0,0 @@ -use crate::buffer::{Buffer, Utf8Decoder}; -use crate::protocols::gamespy::common::has_password; -use crate::protocols::gamespy::three::{data_to_map, GameSpy3}; -use crate::protocols::types::{CommonPlayer, CommonResponse, GenericPlayer, TimeoutSettings}; -use crate::protocols::GenericResponse; -use crate::GDErrorKind::{PacketBad, TypeParse}; -use crate::{GDErrorKind, GDResult}; -use byteorder::BigEndian; -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; -use std::net::{IpAddr, SocketAddr}; - -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct Player { - name: String, - steam_id: String, - ping: u16, -} - -impl CommonPlayer for Player { - fn as_original(&self) -> GenericPlayer { GenericPlayer::JCMP2(self) } - - fn name(&self) -> &str { &self.name } -} - -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct Response { - game_version: String, - description: String, - name: String, - has_password: bool, - players: Vec, - players_maximum: u32, - players_online: u32, -} - -impl CommonResponse for Response { - fn as_original(&self) -> GenericResponse { GenericResponse::JC2M(self) } - - fn game_version(&self) -> Option<&str> { Some(&self.game_version) } - fn description(&self) -> Option<&str> { Some(&self.description) } - fn name(&self) -> Option<&str> { Some(&self.name) } - fn has_password(&self) -> Option { Some(self.has_password) } - fn players_maximum(&self) -> u32 { self.players_maximum } - fn players_online(&self) -> u32 { self.players_online } - - fn players(&self) -> Option> { - Some( - self.players - .iter() - .map(|p| p as &dyn CommonPlayer) - .collect(), - ) - } -} - -fn parse_players_and_teams(packet: &[u8]) -> GDResult> { - let mut buf = Buffer::::new(packet); - - let count = buf.read::()?; - let mut players = Vec::with_capacity(count as usize); - - while buf.remaining_length() != 0 { - players.push(Player { - name: buf.read_string::(None)?, - steam_id: buf.read_string::(None)?, - ping: buf.read::()?, - }); - } - - Ok(players) -} - -pub fn query(address: &IpAddr, port: Option) -> GDResult { query_with_timeout(address, port, None) } - -pub fn query_with_timeout( - address: &IpAddr, - port: Option, - timeout_settings: Option, -) -> GDResult { - let mut client = GameSpy3::new_custom( - &SocketAddr::new(*address, port.unwrap_or(7777)), - timeout_settings, - [0xFF, 0xFF, 0xFF, 0x02], - true, - )?; - - let packets = client.get_server_packets()?; - let data = packets - .get(0) - .ok_or(PacketBad.context("First packet missing"))?; - - let (mut server_vars, remaining_data) = data_to_map(data)?; - let players = parse_players_and_teams(&remaining_data)?; - - let players_maximum = server_vars - .remove("maxplayers") - .ok_or(PacketBad.context("Server variables missing maxplayers"))? - .parse() - .map_err(|e| TypeParse.context(e))?; - let players_online = match server_vars.remove("numplayers") { - None => players.len(), - Some(v) => { - let reported_players = v.parse().map_err(|e| TypeParse.context(e))?; - match reported_players < players.len() { - true => players.len(), - false => reported_players, - } - } - } as u32; - - Ok(Response { - game_version: server_vars - .remove("version") - .ok_or(GDErrorKind::PacketBad)?, - description: server_vars - .remove("description") - .ok_or(GDErrorKind::PacketBad)?, - name: server_vars - .remove("hostname") - .ok_or(GDErrorKind::PacketBad)?, - has_password: has_password(&mut server_vars)?, - players, - players_maximum, - players_online, - }) -} diff --git a/crates/lib/src/games/jc2m/mod.rs b/crates/lib/src/games/jc2m/mod.rs new file mode 100644 index 0000000..642b155 --- /dev/null +++ b/crates/lib/src/games/jc2m/mod.rs @@ -0,0 +1,8 @@ +/// The implementation. +/// Reference: [Node-GameGig](https://github.com/gamedig/node-gamedig/blob/master/protocols/jc2mp.js) +pub mod protocol; +/// All types used by the implementation. +pub mod types; + +pub use protocol::*; +pub use types::*; diff --git a/crates/lib/src/games/jc2m/protocol.rs b/crates/lib/src/games/jc2m/protocol.rs new file mode 100644 index 0000000..b6e0461 --- /dev/null +++ b/crates/lib/src/games/jc2m/protocol.rs @@ -0,0 +1,75 @@ +use crate::buffer::{Buffer, Utf8Decoder}; +use crate::jc2m::{Player, Response}; +use crate::protocols::gamespy::common::has_password; +use crate::protocols::gamespy::three::{data_to_map, GameSpy3}; +use crate::protocols::types::TimeoutSettings; +use crate::GDErrorKind::{PacketBad, TypeParse}; +use crate::GDResult; +use byteorder::BigEndian; +use std::net::{IpAddr, SocketAddr}; + +fn parse_players_and_teams(packet: &[u8]) -> GDResult> { + let mut buf = Buffer::::new(packet); + + let count = buf.read::()?; + let mut players = Vec::with_capacity(count as usize); + + while buf.remaining_length() != 0 { + players.push(Player { + name: buf.read_string::(None)?, + steam_id: buf.read_string::(None)?, + ping: buf.read::()?, + }); + } + + Ok(players) +} + +pub fn query(address: &IpAddr, port: Option) -> GDResult { query_with_timeout(address, port, None) } + +pub fn query_with_timeout( + address: &IpAddr, + port: Option, + timeout_settings: Option, +) -> GDResult { + let mut client = GameSpy3::new_custom( + &SocketAddr::new(*address, port.unwrap_or(7777)), + timeout_settings, + [0xFF, 0xFF, 0xFF, 0x02], + true, + )?; + + let packets = client.get_server_packets()?; + let data = packets + .get(0) + .ok_or_else(|| PacketBad.context("First packet missing"))?; + + let (mut server_vars, remaining_data) = data_to_map(data)?; + let players = parse_players_and_teams(&remaining_data)?; + + let players_maximum = server_vars + .remove("maxplayers") + .ok_or_else(|| PacketBad.context("Server variables missing maxplayers"))? + .parse() + .map_err(|e| TypeParse.context(e))?; + let players_online = match server_vars.remove("numplayers") { + None => players.len(), + Some(v) => { + let reported_players = v.parse().map_err(|e| TypeParse.context(e))?; + match reported_players < players.len() { + true => players.len(), + false => reported_players, + } + } + } as u32; + + Ok(Response { + game_version: server_vars.remove("version").ok_or(PacketBad)?, + description: server_vars.remove("description").ok_or(PacketBad)?, + name: server_vars.remove("hostname").ok_or(PacketBad)?, + has_password: has_password(&mut server_vars)?, + players, + players_maximum, + players_online, + }) +} diff --git a/crates/lib/src/games/jc2m/types.rs b/crates/lib/src/games/jc2m/types.rs new file mode 100644 index 0000000..0f14a58 --- /dev/null +++ b/crates/lib/src/games/jc2m/types.rs @@ -0,0 +1,50 @@ +use crate::protocols::types::{CommonPlayer, CommonResponse, GenericPlayer}; +use crate::protocols::GenericResponse; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Player { + pub name: String, + pub steam_id: String, + pub ping: u16, +} + +impl CommonPlayer for Player { + fn as_original(&self) -> GenericPlayer { GenericPlayer::JCMP2(self) } + + fn name(&self) -> &str { &self.name } +} + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Response { + pub game_version: String, + pub description: String, + pub name: String, + pub has_password: bool, + pub players: Vec, + pub players_maximum: u32, + pub players_online: u32, +} + +impl CommonResponse for Response { + fn as_original(&self) -> GenericResponse { GenericResponse::JC2M(self) } + + fn game_version(&self) -> Option<&str> { Some(&self.game_version) } + fn description(&self) -> Option<&str> { Some(&self.description) } + fn name(&self) -> Option<&str> { Some(&self.name) } + fn has_password(&self) -> Option { Some(self.has_password) } + fn players_maximum(&self) -> u32 { self.players_maximum } + fn players_online(&self) -> u32 { self.players_online } + + fn players(&self) -> Option> { + Some( + self.players + .iter() + .map(|p| p as &dyn CommonPlayer) + .collect(), + ) + } +} diff --git a/crates/lib/src/games/mindustry/mod.rs b/crates/lib/src/games/mindustry/mod.rs new file mode 100644 index 0000000..dc3fb7c --- /dev/null +++ b/crates/lib/src/games/mindustry/mod.rs @@ -0,0 +1,25 @@ +//! Mindustry game ping (v146) +//! +//! [Reference](https://github.com/Anuken/Mindustry/blob/a2e5fbdedb2fc1c8d3c157bf344d10ad6d321442/core/src/mindustry/net/ArcNetProvider.java#L225-L259) + +use std::{net::IpAddr, net::SocketAddr}; + +use crate::{GDResult, TimeoutSettings}; + +use self::types::ServerData; + +pub mod types; + +pub mod protocol; + +/// Default mindustry server port +/// +/// [Reference](https://github.com/Anuken/Mindustry/blob/a2e5fbdedb2fc1c8d3c157bf344d10ad6d321442/core/src/mindustry/Vars.java#L141-L142) +pub const DEFAULT_PORT: u16 = 6567; + +/// Query a mindustry server. +pub fn query(ip: &IpAddr, port: Option, timeout_settings: &Option) -> GDResult { + let address = SocketAddr::new(*ip, port.unwrap_or(DEFAULT_PORT)); + + protocol::query_with_retries(&address, timeout_settings) +} diff --git a/crates/lib/src/games/mindustry/protocol.rs b/crates/lib/src/games/mindustry/protocol.rs new file mode 100644 index 0000000..a42d69a --- /dev/null +++ b/crates/lib/src/games/mindustry/protocol.rs @@ -0,0 +1,58 @@ +use std::net::SocketAddr; + +use crate::{ + buffer::{self, Buffer}, + socket::{Socket, UdpSocket}, + utils, + GDResult, + TimeoutSettings, +}; + +use super::types::ServerData; + +/// Mindustry max datagram packet size. +pub const MAX_BUFFER_SIZE: usize = 500; + +/// Send a ping packet. +/// +/// [Reference](https://github.com/Anuken/Mindustry/blob/a2e5fbdedb2fc1c8d3c157bf344d10ad6d321442/core/src/mindustry/net/ArcNetProvider.java#L248) +pub fn send_ping(socket: &mut UdpSocket) -> GDResult<()> { socket.send(&[-2i8 as u8, 1i8 as u8]) } + +/// Parse server data. +/// +/// [Reference](https://github.com/Anuken/Mindustry/blob/a2e5fbdedb2fc1c8d3c157bf344d10ad6d321442/core/src/mindustry/net/NetworkIO.java#L122-L135) +pub fn parse_server_data( + buffer: &mut Buffer, +) -> GDResult { + Ok(ServerData { + host: buffer.read_string::(None)?, + map: buffer.read_string::(None)?, + players: buffer.read()?, + wave: buffer.read()?, + version: buffer.read()?, + version_type: buffer.read_string::(None)?, + gamemode: buffer.read::()?.try_into()?, + player_limit: buffer.read()?, + description: buffer.read_string::(None)?, + mode_name: buffer.read_string::(None).ok(), + }) +} + +/// Query a Mindustry server (without retries). +pub fn query(address: &SocketAddr, timeout_settings: &Option) -> GDResult { + let mut socket = UdpSocket::new(address, timeout_settings)?; + + send_ping(&mut socket)?; + + let socket_data = socket.receive(Some(MAX_BUFFER_SIZE))?; + let mut buffer = Buffer::new(&socket_data); + + parse_server_data::(&mut buffer) +} + +/// Query a Mindustry server. +pub fn query_with_retries(address: &SocketAddr, timeout_settings: &Option) -> GDResult { + let retries = TimeoutSettings::get_retries_or_default(timeout_settings); + + utils::retry_on_timeout(retries, || query(address, timeout_settings)) +} diff --git a/crates/lib/src/games/mindustry/types.rs b/crates/lib/src/games/mindustry/types.rs new file mode 100644 index 0000000..c1e84f4 --- /dev/null +++ b/crates/lib/src/games/mindustry/types.rs @@ -0,0 +1,108 @@ +use crate::{ + protocols::types::{CommonResponse, GenericResponse}, + GDErrorKind, +}; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// Mindustry sever data +/// +/// [Reference](https://github.com/Anuken/Mindustry/blob/a2e5fbdedb2fc1c8d3c157bf344d10ad6d321442/core/src/mindustry/net/NetworkIO.java#L122-L135) +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq)] +pub struct ServerData { + pub host: String, + pub map: String, + pub players: i32, + pub wave: i32, + pub version: i32, + pub version_type: String, + pub gamemode: GameMode, + pub player_limit: i32, + pub description: String, + pub mode_name: Option, +} + +/// Mindustry game mode +/// +/// [Reference](https://github.com/Anuken/Mindustry/blob/a2e5fbdedb2fc1c8d3c157bf344d10ad6d321442/core/src/mindustry/game/Gamemode.java) +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq)] +pub enum GameMode { + Survival, + Sandbox, + Attack, + PVP, + Editor, +} + +impl TryFrom for GameMode { + type Error = GDErrorKind; + fn try_from(value: u8) -> Result { + use GameMode::*; + Ok(match value { + 0 => Survival, + 1 => Sandbox, + 2 => Attack, + 3 => PVP, + 4 => Editor, + _ => return Err(GDErrorKind::TypeParse), + }) + } +} + +impl GameMode { + fn as_str(&self) -> &'static str { + use GameMode::*; + match self { + Survival => "survival", + Sandbox => "sandbox", + Attack => "attack", + PVP => "pvp", + Editor => "editor", + } + } +} + +impl CommonResponse for ServerData { + fn as_original(&self) -> GenericResponse { GenericResponse::Mindustry(self) } + + fn players_online(&self) -> u32 { self.players.try_into().unwrap_or(0) } + fn players_maximum(&self) -> u32 { self.player_limit.try_into().unwrap_or(0) } + + fn game_mode(&self) -> Option<&str> { Some(self.gamemode.as_str()) } + + fn map(&self) -> Option<&str> { Some(&self.map) } + fn description(&self) -> Option<&str> { Some(&self.description) } +} + +#[cfg(test)] +mod test { + use crate::protocols::types::CommonResponse; + + use super::ServerData; + + #[test] + fn common_impl() { + let data = ServerData { + host: String::from("host"), + map: String::from("map"), + players: 5, + wave: 2, + version: 142, + version_type: String::from("steam"), + gamemode: super::GameMode::PVP, + player_limit: 20, + description: String::from("description"), + mode_name: Some(String::from("campaign")), + }; + + let common: &dyn CommonResponse = &data; + + assert_eq!(common.players_online(), 5); + assert_eq!(common.players_maximum(), 20); + assert_eq!(common.game_mode(), Some("pvp")); + assert_eq!(common.map(), Some("map")); + assert_eq!(common.description(), Some("description")); + } +} diff --git a/crates/lib/src/games/minecraft/types.rs b/crates/lib/src/games/minecraft/types.rs index db64792..4e6ed01 100644 --- a/crates/lib/src/games/minecraft/types.rs +++ b/crates/lib/src/games/minecraft/types.rs @@ -115,7 +115,7 @@ impl Default for RequestSettings { impl RequestSettings { /// Make a new *RequestSettings* with just the hostname, the protocol /// version defaults to -1 - pub fn new_just_hostname(hostname: String) -> Self { + pub const fn new_just_hostname(hostname: String) -> Self { Self { hostname, protocol_version: -1, diff --git a/crates/lib/src/games/mod.rs b/crates/lib/src/games/mod.rs index e8e5641..5088ffd 100644 --- a/crates/lib/src/games/mod.rs +++ b/crates/lib/src/games/mod.rs @@ -1,8 +1,5 @@ //! Currently supported games. -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; - pub mod gamespy; pub mod quake; pub mod unreal2; @@ -19,137 +16,23 @@ pub mod battalion1944; pub mod ffow; /// Just Cause 2: Multiplayer pub mod jc2m; +/// Mindustry +pub mod mindustry; /// Minecraft pub mod minecraft; +/// Savage 2 +pub mod savage2; /// The Ship pub mod theship; -use crate::protocols::gamespy::GameSpyVersion; -use crate::protocols::quake::QuakeVersion; -use crate::protocols::types::{CommonResponse, ExtraRequestSettings, ProprietaryProtocol, TimeoutSettings}; -use crate::protocols::{self, Protocol}; -use crate::GDResult; -use std::net::{IpAddr, SocketAddr}; +pub mod types; +pub use types::*; -/// Definition of a game -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Game { - /// Full name of the game - pub name: &'static str, - /// Default port used by game - pub default_port: u16, - /// The protocol the game's query uses - pub protocol: Protocol, - /// Request settings. - pub request_settings: ExtraRequestSettings, -} +pub mod query; +pub use query::*; #[cfg(feature = "game_defs")] mod definitions; #[cfg(feature = "game_defs")] pub use definitions::GAMES; - -/// Make a query given a game definition -#[inline] -pub fn query(game: &Game, address: &IpAddr, port: Option) -> GDResult> { - query_with_timeout_and_extra_settings(game, address, port, None, None) -} - -/// Make a query given a game definition and timeout settings -#[inline] -pub fn query_with_timeout( - game: &Game, - address: &IpAddr, - port: Option, - timeout_settings: Option, -) -> GDResult> { - query_with_timeout_and_extra_settings(game, address, port, timeout_settings, None) -} - -/// Make a query given a game definition, timeout settings, and extra settings -pub fn query_with_timeout_and_extra_settings( - game: &Game, - address: &IpAddr, - port: Option, - timeout_settings: Option, - extra_settings: Option, -) -> GDResult> { - let socket_addr = SocketAddr::new(*address, port.unwrap_or(game.default_port)); - Ok(match &game.protocol { - Protocol::Valve(engine) => { - protocols::valve::query( - &socket_addr, - *engine, - extra_settings - .or(Option::from(game.request_settings.clone())) - .map(ExtraRequestSettings::into), - timeout_settings, - ) - .map(Box::new)? - } - Protocol::Gamespy(version) => { - match version { - GameSpyVersion::One => protocols::gamespy::one::query(&socket_addr, timeout_settings).map(Box::new)?, - GameSpyVersion::Two => protocols::gamespy::two::query(&socket_addr, timeout_settings).map(Box::new)?, - GameSpyVersion::Three => { - protocols::gamespy::three::query(&socket_addr, timeout_settings).map(Box::new)? - } - } - } - Protocol::Quake(version) => { - match version { - QuakeVersion::One => protocols::quake::one::query(&socket_addr, timeout_settings).map(Box::new)?, - QuakeVersion::Two => protocols::quake::two::query(&socket_addr, timeout_settings).map(Box::new)?, - QuakeVersion::Three => protocols::quake::three::query(&socket_addr, timeout_settings).map(Box::new)?, - } - } - Protocol::Unreal2 => { - protocols::unreal2::query( - &socket_addr, - &extra_settings - .map(ExtraRequestSettings::into) - .unwrap_or_default(), - timeout_settings, - ) - .map(Box::new)? - } - Protocol::PROPRIETARY(protocol) => { - match protocol { - ProprietaryProtocol::TheShip => { - theship::query_with_timeout(address, port, timeout_settings).map(Box::new)? - } - ProprietaryProtocol::FFOW => ffow::query_with_timeout(address, port, timeout_settings).map(Box::new)?, - ProprietaryProtocol::JC2M => jc2m::query_with_timeout(address, port, timeout_settings).map(Box::new)?, - ProprietaryProtocol::Minecraft(version) => { - match version { - Some(minecraft::Server::Java) => { - minecraft::protocol::query_java( - &socket_addr, - timeout_settings, - extra_settings.map(ExtraRequestSettings::into), - ) - .map(Box::new)? - } - Some(minecraft::Server::Bedrock) => { - minecraft::protocol::query_bedrock(&socket_addr, timeout_settings).map(Box::new)? - } - Some(minecraft::Server::Legacy(group)) => { - minecraft::protocol::query_legacy_specific(*group, &socket_addr, timeout_settings) - .map(Box::new)? - } - None => { - minecraft::protocol::query( - &socket_addr, - timeout_settings, - extra_settings.map(ExtraRequestSettings::into), - ) - .map(Box::new)? - } - } - } - } - } - }) -} diff --git a/crates/lib/src/games/query.rs b/crates/lib/src/games/query.rs new file mode 100644 index 0000000..2d2bf9c --- /dev/null +++ b/crates/lib/src/games/query.rs @@ -0,0 +1,118 @@ +//! Generic query functions + +use std::net::{IpAddr, SocketAddr}; + +use crate::games::types::Game; +use crate::games::{ffow, jc2m, mindustry, minecraft, savage2, theship}; +use crate::protocols; +use crate::protocols::gamespy::GameSpyVersion; +use crate::protocols::quake::QuakeVersion; +use crate::protocols::types::{CommonResponse, ExtraRequestSettings, ProprietaryProtocol, Protocol, TimeoutSettings}; +use crate::GDResult; + +/// Make a query given a game definition +#[inline] +pub fn query(game: &Game, address: &IpAddr, port: Option) -> GDResult> { + query_with_timeout_and_extra_settings(game, address, port, None, None) +} + +/// Make a query given a game definition and timeout settings +#[inline] +pub fn query_with_timeout( + game: &Game, + address: &IpAddr, + port: Option, + timeout_settings: Option, +) -> GDResult> { + query_with_timeout_and_extra_settings(game, address, port, timeout_settings, None) +} + +/// Make a query given a game definition, timeout settings, and extra settings +pub fn query_with_timeout_and_extra_settings( + game: &Game, + address: &IpAddr, + port: Option, + timeout_settings: Option, + extra_settings: Option, +) -> GDResult> { + let socket_addr = SocketAddr::new(*address, port.unwrap_or(game.default_port)); + Ok(match &game.protocol { + Protocol::Valve(engine) => { + protocols::valve::query( + &socket_addr, + *engine, + extra_settings + .or_else(|| Option::from(game.request_settings.clone())) + .map(ExtraRequestSettings::into), + timeout_settings, + ) + .map(Box::new)? + } + Protocol::Gamespy(version) => { + match version { + GameSpyVersion::One => protocols::gamespy::one::query(&socket_addr, timeout_settings).map(Box::new)?, + GameSpyVersion::Two => protocols::gamespy::two::query(&socket_addr, timeout_settings).map(Box::new)?, + GameSpyVersion::Three => { + protocols::gamespy::three::query(&socket_addr, timeout_settings).map(Box::new)? + } + } + } + Protocol::Quake(version) => { + match version { + QuakeVersion::One => protocols::quake::one::query(&socket_addr, timeout_settings).map(Box::new)?, + QuakeVersion::Two => protocols::quake::two::query(&socket_addr, timeout_settings).map(Box::new)?, + QuakeVersion::Three => protocols::quake::three::query(&socket_addr, timeout_settings).map(Box::new)?, + } + } + Protocol::Unreal2 => { + protocols::unreal2::query( + &socket_addr, + &extra_settings + .map(ExtraRequestSettings::into) + .unwrap_or_default(), + timeout_settings, + ) + .map(Box::new)? + } + Protocol::PROPRIETARY(protocol) => { + match protocol { + ProprietaryProtocol::Savage2 => { + savage2::query_with_timeout(address, port, timeout_settings).map(Box::new)? + } + ProprietaryProtocol::TheShip => { + theship::query_with_timeout(address, port, timeout_settings).map(Box::new)? + } + ProprietaryProtocol::FFOW => ffow::query_with_timeout(address, port, timeout_settings).map(Box::new)?, + ProprietaryProtocol::JC2M => jc2m::query_with_timeout(address, port, timeout_settings).map(Box::new)?, + ProprietaryProtocol::Mindustry => mindustry::query(address, port, &timeout_settings).map(Box::new)?, + ProprietaryProtocol::Minecraft(version) => { + match version { + Some(minecraft::Server::Java) => { + minecraft::protocol::query_java( + &socket_addr, + timeout_settings, + extra_settings.map(ExtraRequestSettings::into), + ) + .map(Box::new)? + } + Some(minecraft::Server::Bedrock) => { + minecraft::protocol::query_bedrock(&socket_addr, timeout_settings).map(Box::new)? + } + Some(minecraft::Server::Legacy(group)) => { + minecraft::protocol::query_legacy_specific(*group, &socket_addr, timeout_settings) + .map(Box::new)? + } + None => { + minecraft::protocol::query( + &socket_addr, + timeout_settings, + extra_settings.map(ExtraRequestSettings::into), + ) + .map(Box::new)? + } + } + } + } + } + }) +} diff --git a/crates/lib/src/games/savage2/mod.rs b/crates/lib/src/games/savage2/mod.rs new file mode 100644 index 0000000..88d57f2 --- /dev/null +++ b/crates/lib/src/games/savage2/mod.rs @@ -0,0 +1,8 @@ +/// The implementation. +/// Reference: [Node-GameGig](https://github.com/gamedig/node-gamedig/blob/master/protocols/savage2.js) +pub mod protocol; +/// All types used by the implementation. +pub mod types; + +pub use protocol::*; +pub use types::*; diff --git a/crates/lib/src/games/savage2/protocol.rs b/crates/lib/src/games/savage2/protocol.rs new file mode 100644 index 0000000..dbb1eb8 --- /dev/null +++ b/crates/lib/src/games/savage2/protocol.rs @@ -0,0 +1,37 @@ +use crate::buffer::{Buffer, Utf8Decoder}; +use crate::games::savage2::types::Response; +use crate::protocols::types::TimeoutSettings; +use crate::socket::{Socket, UdpSocket}; +use crate::GDResult; +use byteorder::LittleEndian; +use std::net::{IpAddr, SocketAddr}; + +pub fn query(address: &IpAddr, port: Option) -> GDResult { query_with_timeout(address, port, None) } + +pub fn query_with_timeout( + address: &IpAddr, + port: Option, + timeout_settings: Option, +) -> GDResult { + let addr = &SocketAddr::new(*address, port.unwrap_or(11235)); + let mut socket = UdpSocket::new(addr, &timeout_settings)?; + socket.send(&[0x01])?; + let data = socket.receive(None)?; + let mut buffer = Buffer::::new(&data); + + buffer.move_cursor(12)?; + + Ok(Response { + name: buffer.read_string::(None)?, + players_online: buffer.read::()?, + players_maximum: buffer.read::()?, + time: buffer.read_string::(None)?, + map: buffer.read_string::(None)?, + next_map: buffer.read_string::(None)?, + location: buffer.read_string::(None)?, + players_minimum: buffer.read::()?, + game_mode: buffer.read_string::(None)?, + protocol_version: buffer.read_string::(None)?, + level_minimum: buffer.read::()?, + }) +} diff --git a/crates/lib/src/games/savage2/types.rs b/crates/lib/src/games/savage2/types.rs new file mode 100644 index 0000000..ceedd98 --- /dev/null +++ b/crates/lib/src/games/savage2/types.rs @@ -0,0 +1,30 @@ +use crate::protocols::types::CommonResponse; +use crate::protocols::GenericResponse; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + pub name: String, + pub players_online: u8, + pub players_maximum: u8, + pub players_minimum: u8, + pub time: String, + pub map: String, + pub next_map: String, + pub location: String, + pub game_mode: String, + pub protocol_version: String, + pub level_minimum: u8, +} + +impl CommonResponse for Response { + fn as_original(&self) -> GenericResponse { GenericResponse::Savage2(self) } + + fn name(&self) -> Option<&str> { Some(&self.name) } + fn game_mode(&self) -> Option<&str> { Some(&self.game_mode) } + fn map(&self) -> Option<&str> { Some(&self.map) } + fn players_maximum(&self) -> u32 { self.players_maximum.into() } + fn players_online(&self) -> u32 { self.players_online.into() } +} diff --git a/crates/lib/src/games/theship/mod.rs b/crates/lib/src/games/theship/mod.rs new file mode 100644 index 0000000..b37a291 --- /dev/null +++ b/crates/lib/src/games/theship/mod.rs @@ -0,0 +1,8 @@ +/// The implementation. +/// Reference: [server queries](https://developer.valvesoftware.com/wiki/Server_queries) +pub mod protocol; +/// All types used by the implementation. +pub mod types; + +pub use protocol::*; +pub use types::*; diff --git a/crates/lib/src/games/theship/protocol.rs b/crates/lib/src/games/theship/protocol.rs new file mode 100644 index 0000000..c041ceb --- /dev/null +++ b/crates/lib/src/games/theship/protocol.rs @@ -0,0 +1,23 @@ +use crate::games::theship::types::Response; +use crate::protocols::types::TimeoutSettings; +use crate::protocols::valve; +use crate::protocols::valve::Engine; +use crate::GDResult; +use std::net::{IpAddr, SocketAddr}; + +pub fn query(address: &IpAddr, port: Option) -> GDResult { query_with_timeout(address, port, None) } + +pub fn query_with_timeout( + address: &IpAddr, + port: Option, + timeout_settings: Option, +) -> GDResult { + let valve_response = valve::query( + &SocketAddr::new(*address, port.unwrap_or(27015)), + Engine::new(2400), + None, + timeout_settings, + )?; + + Response::new_from_valve_response(valve_response) +} diff --git a/crates/lib/src/games/theship.rs b/crates/lib/src/games/theship/types.rs similarity index 82% rename from crates/lib/src/games/theship.rs rename to crates/lib/src/games/theship/types.rs index 5084200..9df679c 100644 --- a/crates/lib/src/games/theship.rs +++ b/crates/lib/src/games/theship/types.rs @@ -1,17 +1,10 @@ -use crate::{ - protocols::{ - types::{CommonPlayer, CommonResponse, GenericPlayer, TimeoutSettings}, - valve::{self, get_optional_extracted_data, Server, ServerPlayer}, - GenericResponse, - }, - GDErrorKind::PacketBad, - GDResult, -}; -use std::net::{IpAddr, SocketAddr}; - +use crate::protocols::types::{CommonPlayer, CommonResponse, GenericPlayer}; +use crate::protocols::valve::{get_optional_extracted_data, Server, ServerPlayer}; +use crate::protocols::{valve, GenericResponse}; +use crate::GDErrorKind::PacketBad; +use crate::GDResult; use std::collections::HashMap; -use crate::protocols::valve::Engine; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; @@ -127,20 +120,3 @@ impl Response { }) } } - -pub fn query(address: &IpAddr, port: Option) -> GDResult { query_with_timeout(address, port, None) } - -pub fn query_with_timeout( - address: &IpAddr, - port: Option, - timeout_settings: Option, -) -> GDResult { - let valve_response = valve::query( - &SocketAddr::new(*address, port.unwrap_or(27015)), - Engine::new(2400), - None, - timeout_settings, - )?; - - Response::new_from_valve_response(valve_response) -} diff --git a/crates/lib/src/games/types.rs b/crates/lib/src/games/types.rs new file mode 100644 index 0000000..3868e21 --- /dev/null +++ b/crates/lib/src/games/types.rs @@ -0,0 +1,20 @@ +//! Game related types + +use crate::protocols::types::{ExtraRequestSettings, Protocol}; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// Definition of a game +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Game { + /// Full name of the game + pub name: &'static str, + /// Default port used by game + pub default_port: u16, + /// The protocol the game's query uses + pub protocol: Protocol, + /// Request settings. + pub request_settings: ExtraRequestSettings, +} diff --git a/crates/lib/src/games/valve.rs b/crates/lib/src/games/valve.rs index acd612c..d70a030 100644 --- a/crates/lib/src/games/valve.rs +++ b/crates/lib/src/games/valve.rs @@ -8,8 +8,20 @@ game_query_mod!( Engine::new(33930), 2304 ); +game_query_mod!(basedefense, "Base Defense", Engine::new(632_730), 27015); game_query_mod!(alienswarm, "Alien Swarm", Engine::new(630), 27015); game_query_mod!(aoc, "Age of Chivalry", Engine::new(17510), 27015); +game_query_mod!( + aapg, + "America's Army: Proving Grounds", + Engine::new(203_290), + 27020, + GatheringSettings { + players: true, + rules: false, + check_app_id: true, + } +); game_query_mod!(ase, "ARK: Survival Evolved", Engine::new(346_110), 27015); game_query_mod!( asrd, @@ -17,6 +29,7 @@ game_query_mod!( Engine::new(563_560), 2304 ); +game_query_mod!(atlas, "ATLAS", Engine::new(834_910), 57561); game_query_mod!(avorion, "Avorion", Engine::new(445_220), 27020); game_query_mod!( ballisticoverkill, @@ -100,16 +113,19 @@ game_query_mod!( 27005 ); game_query_mod!(onset, "Onset", Engine::new(1_105_810), 7776); +game_query_mod!(postscriptum, "Post Scriptum", Engine::new(736_220), 10037); game_query_mod!( projectzomboid, "Project Zomboid", Engine::new(108_600), 16261 ); +game_query_mod!(risingworld, "Rising World", Engine::new(324_080), 4254); game_query_mod!(ror2, "Risk of Rain 2", Engine::new(632_360), 27016); game_query_mod!(rust, "Rust", Engine::new(252_490), 27015); game_query_mod!(sco, "Sven Co-op", Engine::new_gold_src(false), 27015); game_query_mod!(sdtd, "7 Days to Die", Engine::new(251_570), 26900); +game_query_mod!(squad, "Squad", Engine::new(393_380), 27165); game_query_mod!(teamfortress2, "Team Fortress 2", Engine::new(440), 27015); game_query_mod!( tfc, @@ -132,3 +148,4 @@ game_query_mod!( } ); game_query_mod!(vrising, "V Rising", Engine::new(1_604_030), 27016); +game_query_mod!(zps, "Zombie Panic: Source", Engine::new(17_500), 27015); diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index 38ec19c..e0214f3 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -15,7 +15,7 @@ //! //! ## Using a game definition //! ``` -//! use gamedig::games::{GAMES, query}; +//! use gamedig::{GAMES, query}; //! //! let game = GAMES.get("teamfortress2").unwrap(); // Get a game definition, the full list can be found in src/games/mod.rs //! let response = query(game, &"127.0.0.1".parse().unwrap(), None); // None will use the default port @@ -51,5 +51,10 @@ pub mod capture; pub use errors::*; #[cfg(feature = "games")] pub use games::*; +#[cfg(feature = "games")] +pub use query::*; #[cfg(feature = "services")] pub use services::*; + +// Re-export types needed to call games::query::query in the root +pub use protocols::types::{ExtraRequestSettings, TimeoutSettings}; diff --git a/crates/lib/src/protocols/gamespy/common.rs b/crates/lib/src/protocols/gamespy/common.rs index 9f78e17..18b672d 100644 --- a/crates/lib/src/protocols/gamespy/common.rs +++ b/crates/lib/src/protocols/gamespy/common.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; pub fn has_password(server_vars: &mut HashMap) -> GDResult { let password_value = server_vars .remove("password") - .ok_or(GDErrorKind::PacketBad.context("Missing password (exists) field"))? + .ok_or_else(|| GDErrorKind::PacketBad.context("Missing password (exists) field"))? .to_lowercase(); if let Ok(has) = password_value.parse::() { diff --git a/crates/lib/src/protocols/gamespy/protocols/one/protocol.rs b/crates/lib/src/protocols/gamespy/protocols/one/protocol.rs index a84c93d..32350ce 100644 --- a/crates/lib/src/protocols/gamespy/protocols/one/protocol.rs +++ b/crates/lib/src/protocols/gamespy/protocols/one/protocol.rs @@ -55,7 +55,7 @@ fn get_server_values_impl(socket: &mut UdpSocket) -> GDResult>) -> GDResult<(Vec, Vec< } let entry_data = data.get_mut(offset).ok_or(PacketBad)?; - entry_data.insert(field_name.to_string(), item); + entry_data.insert((*field_name).to_string(), item); offset += 1; } diff --git a/crates/lib/src/protocols/quake/client.rs b/crates/lib/src/protocols/quake/client.rs index 972d64a..e905097 100644 --- a/crates/lib/src/protocols/quake/client.rs +++ b/crates/lib/src/protocols/quake/client.rs @@ -78,7 +78,7 @@ fn get_server_values(bufferer: &mut Buffer) -> GDResult( Ok(Response { name: server_vars .remove("hostname") - .or(server_vars.remove("sv_hostname")) + .or_else(|| server_vars.remove("sv_hostname")) .ok_or(GDErrorKind::PacketBad)?, map: server_vars .remove("mapname") - .or(server_vars.remove("map")) + .or_else(|| server_vars.remove("map")) .ok_or(GDErrorKind::PacketBad)?, players_online: players.len() as u8, players_maximum: server_vars .remove("maxclients") - .or(server_vars.remove("sv_maxclients")) + .or_else(|| server_vars.remove("sv_maxclients")) .ok_or(GDErrorKind::PacketBad)? .parse() .map_err(|e| TypeParse.context(e))?, players, game_version: server_vars .remove("version") - .or(server_vars.remove("*version")), + .or_else(|| server_vars.remove("*version")), unused_entries: server_vars, }) } diff --git a/crates/lib/src/protocols/types.rs b/crates/lib/src/protocols/types.rs index 4249d5c..7986769 100644 --- a/crates/lib/src/protocols/types.rs +++ b/crates/lib/src/protocols/types.rs @@ -18,6 +18,8 @@ pub enum ProprietaryProtocol { Minecraft(Option), FFOW, JC2M, + Savage2, + Mindustry, } /// Enumeration of all valid protocol types @@ -41,6 +43,8 @@ pub enum GenericResponse<'a> { Valve(&'a valve::Response), Unreal2(&'a unreal2::Response), #[cfg(feature = "games")] + Mindustry(&'a crate::games::mindustry::types::ServerData), + #[cfg(feature = "games")] Minecraft(minecraft::VersionedResponse<'a>), #[cfg(feature = "games")] TheShip(&'a crate::games::theship::Response), @@ -48,6 +52,8 @@ pub enum GenericResponse<'a> { FFOW(&'a crate::games::ffow::Response), #[cfg(feature = "games")] JC2M(&'a crate::games::jc2m::Response), + #[cfg(feature = "games")] + Savage2(&'a crate::games::savage2::Response), } /// All player types @@ -228,33 +234,33 @@ impl TimeoutSettings { /// Get the number of retries if there are timeout settings else fall back /// to the default - pub const fn get_retries_or_default(timeout_settings: &Option) -> usize { + pub const fn get_retries_or_default(timeout_settings: &Option) -> usize { if let Some(timeout_settings) = timeout_settings { timeout_settings.get_retries() } else { - TimeoutSettings::const_default().get_retries() + Self::const_default().get_retries() } } /// Get the read and write durations if there are timeout settings else fall /// back to the defaults pub const fn get_read_and_write_or_defaults( - timeout_settings: &Option, + timeout_settings: &Option, ) -> (Option, Option) { if let Some(timeout_settings) = timeout_settings { (timeout_settings.get_read(), timeout_settings.get_write()) } else { - let default = TimeoutSettings::const_default(); + let default = Self::const_default(); (default.get_read(), default.get_write()) } } /// Get the connect duration given timeout settings or get the default. - pub const fn get_connect_or_default(timeout_settings: &Option) -> Option { + pub const fn get_connect_or_default(timeout_settings: &Option) -> Option { if let Some(timeout_settings) = timeout_settings { timeout_settings.get_connect() } else { - TimeoutSettings::const_default().get_connect() + Self::const_default().get_connect() } } @@ -337,22 +343,22 @@ impl ExtraRequestSettings { } /// [Sets protocol /// version](ExtraRequestSettings#structfield.protocol_version) - pub fn set_protocol_version(mut self, protocol_version: i32) -> Self { + pub const fn set_protocol_version(mut self, protocol_version: i32) -> Self { self.protocol_version = Some(protocol_version); self } /// [Sets gather players](ExtraRequestSettings#structfield.gather_players) - pub fn set_gather_players(mut self, gather_players: bool) -> Self { + pub const fn set_gather_players(mut self, gather_players: bool) -> Self { self.gather_players = Some(gather_players); self } /// [Sets gather rules](ExtraRequestSettings#structfield.gather_rules) - pub fn set_gather_rules(mut self, gather_rules: bool) -> Self { + pub const fn set_gather_rules(mut self, gather_rules: bool) -> Self { self.gather_rules = Some(gather_rules); self } /// [Sets check app ID](ExtraRequestSettings#structfield.check_app_id) - pub fn set_check_app_id(mut self, check_app_id: bool) -> Self { + pub const fn set_check_app_id(mut self, check_app_id: bool) -> Self { self.check_app_id = Some(check_app_id); self } diff --git a/crates/lib/src/protocols/unreal2/protocol.rs b/crates/lib/src/protocols/unreal2/protocol.rs index e5535ca..1c47e1a 100644 --- a/crates/lib/src/protocols/unreal2/protocol.rs +++ b/crates/lib/src/protocols/unreal2/protocol.rs @@ -33,10 +33,10 @@ pub(crate) struct Unreal2Protocol { impl Unreal2Protocol { pub fn new(address: &SocketAddr, timeout_settings: Option) -> GDResult { let socket = UdpSocket::new(address, &timeout_settings)?; - let retry_count = timeout_settings - .as_ref() - .map(|t| t.get_retries()) - .unwrap_or_else(|| TimeoutSettings::default().get_retries()); + let retry_count = timeout_settings.as_ref().map_or_else( + || TimeoutSettings::default().get_retries(), + TimeoutSettings::get_retries, + ); Ok(Self { socket, @@ -209,7 +209,7 @@ impl StringDecoder for Unreal2StringDecoder { let mut ucs2 = false; let mut length: usize = (*data .first() - .ok_or(PacketBad.context("Tried to decode string without length"))?) + .ok_or_else(|| PacketBad.context("Tried to decode string without length"))?) .into(); let mut start = 0; @@ -225,7 +225,7 @@ impl StringDecoder for Unreal2StringDecoder { // For UCS-2 strings, some unreal 2 games randomly insert an extra 0x01 here, // not included in the length. Skip it if present (hopefully this never happens // legitimately) - if let Some(1) = data[start ..].first() { + if data[start ..].first() == Some(&1) { start += 1; } } diff --git a/crates/lib/src/protocols/unreal2/types.rs b/crates/lib/src/protocols/unreal2/types.rs index 468e9e8..bab95c0 100644 --- a/crates/lib/src/protocols/unreal2/types.rs +++ b/crates/lib/src/protocols/unreal2/types.rs @@ -50,7 +50,7 @@ pub struct ServerInfo { impl ServerInfo { pub fn parse(buffer: &mut Buffer) -> GDResult { - Ok(ServerInfo { + Ok(Self { server_id: buffer.read()?, ip: buffer.read_string::(None)?, game_port: buffer.read()?, @@ -118,7 +118,7 @@ impl Players { /// Pre-allocate the vectors inside the players struct based on the provided /// capacity. pub fn with_capacity(capacity: usize) -> Self { - Players { + Self { players: Vec::with_capacity(capacity), // Allocate half as many bots as we don't expect there to be as many bots: Vec::with_capacity(capacity / 2), @@ -234,7 +234,7 @@ impl GatheringSettings { } impl Default for GatheringSettings { - fn default() -> Self { GatheringSettings::default() } + fn default() -> Self { Self::default() } } impl From for GatheringSettings { diff --git a/crates/lib/src/protocols/valve/protocol.rs b/crates/lib/src/protocols/valve/protocol.rs index 5d18cba..b4ec3c6 100644 --- a/crates/lib/src/protocols/valve/protocol.rs +++ b/crates/lib/src/protocols/valve/protocol.rs @@ -127,10 +127,10 @@ static PACKET_SIZE: usize = 6144; impl ValveProtocol { pub fn new(address: &SocketAddr, timeout_settings: Option) -> GDResult { let socket = UdpSocket::new(address, &timeout_settings)?; - let retry_count = timeout_settings - .as_ref() - .map(|t| t.get_retries()) - .unwrap_or_else(|| TimeoutSettings::default().get_retries()); + let retry_count = timeout_settings.as_ref().map_or_else( + || TimeoutSettings::default().get_retries(), + |t| t.get_retries(), + ); Ok(Self { socket, @@ -271,7 +271,7 @@ impl ValveProtocol { has_password, vac_secured, the_ship: None, - game_version: "".to_string(), // a version field only for the mod + game_version: String::new(), // a version field only for the mod extra_data: None, is_mod, mod_data, diff --git a/crates/lib/src/protocols/valve/types.rs b/crates/lib/src/protocols/valve/types.rs index ebe46ad..b0d9b92 100644 --- a/crates/lib/src/protocols/valve/types.rs +++ b/crates/lib/src/protocols/valve/types.rs @@ -310,7 +310,7 @@ impl GatheringSettings { } impl Default for GatheringSettings { - fn default() -> Self { GatheringSettings::default() } + fn default() -> Self { Self::default() } } impl From for GatheringSettings { diff --git a/crates/lib/src/services/valve_master_server/service.rs b/crates/lib/src/services/valve_master_server/service.rs index ee62c24..f9e992c 100644 --- a/crates/lib/src/services/valve_master_server/service.rs +++ b/crates/lib/src/services/valve_master_server/service.rs @@ -18,7 +18,7 @@ pub fn default_master_address() -> SocketAddr { fn construct_payload(region: Region, filters: &Option, last_ip: &str, last_port: u16) -> Vec { let filters_bytes: Vec = filters .as_ref() - .map_or_else(|| vec![0x00], |f| f.to_bytes()); + .map_or_else(|| vec![0x00], SearchFilters::to_bytes); let region_byte = &[region as u8]; diff --git a/crates/lib/src/socket.rs b/crates/lib/src/socket.rs index 0ba35ee..9dd1e91 100644 --- a/crates/lib/src/socket.rs +++ b/crates/lib/src/socket.rs @@ -75,11 +75,10 @@ pub struct TcpSocketImpl { impl Socket for TcpSocketImpl { fn new(address: &SocketAddr, timeout_settings: &Option) -> GDResult { - let socket = if let Some(timeout) = TimeoutSettings::get_connect_or_default(timeout_settings) { - net::TcpStream::connect_timeout(address, timeout) - } else { - net::TcpStream::connect(address) - }; + let socket = TimeoutSettings::get_connect_or_default(timeout_settings).map_or_else( + || net::TcpStream::connect(address), + |timeout| net::TcpStream::connect_timeout(address, timeout), + ); let socket = Self { socket: socket.map_err(|e| SocketConnect.context(e))?, diff --git a/crates/lib/tests/game_ids.rs b/crates/lib/tests/game_ids.rs new file mode 100644 index 0000000..d7925a9 --- /dev/null +++ b/crates/lib/tests/game_ids.rs @@ -0,0 +1,11 @@ +#![cfg(all(test, feature = "game_defs"))] + +use gamedig::GAMES; + +use gamedig_id_tests::test_game_name_rules; + +#[test] +fn check_definitions_match_name_rules() { + let wrong = test_game_name_rules(GAMES.entries().map(|(id, game)| (id.to_owned(), game.name))); + assert!(wrong.is_empty()); +}