Skip to main content

steam_client/services/
csgo.rs

1//! CS:GO specific service functionality.
2
3use std::collections::HashMap;
4
5use prost::Message;
6use steam_enums::{ECsgoGCMsg, EMsg, EResult};
7use steamid::SteamID;
8
9use crate::{error::SteamError, services::gc::GCProtoHeader, SteamClient};
10
11/// CS:GO App ID
12pub const APP_ID: u32 = 730;
13
14/// CS:GO Service for interacting with the Game Coordinator and Matchmaking
15/// Service.
16pub struct CSGOClient<'a> {
17    pub(crate) client: &'a mut SteamClient,
18}
19
20impl<'a> CSGOClient<'a> {
21    pub fn new(client: &'a mut SteamClient) -> Self {
22        Self { client }
23    }
24
25    /// Send a hello message to the CS:GO Game Coordinator.
26    ///
27    /// This is required to start a session with the CS:GO GC.
28    pub async fn send_hello(&mut self) -> Result<(), SteamError> {
29        let msg = steam_protos::CMsgGccStrike15V2MatchmakingClient2GcHello {};
30
31        self.client.send_to_gc_proto(APP_ID, ECsgoGCMsg::MatchmakingClient2GCHello as u32, &msg.encode_to_vec(), GCProtoHeader::default()).await
32    }
33
34    /// Request a player's CS:GO profile and await the response.
35    ///
36    /// Sends a `ClientRequestPlayersProfile` message to the GC and waits for
37    /// the `PlayersProfile` response (usually takes 400-800ms).
38    /// Returns the parsed profile(s).
39    pub async fn get_player_profile(&mut self, steam_id: SteamID) -> Result<Vec<crate::CsgoClientHello>, SteamError> {
40        use crate::services::gc::GCJobResponse;
41
42        // PlayersProfile response = 9128, 5s timeout matching Node.js
43        let rx = self.client.gc_jobs.create_job_with_timeout(APP_ID, ECsgoGCMsg::PlayersProfile as u32, std::time::Duration::from_secs(5));
44
45        // Send the request
46        self.client.request_players_profile(steam_id).await?;
47
48        // Wait for the response
49        let response = rx.await.map_err(|_| SteamError::ResponseTimeout)?;
50
51        match response {
52            GCJobResponse::Success(payload) => {
53                let profile_msg = steam_protos::CMsgGccStrike15V2PlayersProfile::decode(&payload[..]).map_err(|e| SteamError::bad_response(format!("Failed to decode PlayersProfile: {}", e)))?;
54
55                Ok(profile_msg.account_profiles.iter().map(crate::client::MessageHandler::build_csgo_client_hello).collect())
56            }
57            GCJobResponse::Timeout => Err(SteamError::ResponseTimeout),
58        }
59    }
60
61    /// Initiate a party search and return the results.
62    ///
63    /// Sends a Party_Search request to the CS:GO GC and waits for the
64    /// Party_SearchResults response.
65    pub async fn party_search(&mut self, prime: bool, game_type: u32) -> Result<Vec<crate::CsgoPartyEntry>, SteamError> {
66        use crate::services::gc::GCJobResponse;
67
68        let msg = steam_protos::CMsgGccStrike15V2PartySearch {
69            ver: Some(1), // Version?
70            apr: Some(if prime { 1 } else { 0 }),
71            ark: Some(0),
72            grps: vec![],
73            launcher: Some(0),
74            game_type: Some(game_type),
75        };
76
77        // Party_Search = 9191
78        // Register a job to wait for the response
79        let rx = self.client.gc_jobs.create_job(APP_ID, ECsgoGCMsg::Party_Search as u32);
80
81        // Send the request
82        self.client.send_to_gc_proto(APP_ID, ECsgoGCMsg::Party_Search as u32, &msg.encode_to_vec(), GCProtoHeader::default()).await?;
83
84        // Wait for the response
85        let response = rx.await.map_err(|_| SteamError::ResponseTimeout)?;
86
87        match response {
88            GCJobResponse::Success(payload) => {
89                // Parse the response
90                let results = steam_protos::CMsgGccStrike15V2PartySearchResults::decode(&payload[..]).map_err(|e| SteamError::bad_response(format!("Failed to decode PartySearchResults: {}", e)))?;
91
92                Ok(results
93                    .entries
94                    .into_iter()
95                    .map(|e| crate::CsgoPartyEntry {
96                        account_id: e.accountid.unwrap_or(0),
97                        lobby_id: e.id.unwrap_or(0),
98                        game_type: e.game_type.unwrap_or(0),
99                        loc: e.loc.unwrap_or(0),
100                    })
101                    .collect())
102            }
103            GCJobResponse::Timeout => Err(SteamError::ResponseTimeout),
104        }
105    }
106
107    /// Create a lobby via Matchmaking Service (MMS).
108    pub async fn create_lobby(&mut self, max_members: i32, lobby_type: i32) -> Result<u64, SteamError> {
109        let msg = steam_protos::CMsgClientMmsCreateLobby {
110            app_id: Some(APP_ID),
111            max_members: Some(max_members),
112            lobby_type: Some(lobby_type),
113            lobby_flags: Some(0),
114            cell_id: self.client.account.read().cell_id,
115            ..Default::default()
116        };
117
118        let response: steam_protos::CMsgClientMmsCreateLobbyResponse = self.client.send_request_and_wait(EMsg::ClientMMSCreateLobby, &msg).await?;
119
120        let eresult = EResult::from_i32(response.eresult.unwrap_or(2)).unwrap_or(EResult::Fail);
121        if eresult != EResult::OK {
122            return Err(SteamError::SteamResult(eresult));
123        }
124
125        Ok(response.steam_id_lobby.unwrap_or(0))
126    }
127
128    /// Invite a user to a lobby.
129    pub async fn invite_to_lobby(&mut self, lobby_id: u64, user_id: SteamID) -> Result<(), SteamError> {
130        let msg = steam_protos::CMsgClientMmsInviteToLobby { app_id: Some(APP_ID), steam_id_lobby: Some(lobby_id), steam_id_user_invited: Some(user_id.steam_id64()) };
131
132        self.client.send_message(EMsg::ClientMMSInviteToLobby, &msg).await
133    }
134
135    /// Set CS:GO Rich Presence (Fake Score).
136    ///
137    /// This allows setting competitive rank, wins, and other status information
138    /// that appears on the friends list.
139    pub async fn set_rich_presence(&mut self, rp: &CsgoRichPresence) -> Result<(), SteamError> {
140        self.client.upload_rich_presence(APP_ID, &rp.to_map()).await
141    }
142
143    /// Join an existing lobby.
144    ///
145    /// Returns detailed information about the lobby including current members.
146    pub async fn join_lobby(&mut self, lobby_id: u64) -> Result<JoinLobbyResult, SteamError> {
147        let persona_name = self.client.account.read().info.as_ref().map(|a| a.name.clone()).unwrap_or_default();
148
149        let msg = steam_protos::CMsgClientMmsJoinLobby { app_id: Some(APP_ID), steam_id_lobby: Some(lobby_id), persona_name: Some(persona_name) };
150
151        let response: steam_protos::CMsgClientMmsJoinLobbyResponse = self.client.send_request_and_wait(EMsg::ClientMMSJoinLobby, &msg).await?;
152
153        Ok(JoinLobbyResult {
154            lobby_id: response.steam_id_lobby.unwrap_or(0),
155            owner_id: response.steam_id_owner.unwrap_or(0),
156            chat_room_enter_response: response.chat_room_enter_response.unwrap_or(0),
157            max_members: response.max_members.unwrap_or(0),
158            lobby_type: response.lobby_type.unwrap_or(0),
159            lobby_flags: response.lobby_flags.unwrap_or(0),
160            members: response
161                .members
162                .into_iter()
163                .map(|m| LobbyMember {
164                    steam_id: m.steam_id.unwrap_or(0),
165                    persona_name: m.persona_name.unwrap_or_default(),
166                    metadata: m.metadata.unwrap_or_default(),
167                })
168                .collect(),
169            metadata: response.metadata,
170        })
171    }
172
173    /// Leave a lobby.
174    pub async fn leave_lobby(&mut self, lobby_id: u64) -> Result<(), SteamError> {
175        let msg = steam_protos::CMsgClientMmsLeaveLobby { app_id: Some(APP_ID), steam_id_lobby: Some(lobby_id) };
176
177        let response: steam_protos::CMsgClientMmsLeaveLobbyResponse = self.client.send_request_and_wait(EMsg::ClientMMSLeaveLobby, &msg).await?;
178
179        let eresult = EResult::from_i32(response.eresult.unwrap_or(2)).unwrap_or(EResult::Fail);
180        if eresult != EResult::OK {
181            return Err(SteamError::SteamResult(eresult));
182        }
183
184        Ok(())
185    }
186
187    /// Update lobby settings and metadata.
188    ///
189    /// Use this to change lobby configuration after creation.
190    pub async fn update_lobby(&mut self, lobby_id: u64, config: &LobbyConfig) -> Result<u64, SteamError> {
191        let msg = steam_protos::CMsgClientMmsSetLobbyData {
192            app_id: Some(APP_ID),
193            steam_id_lobby: Some(lobby_id),
194            steam_id_member: Some(0),
195            max_members: config.max_members,
196            lobby_type: config.lobby_type,
197            lobby_flags: config.lobby_flags,
198            metadata: config.metadata.clone(),
199        };
200
201        let response: steam_protos::CMsgClientMmsSetLobbyDataResponse = self.client.send_request_and_wait(EMsg::ClientMMSSetLobbyData, &msg).await?;
202
203        let eresult = EResult::from_i32(response.eresult.unwrap_or(2)).unwrap_or(EResult::Fail);
204        if eresult != EResult::OK {
205            return Err(SteamError::SteamResult(eresult));
206        }
207
208        Ok(response.steam_id_lobby.unwrap_or(0))
209    }
210
211    /// Get data about a specific lobby.
212    ///
213    /// Returns lobby details including members, settings, and metadata.
214    pub async fn get_lobby_data(&mut self, lobby_id: u64) -> Result<LobbyData, SteamError> {
215        let msg = steam_protos::CMsgClientMmsGetLobbyData { app_id: Some(APP_ID), steam_id_lobby: Some(lobby_id) };
216
217        let response: steam_protos::CMsgClientMmsLobbyData = self.client.send_request_and_wait(EMsg::ClientMMSGetLobbyData, &msg).await?;
218
219        Ok(LobbyData {
220            lobby_id: response.steam_id_lobby.unwrap_or(0),
221            owner_id: response.steam_id_owner.unwrap_or(0),
222            num_members: response.num_members.unwrap_or(0),
223            max_members: response.max_members.unwrap_or(0),
224            lobby_type: response.lobby_type.unwrap_or(0),
225            lobby_flags: response.lobby_flags.unwrap_or(0),
226            members: response
227                .members
228                .into_iter()
229                .map(|m| LobbyMember {
230                    steam_id: m.steam_id.unwrap_or(0),
231                    persona_name: m.persona_name.unwrap_or_default(),
232                    metadata: m.metadata.unwrap_or_default(),
233                })
234                .collect(),
235            metadata: response.metadata,
236        })
237    }
238
239    /// Create a lobby, update its settings, then invite users.
240    ///
241    /// This is a convenience method that combines create_lobby, update_lobby,
242    /// and invite_to_lobby into a single workflow.
243    pub async fn create_and_invite(&mut self, config: &LobbyConfig, users: &[SteamID]) -> Result<u64, SteamError> {
244        // Create the lobby
245        let lobby_id = self.create_lobby(config.max_members.unwrap_or(10), config.lobby_type.unwrap_or(1)).await?;
246
247        // Update with full config if we have metadata or other settings
248        if config.metadata.is_some() || config.lobby_flags.is_some() {
249            self.update_lobby(lobby_id, config).await?;
250        }
251
252        // Invite all users
253        for user in users {
254            self.invite_to_lobby(lobby_id, *user).await?;
255        }
256
257        Ok(lobby_id)
258    }
259
260    /// Register a party (L2P advertisement) with the CS:GO GC.
261    ///
262    /// This makes the account visible in the "Looking to Play" dashboard.
263    pub async fn party_register(&mut self, prime: bool, game_type: u32) -> Result<(), SteamError> {
264        let msg = steam_protos::CMsgGccStrike15V2PartyRegister {
265            id: Some(0),
266            ver: Some(13960), // CSGO_VER
267            apr: Some(if prime { 1 } else { 0 }),
268            ark: Some(if prime { 180 } else { 0 }),
269            nby: Some(0),
270            grp: Some(0),
271            slots: Some(0),
272            launcher: Some(0),
273            game_type: Some(game_type),
274        };
275
276        let res = self.client.send_to_gc_proto(APP_ID, ECsgoGCMsg::Party_Register as u32, &msg.encode_to_vec(), GCProtoHeader::default()).await;
277
278        if res.is_ok() {
279            self.client.last_time_party_register = Some(chrono::Utc::now().timestamp_millis());
280        }
281
282        res
283    }
284
285    /// Acknowledge a competitive cooldown/penalty with the CS:GO GC.
286    ///
287    /// This is equivalent to clicking the "Acknowledge" button in the CS:GO UI
288    /// to clear the penalty banner after a cooldown has expired.
289    pub async fn acknowledge_penalty(&mut self) -> Result<(), SteamError> {
290        let msg = steam_protos::CMsgGccStrike15V2AcknowledgePenalty { acknowledged: Some(1) };
291
292        self.client.send_to_gc_proto(APP_ID, ECsgoGCMsg::AcknowledgePenalty as u32, &msg.encode_to_vec(), GCProtoHeader::default()).await
293    }
294}
295
296/// Result of joining a lobby.
297#[derive(Debug, Clone)]
298pub struct JoinLobbyResult {
299    /// Steam ID of the lobby.
300    pub lobby_id: u64,
301    /// Steam ID of the lobby owner.
302    pub owner_id: u64,
303    /// Response code indicating success/failure of entering the chat room.
304    pub chat_room_enter_response: i32,
305    /// Maximum number of members allowed in the lobby.
306    pub max_members: i32,
307    /// Type of lobby (public, friends-only, private, etc.).
308    pub lobby_type: i32,
309    /// Lobby flags.
310    pub lobby_flags: i32,
311    /// Current members in the lobby.
312    pub members: Vec<LobbyMember>,
313    /// Raw lobby metadata bytes (CS:GO-specific format).
314    pub metadata: Option<Vec<u8>>,
315}
316
317/// A member in a lobby.
318#[derive(Debug, Clone)]
319pub struct LobbyMember {
320    /// Steam ID of the member.
321    pub steam_id: u64,
322    /// Display name of the member.
323    pub persona_name: String,
324    /// Member-specific metadata.
325    pub metadata: Vec<u8>,
326}
327
328/// Configuration for creating or updating a lobby.
329#[derive(Debug, Clone, Default)]
330pub struct LobbyConfig {
331    /// Maximum number of members.
332    pub max_members: Option<i32>,
333    /// Lobby type (1 = private, 2 = friends only, 3 = public).
334    pub lobby_type: Option<i32>,
335    /// Lobby flags.
336    pub lobby_flags: Option<i32>,
337    /// Raw metadata bytes.
338    pub metadata: Option<Vec<u8>>,
339}
340
341impl LobbyConfig {
342    /// Create a new empty lobby config.
343    pub fn new() -> Self {
344        Self::default()
345    }
346
347    /// Set maximum members.
348    pub fn max_members(mut self, max: i32) -> Self {
349        self.max_members = Some(max);
350        self
351    }
352
353    /// Set lobby type.
354    pub fn lobby_type(mut self, t: i32) -> Self {
355        self.lobby_type = Some(t);
356        self
357    }
358
359    /// Set lobby flags.
360    pub fn lobby_flags(mut self, flags: i32) -> Self {
361        self.lobby_flags = Some(flags);
362        self
363    }
364
365    /// Set raw metadata.
366    pub fn metadata(mut self, data: Vec<u8>) -> Self {
367        self.metadata = Some(data);
368        self
369    }
370}
371
372/// Data about a lobby.
373#[derive(Debug, Clone)]
374pub struct LobbyData {
375    /// Steam ID of the lobby.
376    pub lobby_id: u64,
377    /// Steam ID of the lobby owner.
378    pub owner_id: u64,
379    /// Current number of members.
380    pub num_members: i32,
381    /// Maximum number of members.
382    pub max_members: i32,
383    /// Lobby type.
384    pub lobby_type: i32,
385    /// Lobby flags.
386    pub lobby_flags: i32,
387    /// Current members.
388    pub members: Vec<LobbyMember>,
389    /// Raw metadata bytes.
390    pub metadata: Option<Vec<u8>>,
391}
392
393impl LobbyData {
394    /// Parse the raw metadata bytes into a structured `LobbyMetadata`.
395    pub fn parse_metadata(&self) -> Option<LobbyMetadata> {
396        self.metadata.as_ref().and_then(|data| LobbyMetadata::decode(data).ok())
397    }
398}
399
400impl JoinLobbyResult {
401    /// Parse the raw metadata bytes into a structured `LobbyMetadata`.
402    pub fn parse_metadata(&self) -> Option<LobbyMetadata> {
403        self.metadata.as_ref().and_then(|data| LobbyMetadata::decode(data).ok())
404    }
405}
406
407//=============================================================================
408// Lobby Metadata Encoding/Decoding
409//=============================================================================
410
411/// Binary KV type markers used in CS:GO lobby metadata.
412mod kv_type {
413    pub const NONE: u8 = 0; // Nested object start
414    pub const STRING: u8 = 1; // String value (also used for numbers as strings)
415    pub const END: u8 = 8; // End marker
416}
417
418/// CS:GO lobby metadata (parsed from binary format).
419///
420/// This represents the game settings stored in lobby metadata blobs.
421#[derive(Debug, Clone, Default)]
422pub struct LobbyMetadata {
423    /// Game rank ("game:ark").
424    pub rank: Option<String>,
425    /// Location/country message ("game:loc").
426    pub location: Option<String>,
427    /// Map group name ("game:mapgroupname"), e.g., "mg_de_mirage".
428    pub map_group_name: Option<String>,
429    /// Game mode ("game:mode"), e.g., "competitive".
430    pub game_mode: Option<String>,
431    /// Prime status ("game:prime"), "1" for prime, "0" for non-prime.
432    pub prime: Option<String>,
433    /// Game type ("game:type"), e.g., "classic".
434    pub game_type: Option<String>,
435    /// Number of players ("members:numPlayers").
436    pub num_players: Option<String>,
437    /// Action ("options:action"), e.g., "custommatch".
438    pub action: Option<String>,
439    /// Any type mode ("options:anytypemode").
440    pub any_type_mode: Option<String>,
441    /// Access level ("system:access"), e.g., "private".
442    pub access: Option<String>,
443    /// Network ("system:network"), e.g., "LIVE".
444    pub network: Option<String>,
445    /// Member Steam IDs (account IDs, varint encoded in binary).
446    pub uids: Vec<u32>,
447    /// Additional key-value pairs not explicitly parsed.
448    pub extra: HashMap<String, String>,
449}
450
451impl LobbyMetadata {
452    /// Create a new empty lobby metadata.
453    pub fn new() -> Self {
454        Self::default()
455    }
456
457    /// Set map group name.
458    pub fn map_group(mut self, name: impl Into<String>) -> Self {
459        self.map_group_name = Some(name.into());
460        self
461    }
462
463    /// Set game mode (e.g., "competitive", "casual", "deathmatch").
464    pub fn mode(mut self, mode: impl Into<String>) -> Self {
465        self.game_mode = Some(mode.into());
466        self
467    }
468
469    /// Set prime status.
470    pub fn prime(mut self, is_prime: bool) -> Self {
471        self.prime = Some(if is_prime { "1".to_string() } else { "0".to_string() });
472        self
473    }
474
475    /// Set game type (e.g., "classic").
476    pub fn game_type(mut self, t: impl Into<String>) -> Self {
477        self.game_type = Some(t.into());
478        self
479    }
480
481    /// Set number of players.
482    pub fn num_players(mut self, count: u32) -> Self {
483        self.num_players = Some(count.to_string());
484        self
485    }
486
487    /// Set action (e.g., "custommatch").
488    pub fn action(mut self, action: impl Into<String>) -> Self {
489        self.action = Some(action.into());
490        self
491    }
492
493    /// Set access level (e.g., "private", "public").
494    pub fn access(mut self, access: impl Into<String>) -> Self {
495        self.access = Some(access.into());
496        self
497    }
498
499    /// Set network (e.g., "LIVE").
500    pub fn network(mut self, network: impl Into<String>) -> Self {
501        self.network = Some(network.into());
502        self
503    }
504
505    /// Add member Steam IDs (as account IDs, the lower 32 bits of SteamID64).
506    pub fn uids(mut self, ids: Vec<u32>) -> Self {
507        self.uids = ids;
508        self
509    }
510
511    /// Encode the metadata to binary format.
512    ///
513    /// The format is:
514    /// - 2 prefix bytes (0x00, 0x00)
515    /// - Key-value pairs as: [type byte][key C-string][value C-string or
516    ///   nested]
517    /// - Suffix byte (0x08)
518    /// - End marker (0x08)
519    pub fn encode(&self) -> Vec<u8> {
520        let mut buf = Vec::new();
521
522        // Prefix bytes
523        buf.push(0x00);
524        buf.push(0x00);
525
526        // Encode string fields
527        if let Some(v) = &self.rank {
528            Self::encode_string(&mut buf, "game:ark", v);
529        }
530        if let Some(v) = &self.location {
531            Self::encode_string(&mut buf, "game:loc", v);
532        }
533        if let Some(v) = &self.map_group_name {
534            Self::encode_string(&mut buf, "game:mapgroupname", v);
535        }
536        if let Some(v) = &self.game_mode {
537            Self::encode_string(&mut buf, "game:mode", v);
538        }
539        if let Some(v) = &self.prime {
540            Self::encode_string(&mut buf, "game:prime", v);
541        }
542        if let Some(v) = &self.game_type {
543            Self::encode_string(&mut buf, "game:type", v);
544        }
545        if let Some(v) = &self.num_players {
546            Self::encode_string(&mut buf, "members:numPlayers", v);
547        }
548        if let Some(v) = &self.action {
549            Self::encode_string(&mut buf, "options:action", v);
550        }
551        if let Some(v) = &self.any_type_mode {
552            Self::encode_string(&mut buf, "options:anytypemode", v);
553        }
554        if let Some(v) = &self.access {
555            Self::encode_string(&mut buf, "system:access", v);
556        }
557        if let Some(v) = &self.network {
558            Self::encode_string(&mut buf, "system:network", v);
559        }
560
561        // Encode extra fields
562        for (k, v) in &self.extra {
563            Self::encode_string(&mut buf, k, v);
564        }
565
566        // Encode uids as nested binary blob
567        if !self.uids.is_empty() {
568            buf.push(kv_type::STRING);
569            Self::write_cstring(&mut buf, "uids");
570            // Encode UIDs using varint
571            let uid_bytes = Self::encode_uids(&self.uids);
572            buf.extend_from_slice(&uid_bytes);
573        }
574
575        // Suffix byte
576        buf.push(0x08);
577
578        // End marker
579        buf.push(kv_type::END);
580
581        buf
582    }
583
584    /// Decode binary metadata into a structured LobbyMetadata.
585    pub fn decode(data: &[u8]) -> Result<Self, crate::error::SteamError> {
586        let mut meta = LobbyMetadata::default();
587        let mut pos = 0;
588
589        // Skip prefix bytes (usually 0x00, 0x00)
590        if data.len() >= 2 {
591            pos = 2;
592        }
593
594        while pos < data.len() {
595            let type_byte = data[pos];
596            pos += 1;
597
598            match type_byte {
599                kv_type::STRING => {
600                    // Read key
601                    let key = Self::read_cstring(data, &mut pos)?;
602
603                    // Check if this might be a binary blob (uids)
604                    if key == "uids" {
605                        // Read varint-encoded account IDs until null terminator
606                        let uids = Self::decode_uids(data, &mut pos)?;
607                        meta.uids = uids;
608                    } else {
609                        // Read string value
610                        let value = Self::read_cstring(data, &mut pos)?;
611                        meta.set_field(&key, value);
612                    }
613                }
614                kv_type::NONE => {
615                    // Nested object - read name and skip for now
616                    let _name = Self::read_cstring(data, &mut pos)?;
617                    // Skip nested content until END
618                    while pos < data.len() && data[pos] != kv_type::END {
619                        pos += 1;
620                    }
621                    pos += 1; // Skip END byte
622                }
623                kv_type::END => {
624                    break;
625                }
626                _ => {
627                    // Unknown type, try to skip
628                    continue;
629                }
630            }
631        }
632
633        Ok(meta)
634    }
635
636    fn set_field(&mut self, key: &str, value: String) {
637        match key {
638            "game:ark" => self.rank = Some(value),
639            "game:loc" => self.location = Some(value),
640            "game:mapgroupname" => self.map_group_name = Some(value),
641            "game:mode" => self.game_mode = Some(value),
642            "game:prime" => self.prime = Some(value),
643            "game:type" => self.game_type = Some(value),
644            "members:numPlayers" => self.num_players = Some(value),
645            "options:action" => self.action = Some(value),
646            "options:anytypemode" => self.any_type_mode = Some(value),
647            "system:access" => self.access = Some(value),
648            "system:network" => self.network = Some(value),
649            _ => {
650                self.extra.insert(key.to_string(), value);
651            }
652        }
653    }
654
655    fn encode_string(buf: &mut Vec<u8>, key: &str, value: &str) {
656        buf.push(kv_type::STRING);
657        Self::write_cstring(buf, key);
658        Self::write_cstring(buf, value);
659    }
660
661    fn write_cstring(buf: &mut Vec<u8>, s: &str) {
662        buf.extend_from_slice(s.as_bytes());
663        buf.push(0x00); // Null terminator
664    }
665
666    fn read_cstring(data: &[u8], pos: &mut usize) -> Result<String, crate::error::SteamError> {
667        let start = *pos;
668        while *pos < data.len() && data[*pos] != 0x00 {
669            *pos += 1;
670        }
671        let s = String::from_utf8_lossy(&data[start..*pos]).to_string();
672        *pos += 1; // Skip null terminator
673        Ok(s)
674    }
675
676    /// Encode account IDs using varint encoding.
677    fn encode_uids(uids: &[u32]) -> Vec<u8> {
678        let mut buf = Vec::new();
679        for &id in uids {
680            let mut val = id;
681            while val > 0x7f {
682                buf.push(((val | 0x80) & 0xff) as u8);
683                val >>= 7;
684            }
685            buf.push(val as u8);
686        }
687        buf.push(0x00); // Null terminator
688        buf
689    }
690
691    /// Decode varint-encoded account IDs.
692    fn decode_uids(data: &[u8], pos: &mut usize) -> Result<Vec<u32>, crate::error::SteamError> {
693        let mut uids = Vec::new();
694        let mut current: u32 = 0;
695        let mut shift = 0;
696
697        while *pos < data.len() {
698            let byte = data[*pos];
699            *pos += 1;
700
701            if byte == 0x00 {
702                // Null terminator - push last value if any
703                if current > 0 || shift > 0 {
704                    uids.push(current);
705                }
706                break;
707            }
708
709            current |= ((byte & 0x7f) as u32) << shift;
710            shift += 7;
711
712            if byte & 0x80 == 0 {
713                // This byte is the last one for this value
714                uids.push(current);
715                current = 0;
716                shift = 0;
717            }
718        }
719
720        Ok(uids)
721    }
722}
723
724/// CS:GO Competitive Ranks.
725#[derive(Debug, Clone, Copy, PartialEq, Eq)]
726pub enum CsgoRank {
727    Unranked = 0,
728    SilverI = 1,
729    SilverII = 2,
730    SilverIII = 3,
731    SilverIV = 4,
732    SilverElite = 5,
733    SilverEliteMaster = 6,
734    GoldNovaI = 7,
735    GoldNovaII = 8,
736    GoldNovaIII = 9,
737    GoldNovaMaster = 10,
738    MasterGuardianI = 11,
739    MasterGuardianII = 12,
740    MasterGuardianElite = 13,
741    DistinguishedMasterGuardian = 14,
742    LegendaryEagle = 15,
743    LegendaryEagleMaster = 16,
744    SupremeMasterFirstClass = 17,
745    TheGlobalElite = 18,
746}
747
748/// CS:GO Game Modes.
749#[derive(Debug, Clone, Copy, PartialEq, Eq)]
750pub enum CsgoGameMode {
751    Casual,
752    Competitive,
753    Wingman,
754    Deathmatch,
755    ArmsRace,
756    Demolition,
757    FlyingScoutsman,
758    DangerZone,
759}
760
761impl CsgoGameMode {
762    fn as_str(&self) -> &'static str {
763        match self {
764            CsgoGameMode::Casual => "casual",
765            CsgoGameMode::Competitive => "competitive",
766            CsgoGameMode::Wingman => "scrimcomp2v2",
767            CsgoGameMode::Deathmatch => "deathmatch",
768            CsgoGameMode::ArmsRace => "gungameprogressive",
769            CsgoGameMode::Demolition => "gungametrbomb",
770            CsgoGameMode::FlyingScoutsman => "flyingscoutsman",
771            CsgoGameMode::DangerZone => "survival",
772        }
773    }
774}
775
776/// Builder for CS:GO Rich Presence data.
777#[derive(Debug, Clone, Default)]
778pub struct CsgoRichPresence {
779    rank: Option<CsgoRank>,
780    wins: Option<u32>,
781    level: Option<u32>,
782    score: Option<u32>,
783    #[allow(dead_code)]
784    leaderboard_time: Option<u32>,
785    status: Option<String>,
786    game_mode: Option<CsgoGameMode>,
787    map_group: Option<String>,
788}
789
790impl CsgoRichPresence {
791    pub fn new() -> Self {
792        Self::default()
793    }
794
795    pub fn rank(mut self, rank: CsgoRank) -> Self {
796        self.rank = Some(rank);
797        self
798    }
799
800    pub fn wins(mut self, wins: u32) -> Self {
801        self.wins = Some(wins);
802        self
803    }
804
805    pub fn level(mut self, level: u32) -> Self {
806        self.level = Some(level);
807        self
808    }
809
810    pub fn score(mut self, score: u32) -> Self {
811        self.score = Some(score);
812        self
813    }
814
815    pub fn status(mut self, status: impl Into<String>) -> Self {
816        self.status = Some(status.into());
817        self
818    }
819
820    pub fn game_mode(mut self, mode: CsgoGameMode) -> Self {
821        self.game_mode = Some(mode);
822        self
823    }
824
825    pub fn map_group(mut self, group: impl Into<String>) -> Self {
826        self.map_group = Some(group.into());
827        self
828    }
829
830    pub fn to_map(&self) -> HashMap<String, String> {
831        let mut map = HashMap::new();
832
833        map.insert("version".to_string(), "1".to_string());
834
835        if let Some(rank) = self.rank {
836            map.insert("competitive_ranking".to_string(), (rank as u32).to_string());
837        }
838
839        if let Some(wins) = self.wins {
840            map.insert("competitive_wins".to_string(), wins.to_string());
841        }
842
843        if let Some(level) = self.level {
844            map.insert("level".to_string(), level.to_string());
845        }
846
847        if let Some(score) = self.score {
848            map.insert("score".to_string(), score.to_string());
849        }
850
851        if let Some(status) = &self.status {
852            map.insert("game:state".to_string(), status.clone());
853        }
854
855        if let Some(mode) = self.game_mode {
856            map.insert("game:mode".to_string(), mode.as_str().to_string());
857        }
858
859        if let Some(group) = &self.map_group {
860            map.insert("game:mapgroupname".to_string(), group.clone());
861        }
862
863        // If we have a rank, we usually want to show it using the competitive display
864        if self.rank.is_some() {
865            map.insert("steam_display".to_string(), "#RP_Status_Competitive".to_string());
866        } else if self.status.is_some() {
867            // If manual status is set but no rank, generic display
868            map.insert("steam_display".to_string(), "#RP_Status_Command".to_string());
869        }
870
871        map
872    }
873}