use anyhow::Result; use tracing::info; use super::rcon::RconClient; /// Player information from CS2 RCON. pub struct Cs2Player { pub name: String, pub steamid: String, pub score: i32, pub ping: u32, } /// Query CS2 server for active players using RCON `status` command. pub async fn get_players(rcon_address: &str, rcon_password: &str) -> Result<(Vec, u32)> { let mut client = RconClient::connect(rcon_address, rcon_password).await?; let response = client.command("status").await?; let (players, max) = parse_status_response(&response); info!( count = players.len(), max = max, "CS2 player list retrieved" ); Ok((players, max)) } fn parse_status_response(response: &str) -> (Vec, u32) { let mut players = Vec::new(); let mut max_players = 0u32; let mut in_player_section = false; for line in response.lines() { let trimmed = line.trim(); // Parse max players from status line variants: // "players : X humans, Y bots (Z/M max)" // "players : X humans, Y bots (Z max)" if trimmed.starts_with("players") { if let Some(parsed_max) = parse_max_players_from_line(trimmed) { max_players = parsed_max; } } if trimmed.contains("---------players--------") || trimmed.starts_with("# userid") { in_player_section = true; continue; } if in_player_section && (trimmed == "#end" || trimmed.starts_with("---------")) { in_player_section = false; continue; } // Parse player lines for both old and current CS2 status formats. if in_player_section { if let Some((name, steamid)) = parse_player_line(trimmed) { players.push(Cs2Player { name, steamid, score: 0, ping: 0, }); } } } (players, max_players) } fn parse_max_players_from_line(line: &str) -> Option { let start = line.find('(')?; let end = line[start + 1..].find(')')? + start + 1; let inside = &line[start + 1..end]; inside .split(|c: char| !c.is_ascii_digit()) .filter(|s| !s.is_empty()) .filter_map(|s| s.parse::().ok()) .max() } fn parse_player_line(line: &str) -> Option<(String, String)> { // Skip table/header rows. if line.is_empty() || line.starts_with("id ") || line.contains("userid") || line.contains("steamid") || line.contains("adr name") { return None; } // Legacy format: # 2 "Player" STEAM_... if let Some(quote_start) = line.find('"') { let quote_end = line[quote_start + 1..].find('"')? + quote_start + 1; let name = line[quote_start + 1..quote_end].trim().to_string(); if name.is_empty() { return None; } let rest = line[quote_end + 1..].trim(); let steamid = rest.split_whitespace().next()?.to_string(); if steamid.is_empty() { return None; } return Some((name, steamid)); } // Current CS2 format: ... 'PlayerName' let quote_end = line.rfind('\'')?; let before_end = &line[..quote_end]; let quote_start = before_end.rfind('\'')?; if quote_start >= quote_end { return None; } let name = line[quote_start + 1..quote_end].trim().to_string(); if name.is_empty() { return None; } // New status output does not include steamid in player rows. Some((name, String::new())) } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_status_basic() { let response = r#"hostname: Test Server version : 2.0.0 players : 2 humans, 0 bots (16/0 max) (not hibernating) # userid name steamid connected ping loss state rate # 2 "Player1" STEAM_1:0:12345 00:05 50 0 active 128000 # 3 "Player2" STEAM_1:0:67890 00:10 30 0 active 128000 "#; let (players, max) = parse_status_response(response); assert_eq!(max, 16); assert_eq!(players.len(), 2); } #[test] fn test_parse_status_current_cs2_format() { let response = r#"Server: Running [0.0.0.0:27015] players : 1 humans, 2 bots (0 max) (not hibernating) (unreserved) ---------players-------- id time ping loss state rate adr name 65535 [NoChan] 0 0 challenging 0unknown '' 1 BOT 0 0 active 0 'Rezan' 2 00:21 11 0 active 786432 212.154.6.153:57008 'hibna' 3 BOT 0 0 active 0 'Squad' #end "#; let (players, max) = parse_status_response(response); assert_eq!(max, 0); assert_eq!(players.len(), 3); assert_eq!(players[0].name, "Rezan"); assert_eq!(players[1].name, "hibna"); assert_eq!(players[2].name, "Squad"); } }