Skip to main content

proof_engine/networking/
lobby.rs

1//! Multiplayer lobby system: lobby management, browser, matchmaking,
2//! team assignment, ready-checks, and voice-chat metadata.
3
4use std::collections::{HashMap, VecDeque};
5use std::time::{Duration, Instant};
6
7// ─── IDs ─────────────────────────────────────────────────────────────────────
8
9/// Opaque lobby identifier.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub struct LobbyId(pub u64);
12
13/// Opaque player identifier.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub struct PlayerId(pub u64);
16
17/// Opaque team identifier.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub struct TeamId(pub u8);
20
21/// Opaque game-mode identifier.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub struct GameMode(pub u8);
24
25impl GameMode {
26    pub const DEATHMATCH:   Self = Self(0);
27    pub const TEAM_VS_TEAM: Self = Self(1);
28    pub const CAPTURE_FLAG: Self = Self(2);
29    pub const KING_HILL:    Self = Self(3);
30    pub const CUSTOM:       Self = Self(0xFF);
31}
32
33// ─── LobbyState ──────────────────────────────────────────────────────────────
34
35/// Lifecycle phase of a lobby.
36#[derive(Debug, Clone, PartialEq)]
37pub enum LobbyState {
38    /// Waiting for players; lobby is open.
39    Waiting,
40    /// Countdown in progress.  `f32` = remaining seconds.
41    Countdown(f32),
42    /// A match is actively running.
43    InGame,
44    /// Match has ended; displaying results.
45    Postgame,
46}
47
48impl LobbyState {
49    pub fn is_joinable(&self) -> bool {
50        matches!(self, LobbyState::Waiting)
51    }
52}
53
54// ─── LobbyConfig ─────────────────────────────────────────────────────────────
55
56/// Immutable configuration set when the lobby is created.
57#[derive(Debug, Clone)]
58pub struct LobbyConfig {
59    pub max_players: u8,
60    pub min_players: u8,
61    pub game_mode:   GameMode,
62    pub map_id:      u32,
63    /// Optional password.  Empty string = no password.
64    pub password:    String,
65    pub public:      bool,
66    pub ranked:      bool,
67    /// Duration of the pre-game countdown in seconds.
68    pub countdown_secs: f32,
69}
70
71impl Default for LobbyConfig {
72    fn default() -> Self {
73        Self {
74            max_players:    8,
75            min_players:    2,
76            game_mode:      GameMode::DEATHMATCH,
77            map_id:         0,
78            password:       String::new(),
79            public:         true,
80            ranked:         false,
81            countdown_secs: 10.0,
82        }
83    }
84}
85
86impl LobbyConfig {
87    pub fn has_password(&self) -> bool { !self.password.is_empty() }
88}
89
90// ─── LobbyPlayer ─────────────────────────────────────────────────────────────
91
92/// State of one player inside a lobby.
93#[derive(Debug, Clone)]
94pub struct LobbyPlayer {
95    pub id:        PlayerId,
96    pub name:      String,
97    pub ready:     bool,
98    pub team:      Option<TeamId>,
99    pub ping_ms:   u32,
100    pub spectator: bool,
101}
102
103impl LobbyPlayer {
104    pub fn new(id: PlayerId, name: impl Into<String>) -> Self {
105        Self {
106            id, name: name.into(), ready: false,
107            team: None, ping_ms: 0, spectator: false,
108        }
109    }
110}
111
112// ─── Lobby ───────────────────────────────────────────────────────────────────
113
114/// A single server-side lobby instance.
115#[derive(Debug)]
116pub struct Lobby {
117    pub id:        LobbyId,
118    pub name:      String,
119    pub host_id:   PlayerId,
120    pub players:   Vec<LobbyPlayer>,
121    pub state:     LobbyState,
122    pub config:    LobbyConfig,
123    created_at:    Instant,
124    countdown_started: Option<Instant>,
125}
126
127impl Lobby {
128    pub fn new(id: LobbyId, name: impl Into<String>, host_id: PlayerId, config: LobbyConfig) -> Self {
129        Self {
130            id, name: name.into(), host_id,
131            players: Vec::new(), state: LobbyState::Waiting,
132            config, created_at: Instant::now(), countdown_started: None,
133        }
134    }
135
136    /// Returns `true` if there is room for one more player.
137    pub fn has_room(&self) -> bool {
138        self.players.len() < self.config.max_players as usize
139    }
140
141    /// Returns `true` if the lobby has enough players to start.
142    pub fn has_min_players(&self) -> bool {
143        self.players.len() >= self.config.min_players as usize
144    }
145
146    pub fn all_ready(&self) -> bool {
147        !self.players.is_empty()
148            && self.players.iter().filter(|p| !p.spectator).all(|p| p.ready)
149    }
150
151    pub fn player(&self, id: PlayerId) -> Option<&LobbyPlayer> {
152        self.players.iter().find(|p| p.id == id)
153    }
154
155    pub fn player_mut(&mut self, id: PlayerId) -> Option<&mut LobbyPlayer> {
156        self.players.iter_mut().find(|p| p.id == id)
157    }
158
159    pub fn contains(&self, id: PlayerId) -> bool {
160        self.players.iter().any(|p| p.id == id)
161    }
162
163    /// Tick the countdown; returns `true` when countdown expires.
164    pub fn tick_countdown(&mut self, dt: f32) -> bool {
165        if let LobbyState::Countdown(ref mut t) = self.state {
166            *t -= dt;
167            if *t <= 0.0 {
168                return true;
169            }
170        }
171        false
172    }
173
174    pub fn player_count(&self) -> usize { self.players.len() }
175    pub fn spectator_count(&self) -> usize { self.players.iter().filter(|p| p.spectator).count() }
176    pub fn active_count(&self) -> usize { self.players.iter().filter(|p| !p.spectator).count() }
177}
178
179// ─── LobbyError ──────────────────────────────────────────────────────────────
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum LobbyError {
183    LobbyNotFound(LobbyId),
184    LobbyFull,
185    LobbyNotJoinable,
186    WrongPassword,
187    PlayerNotFound(PlayerId),
188    PlayerAlreadyInLobby,
189    NotHost,
190    NotEnoughPlayers,
191    AlreadyInGame,
192    CannotKickSelf,
193    TeamNotFound(TeamId),
194    MatchmakingError(String),
195}
196
197impl std::fmt::Display for LobbyError {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        write!(f, "{self:?}")
200    }
201}
202
203impl std::error::Error for LobbyError {}
204
205// ─── LobbyManager ────────────────────────────────────────────────────────────
206
207/// Server-side manager for all active lobbies.
208pub struct LobbyManager {
209    lobbies:       HashMap<LobbyId, Lobby>,
210    /// Maps player → their current lobby (if any).
211    player_lobby:  HashMap<PlayerId, LobbyId>,
212    next_lobby_id: u64,
213}
214
215impl LobbyManager {
216    pub fn new() -> Self {
217        Self {
218            lobbies: HashMap::new(),
219            player_lobby: HashMap::new(),
220            next_lobby_id: 1,
221        }
222    }
223
224    fn alloc_id(&mut self) -> LobbyId {
225        let id = LobbyId(self.next_lobby_id);
226        self.next_lobby_id += 1;
227        id
228    }
229
230    /// Create a new lobby.  The host is automatically added as the first player.
231    pub fn create_lobby(
232        &mut self,
233        host_id: PlayerId,
234        host_name: impl Into<String>,
235        name: impl Into<String>,
236        config: LobbyConfig,
237    ) -> Result<LobbyId, LobbyError> {
238        // A player can only be in one lobby at a time
239        if self.player_lobby.contains_key(&host_id) {
240            return Err(LobbyError::PlayerAlreadyInLobby);
241        }
242        let id = self.alloc_id();
243        let mut lobby = Lobby::new(id, name, host_id, config);
244        lobby.players.push(LobbyPlayer::new(host_id, host_name));
245        self.player_lobby.insert(host_id, id);
246        self.lobbies.insert(id, lobby);
247        Ok(id)
248    }
249
250    /// Destroy a lobby, removing all players from the tracking map.
251    pub fn destroy_lobby(&mut self, id: LobbyId) -> Result<(), LobbyError> {
252        let lobby = self.lobbies.remove(&id).ok_or(LobbyError::LobbyNotFound(id))?;
253        for p in &lobby.players {
254            self.player_lobby.remove(&p.id);
255        }
256        Ok(())
257    }
258
259    /// Add a player to a lobby.
260    pub fn join(
261        &mut self,
262        player_id: PlayerId,
263        player_name: impl Into<String>,
264        lobby_id: LobbyId,
265        password: &str,
266    ) -> Result<(), LobbyError> {
267        if self.player_lobby.contains_key(&player_id) {
268            return Err(LobbyError::PlayerAlreadyInLobby);
269        }
270        let lobby = self.lobbies.get_mut(&lobby_id).ok_or(LobbyError::LobbyNotFound(lobby_id))?;
271        if !lobby.state.is_joinable() {
272            return Err(LobbyError::LobbyNotJoinable);
273        }
274        if !lobby.has_room() {
275            return Err(LobbyError::LobbyFull);
276        }
277        if lobby.config.has_password() && lobby.config.password != password {
278            return Err(LobbyError::WrongPassword);
279        }
280        lobby.players.push(LobbyPlayer::new(player_id, player_name));
281        self.player_lobby.insert(player_id, lobby_id);
282        Ok(())
283    }
284
285    /// Remove a player from their current lobby.
286    pub fn leave(&mut self, player_id: PlayerId) -> Result<LobbyId, LobbyError> {
287        let lobby_id = self.player_lobby.remove(&player_id)
288            .ok_or(LobbyError::PlayerNotFound(player_id))?;
289        let lobby = self.lobbies.get_mut(&lobby_id)
290            .ok_or(LobbyError::LobbyNotFound(lobby_id))?;
291        lobby.players.retain(|p| p.id != player_id);
292
293        // If host left, transfer host to next player or destroy
294        if lobby.host_id == player_id {
295            if let Some(new_host) = lobby.players.first() {
296                lobby.host_id = new_host.id;
297            } else {
298                // Empty lobby — destroy
299                self.lobbies.remove(&lobby_id);
300            }
301        }
302        Ok(lobby_id)
303    }
304
305    /// Set the ready state of a player.
306    pub fn set_ready(&mut self, player_id: PlayerId, ready: bool) -> Result<(), LobbyError> {
307        let lobby_id = *self.player_lobby.get(&player_id)
308            .ok_or(LobbyError::PlayerNotFound(player_id))?;
309        let lobby = self.lobbies.get_mut(&lobby_id)
310            .ok_or(LobbyError::LobbyNotFound(lobby_id))?;
311        let player = lobby.player_mut(player_id)
312            .ok_or(LobbyError::PlayerNotFound(player_id))?;
313        player.ready = ready;
314        Ok(())
315    }
316
317    /// Host kicks a player.
318    pub fn kick(&mut self, host_id: PlayerId, target_id: PlayerId) -> Result<(), LobbyError> {
319        if host_id == target_id {
320            return Err(LobbyError::CannotKickSelf);
321        }
322        let lobby_id = *self.player_lobby.get(&host_id)
323            .ok_or(LobbyError::PlayerNotFound(host_id))?;
324        let lobby = self.lobbies.get(&lobby_id)
325            .ok_or(LobbyError::LobbyNotFound(lobby_id))?;
326        if lobby.host_id != host_id {
327            return Err(LobbyError::NotHost);
328        }
329        if !lobby.contains(target_id) {
330            return Err(LobbyError::PlayerNotFound(target_id));
331        }
332        self.leave(target_id)?;
333        Ok(())
334    }
335
336    /// Attempt to start the game.  Called by host or automatically when all ready.
337    /// Returns `Ok(())` and transitions lobby to `Countdown`.
338    pub fn start_game(&mut self, host_id: PlayerId) -> Result<(), LobbyError> {
339        let lobby_id = *self.player_lobby.get(&host_id)
340            .ok_or(LobbyError::PlayerNotFound(host_id))?;
341        let lobby = self.lobbies.get_mut(&lobby_id)
342            .ok_or(LobbyError::LobbyNotFound(lobby_id))?;
343        if lobby.host_id != host_id {
344            return Err(LobbyError::NotHost);
345        }
346        if matches!(lobby.state, LobbyState::InGame) {
347            return Err(LobbyError::AlreadyInGame);
348        }
349        if !lobby.has_min_players() {
350            return Err(LobbyError::NotEnoughPlayers);
351        }
352        let secs = lobby.config.countdown_secs;
353        lobby.state = LobbyState::Countdown(secs);
354        lobby.countdown_started = Some(Instant::now());
355        Ok(())
356    }
357
358    /// Tick all lobby countdowns.  Returns a list of lobby IDs that transitioned to `InGame`.
359    pub fn tick(&mut self, dt: f32) -> Vec<LobbyId> {
360        let mut started = Vec::new();
361        for (id, lobby) in self.lobbies.iter_mut() {
362            if lobby.tick_countdown(dt) {
363                lobby.state = LobbyState::InGame;
364                started.push(*id);
365            }
366        }
367        started
368    }
369
370    /// Mark a lobby as finished and transition to Postgame.
371    pub fn end_game(&mut self, lobby_id: LobbyId) -> Result<(), LobbyError> {
372        let lobby = self.lobbies.get_mut(&lobby_id)
373            .ok_or(LobbyError::LobbyNotFound(lobby_id))?;
374        lobby.state = LobbyState::Postgame;
375        Ok(())
376    }
377
378    /// Reset lobby back to waiting state.
379    pub fn reset_lobby(&mut self, lobby_id: LobbyId) -> Result<(), LobbyError> {
380        let lobby = self.lobbies.get_mut(&lobby_id)
381            .ok_or(LobbyError::LobbyNotFound(lobby_id))?;
382        lobby.state = LobbyState::Waiting;
383        for p in lobby.players.iter_mut() {
384            p.ready = false;
385        }
386        Ok(())
387    }
388
389    pub fn lobby(&self, id: LobbyId) -> Option<&Lobby> {
390        self.lobbies.get(&id)
391    }
392
393    pub fn lobby_for_player(&self, player_id: PlayerId) -> Option<&Lobby> {
394        let lid = self.player_lobby.get(&player_id)?;
395        self.lobbies.get(lid)
396    }
397
398    pub fn lobby_count(&self) -> usize { self.lobbies.len() }
399
400    /// All public lobbies sorted by player count (descending).
401    pub fn public_lobbies(&self) -> Vec<&Lobby> {
402        let mut list: Vec<&Lobby> = self.lobbies.values()
403            .filter(|l| l.config.public && l.state.is_joinable())
404            .collect();
405        list.sort_by(|a, b| b.players.len().cmp(&a.players.len()));
406        list
407    }
408}
409
410impl Default for LobbyManager {
411    fn default() -> Self { Self::new() }
412}
413
414// ─── LobbyInfo ────────────────────────────────────────────────────────────────
415
416/// Lightweight snapshot of a lobby sent to browsing clients.
417#[derive(Debug, Clone)]
418pub struct LobbyInfo {
419    pub id:           LobbyId,
420    pub name:         String,
421    pub player_count: u8,
422    pub max_players:  u8,
423    pub map_id:       u32,
424    pub game_mode:    GameMode,
425    /// Measured RTT from the browsing client's perspective.
426    pub ping_ms:      u32,
427    pub has_password: bool,
428}
429
430impl LobbyInfo {
431    pub fn from_lobby(lobby: &Lobby, ping_ms: u32) -> Self {
432        Self {
433            id:           lobby.id,
434            name:         lobby.name.clone(),
435            player_count: lobby.players.len() as u8,
436            max_players:  lobby.config.max_players,
437            map_id:       lobby.config.map_id,
438            game_mode:    lobby.config.game_mode,
439            ping_ms,
440            has_password: lobby.config.has_password(),
441        }
442    }
443}
444
445// ─── LobbyBrowser ─────────────────────────────────────────────────────────────
446
447/// Client-side lobby listing with filter/sort and rate-limited refresh.
448pub struct LobbyBrowser {
449    listings: Vec<LobbyInfo>,
450    last_refresh: Option<Instant>,
451    /// Minimum time between refreshes.
452    refresh_cooldown: Duration,
453}
454
455/// Sort order for lobby listings.
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457pub enum LobbySort {
458    ByPlayerCount,
459    ByPing,
460    ByName,
461    ByGameMode,
462}
463
464/// Filter predicate for lobby listings.
465#[derive(Debug, Clone, Default)]
466pub struct LobbyFilter {
467    pub max_ping_ms:  Option<u32>,
468    pub game_mode:    Option<GameMode>,
469    pub map_id:       Option<u32>,
470    pub name_substr:  Option<String>,
471    pub hide_full:    bool,
472    pub hide_private: bool,
473}
474
475impl LobbyBrowser {
476    pub fn new(refresh_cooldown_ms: u64) -> Self {
477        Self {
478            listings: Vec::new(),
479            last_refresh: None,
480            refresh_cooldown: Duration::from_millis(refresh_cooldown_ms),
481        }
482    }
483
484    /// Returns `true` if we are allowed to refresh now.
485    pub fn can_refresh(&self) -> bool {
486        match self.last_refresh {
487            None => true,
488            Some(t) => t.elapsed() >= self.refresh_cooldown,
489        }
490    }
491
492    /// Update listings from a server response.
493    pub fn update_listings(&mut self, infos: Vec<LobbyInfo>) {
494        self.listings = infos;
495        self.last_refresh = Some(Instant::now());
496    }
497
498    /// Apply filter then sort.
499    pub fn filtered_sorted(&self, filter: &LobbyFilter, sort: LobbySort) -> Vec<&LobbyInfo> {
500        let mut list: Vec<&LobbyInfo> = self.listings.iter()
501            .filter(|l| {
502                if let Some(max_ping) = filter.max_ping_ms {
503                    if l.ping_ms > max_ping { return false; }
504                }
505                if let Some(gm) = filter.game_mode {
506                    if l.game_mode != gm { return false; }
507                }
508                if let Some(map) = filter.map_id {
509                    if l.map_id != map { return false; }
510                }
511                if let Some(ref substr) = filter.name_substr {
512                    if !l.name.to_lowercase().contains(&substr.to_lowercase()) { return false; }
513                }
514                if filter.hide_full && l.player_count >= l.max_players { return false; }
515                if filter.hide_private && l.has_password { return false; }
516                true
517            })
518            .collect();
519
520        match sort {
521            LobbySort::ByPlayerCount => list.sort_by(|a, b| b.player_count.cmp(&a.player_count)),
522            LobbySort::ByPing        => list.sort_by_key(|l| l.ping_ms),
523            LobbySort::ByName        => list.sort_by(|a, b| a.name.cmp(&b.name)),
524            LobbySort::ByGameMode    => list.sort_by_key(|l| l.game_mode.0),
525        }
526        list
527    }
528
529    pub fn listing_count(&self) -> usize { self.listings.len() }
530}
531
532// ─── MatchmakingQueue ─────────────────────────────────────────────────────────
533
534/// Entry in the matchmaking queue.
535#[derive(Debug, Clone)]
536pub struct QueueEntry {
537    pub player_id:     PlayerId,
538    pub skill_rating:  f32,
539    pub queue_time:    Instant,
540    /// Party ID (players with same party_id must be kept together).
541    pub party_id:      Option<u64>,
542    pub preferences:   MatchPreferences,
543}
544
545/// Player preferences for matchmaking.
546#[derive(Debug, Clone, Default)]
547pub struct MatchPreferences {
548    pub game_mode:       Option<GameMode>,
549    pub preferred_map:   Option<u32>,
550    pub max_ping_ms:     Option<u32>,
551    pub ranked:          bool,
552}
553
554/// A proposed match from the matchmaking system.
555#[derive(Debug, Clone)]
556pub struct ProposedMatch {
557    pub players:   Vec<PlayerId>,
558    pub game_mode: GameMode,
559    pub map_id:    u32,
560    pub avg_skill: f32,
561}
562
563/// Fill an existing game's open slot.
564#[derive(Debug, Clone)]
565pub struct BackfillJob {
566    pub lobby_id:   LobbyId,
567    pub open_slots: u8,
568    pub skill_avg:  f32,
569    pub game_mode:  GameMode,
570    pub map_id:     u32,
571}
572
573pub struct MatchmakingQueue {
574    queue:           VecDeque<QueueEntry>,
575    backfill_jobs:   Vec<BackfillJob>,
576    /// Maximum skill spread for initial match window.
577    base_skill_range: f32,
578    /// How much the range expands per second in queue.
579    range_per_sec:    f32,
580    /// Maximum range (after 60s at default settings: 500).
581    max_skill_range:  f32,
582    /// Required players per match.
583    match_size:       usize,
584}
585
586impl MatchmakingQueue {
587    pub fn new(match_size: usize) -> Self {
588        Self {
589            queue:            VecDeque::new(),
590            backfill_jobs:    Vec::new(),
591            base_skill_range: 50.0,
592            range_per_sec:    (500.0 - 50.0) / 60.0, // expands to 500 over 60s
593            max_skill_range:  500.0,
594            match_size,
595        }
596    }
597
598    /// Enqueue a player.
599    pub fn enqueue(&mut self, entry: QueueEntry) {
600        self.queue.push_back(entry);
601    }
602
603    /// Remove a player from the queue (cancelled or timed out).
604    pub fn dequeue(&mut self, player_id: PlayerId) -> bool {
605        let before = self.queue.len();
606        self.queue.retain(|e| e.player_id != player_id);
607        self.queue.len() < before
608    }
609
610    /// Skill range for an entry given how long it has been queuing.
611    fn skill_range_for(&self, entry: &QueueEntry) -> f32 {
612        let wait_secs = entry.queue_time.elapsed().as_secs_f32();
613        (self.base_skill_range + self.range_per_sec * wait_secs).min(self.max_skill_range)
614    }
615
616    /// Attempt to form matches.  Returns a list of proposed matches.
617    /// Party members are kept together.
618    pub fn tick(&mut self) -> Vec<ProposedMatch> {
619        let mut matched: Vec<ProposedMatch> = Vec::new();
620        let mut used: std::collections::HashSet<usize> = std::collections::HashSet::new();
621
622        let entries: Vec<(usize, &QueueEntry)> = self.queue.iter().enumerate().collect();
623
624        'outer: for (i, anchor) in &entries {
625            if used.contains(i) { continue; }
626
627            let range = self.skill_range_for(anchor);
628            let mut group: Vec<usize> = vec![*i];
629            let anchor_party = anchor.party_id;
630
631            for (j, candidate) in &entries {
632                if used.contains(j) || j == i { continue; }
633                if (candidate.skill_rating - anchor.skill_rating).abs() > range { continue; }
634                // Party check: if the anchor is in a party, candidate must be too
635                if let Some(ap) = anchor_party {
636                    if candidate.party_id != Some(ap) && group.len() > 1 { continue; }
637                }
638                // Preference alignment
639                if let Some(gm) = anchor.preferences.game_mode {
640                    if candidate.preferences.game_mode.map_or(false, |cg| cg != gm) { continue; }
641                }
642                group.push(*j);
643                if group.len() >= self.match_size {
644                    break;
645                }
646            }
647
648            if group.len() >= self.match_size {
649                let players: Vec<PlayerId> = group.iter()
650                    .map(|&idx| entries[idx].1.player_id)
651                    .collect();
652                let avg_skill = group.iter().map(|&idx| entries[idx].1.skill_rating).sum::<f32>()
653                    / group.len() as f32;
654                let game_mode = entries[group[0]].1.preferences.game_mode
655                    .unwrap_or(GameMode::DEATHMATCH);
656
657                for &idx in &group { used.insert(idx); }
658
659                matched.push(ProposedMatch {
660                    players,
661                    game_mode,
662                    map_id: 0,
663                    avg_skill,
664                });
665            }
666        }
667
668        // Remove matched players from queue
669        let matched_ids: std::collections::HashSet<PlayerId> = matched.iter()
670            .flat_map(|m| m.players.iter().copied())
671            .collect();
672        self.queue.retain(|e| !matched_ids.contains(&e.player_id));
673
674        matched
675    }
676
677    /// Register a backfill job (open slots in an ongoing game).
678    pub fn add_backfill(&mut self, job: BackfillJob) {
679        self.backfill_jobs.push(job);
680    }
681
682    /// Try to fill backfill jobs from the queue.
683    /// Returns (BackfillJob, Vec<PlayerId>) pairs for each successful fill.
684    pub fn process_backfill(&mut self) -> Vec<(BackfillJob, Vec<PlayerId>)> {
685        let mut results = Vec::new();
686        let mut filled_ids: std::collections::HashSet<PlayerId> = std::collections::HashSet::new();
687
688        let jobs = std::mem::take(&mut self.backfill_jobs);
689        for job in jobs {
690            let mut candidates: Vec<PlayerId> = Vec::new();
691            for entry in self.queue.iter() {
692                if filled_ids.contains(&entry.player_id) { continue; }
693                if entry.preferences.game_mode.map_or(false, |gm| gm != job.game_mode) { continue; }
694                if (entry.skill_rating - job.skill_avg).abs() > 200.0 { continue; }
695                candidates.push(entry.player_id);
696                if candidates.len() >= job.open_slots as usize { break; }
697            }
698            if !candidates.is_empty() {
699                for &pid in &candidates { filled_ids.insert(pid); }
700                results.push((job, candidates));
701            } else {
702                self.backfill_jobs.push(job); // put back
703            }
704        }
705        self.queue.retain(|e| !filled_ids.contains(&e.player_id));
706        results
707    }
708
709    pub fn queue_len(&self) -> usize { self.queue.len() }
710    pub fn backfill_count(&self) -> usize { self.backfill_jobs.len() }
711}
712
713// ─── VoiceChat ────────────────────────────────────────────────────────────────
714
715/// Voice channel type (metadata only — no actual audio transport).
716#[derive(Debug, Clone, Copy, PartialEq, Eq)]
717pub enum VoiceChannel {
718    /// All players in the lobby hear each other.
719    Lobby,
720    /// Only members of the same team.
721    Team(TeamId),
722    /// Direct voice to one player.
723    Direct(PlayerId),
724}
725
726/// Player's current voice state.
727#[derive(Debug, Clone)]
728pub struct VoiceState {
729    pub player_id:   PlayerId,
730    pub channel:     VoiceChannel,
731    pub muted:       bool,
732    pub deafened:    bool,
733    pub push_to_talk: bool,
734    pub speaking:    bool,
735}
736
737impl VoiceState {
738    pub fn new(player_id: PlayerId) -> Self {
739        Self {
740            player_id, channel: VoiceChannel::Lobby,
741            muted: false, deafened: false, push_to_talk: false, speaking: false,
742        }
743    }
744}
745
746/// Voice-chat metadata manager for a lobby.
747pub struct VoiceChatManager {
748    states: HashMap<PlayerId, VoiceState>,
749}
750
751impl VoiceChatManager {
752    pub fn new() -> Self { Self { states: HashMap::new() } }
753
754    pub fn add_player(&mut self, player_id: PlayerId) {
755        self.states.insert(player_id, VoiceState::new(player_id));
756    }
757
758    pub fn remove_player(&mut self, player_id: PlayerId) {
759        self.states.remove(&player_id);
760    }
761
762    pub fn set_muted(&mut self, player_id: PlayerId, muted: bool) {
763        if let Some(s) = self.states.get_mut(&player_id) { s.muted = muted; }
764    }
765
766    pub fn set_deafened(&mut self, player_id: PlayerId, deafened: bool) {
767        if let Some(s) = self.states.get_mut(&player_id) { s.deafened = deafened; }
768    }
769
770    pub fn set_push_to_talk(&mut self, player_id: PlayerId, ptt: bool) {
771        if let Some(s) = self.states.get_mut(&player_id) { s.push_to_talk = ptt; }
772    }
773
774    pub fn set_speaking(&mut self, player_id: PlayerId, speaking: bool) {
775        if let Some(s) = self.states.get_mut(&player_id) {
776            if !s.muted { s.speaking = speaking; }
777        }
778    }
779
780    pub fn set_channel(&mut self, player_id: PlayerId, channel: VoiceChannel) {
781        if let Some(s) = self.states.get_mut(&player_id) { s.channel = channel; }
782    }
783
784    /// Returns the list of players that `listener` can hear in the current state.
785    pub fn audible_speakers(&self, listener: PlayerId) -> Vec<PlayerId> {
786        let listener_state = match self.states.get(&listener) {
787            Some(s) => s,
788            None => return Vec::new(),
789        };
790        if listener_state.deafened { return Vec::new(); }
791
792        self.states.values()
793            .filter(|s| s.player_id != listener && s.speaking && !s.muted)
794            .filter(|s| {
795                // Can hear if: same channel or global channel
796                match &s.channel {
797                    VoiceChannel::Lobby => true,
798                    VoiceChannel::Team(t) => {
799                        listener_state.channel == VoiceChannel::Team(*t)
800                    }
801                    VoiceChannel::Direct(target) => *target == listener,
802                }
803            })
804            .map(|s| s.player_id)
805            .collect()
806    }
807}
808
809impl Default for VoiceChatManager {
810    fn default() -> Self { Self::new() }
811}
812
813// ─── TeamSystem ───────────────────────────────────────────────────────────────
814
815/// Balance strategy for team assignment.
816#[derive(Debug, Clone, Copy, PartialEq, Eq)]
817pub enum TeamBalance {
818    /// Server automatically assigns to minimize skill imbalance.
819    Auto,
820    /// Random assignment.
821    Random,
822    /// Players choose manually; server enforces max-per-team.
823    Manual,
824}
825
826/// A single team.
827#[derive(Debug, Clone)]
828pub struct Team {
829    pub id:      TeamId,
830    pub name:    String,
831    pub players: Vec<PlayerId>,
832    pub max_size: u8,
833}
834
835impl Team {
836    pub fn new(id: TeamId, name: impl Into<String>, max_size: u8) -> Self {
837        Self { id, name: name.into(), players: Vec::new(), max_size }
838    }
839    pub fn is_full(&self) -> bool { self.players.len() >= self.max_size as usize }
840    pub fn has_player(&self, pid: PlayerId) -> bool { self.players.contains(&pid) }
841}
842
843/// Manages team composition and automatic balancing.
844pub struct TeamSystem {
845    pub teams:        Vec<Team>,
846    pub balance_mode: TeamBalance,
847    rng_state:        u64, // simple LCG for random assignment
848}
849
850impl TeamSystem {
851    pub fn new(balance_mode: TeamBalance) -> Self {
852        Self { teams: Vec::new(), balance_mode, rng_state: 0xDEAD_BEEF_CAFE_BABE }
853    }
854
855    fn lcg_rand(&mut self) -> u64 {
856        self.rng_state = self.rng_state.wrapping_mul(6364136223846793005)
857            .wrapping_add(1442695040888963407);
858        self.rng_state
859    }
860
861    pub fn add_team(&mut self, id: TeamId, name: impl Into<String>, max_size: u8) {
862        self.teams.push(Team::new(id, name, max_size));
863    }
864
865    pub fn remove_team(&mut self, id: TeamId) {
866        self.teams.retain(|t| t.id != id);
867    }
868
869    pub fn team(&self, id: TeamId) -> Option<&Team> {
870        self.teams.iter().find(|t| t.id == id)
871    }
872
873    pub fn team_mut(&mut self, id: TeamId) -> Option<&mut Team> {
874        self.teams.iter_mut().find(|t| t.id == id)
875    }
876
877    /// Assign `player_id` to a team based on `balance_mode`.
878    /// Returns the assigned `TeamId`.
879    pub fn assign(
880        &mut self,
881        player_id: PlayerId,
882        skill_rating: f32,
883        preferred_team: Option<TeamId>,
884    ) -> Result<TeamId, LobbyError> {
885        if self.teams.is_empty() {
886            return Err(LobbyError::TeamNotFound(TeamId(0)));
887        }
888
889        match self.balance_mode {
890            TeamBalance::Manual => {
891                let tid = preferred_team.ok_or(LobbyError::TeamNotFound(TeamId(0)))?;
892                let team = self.team_mut(tid).ok_or(LobbyError::TeamNotFound(tid))?;
893                if team.is_full() { return Err(LobbyError::LobbyFull); }
894                team.players.push(player_id);
895                Ok(tid)
896            }
897            TeamBalance::Random => {
898                let n = self.teams.len();
899                let idx = (self.lcg_rand() as usize) % n;
900                // Try to find a non-full team starting from idx
901                for offset in 0..n {
902                    let team = &mut self.teams[(idx + offset) % n];
903                    if !team.is_full() {
904                        team.players.push(player_id);
905                        return Ok(team.id);
906                    }
907                }
908                Err(LobbyError::LobbyFull)
909            }
910            TeamBalance::Auto => {
911                // Assign to team with lowest total skill rating that has room
912                let mut best_idx = None;
913                let mut best_skill = f32::MAX;
914
915                // Compute each team's current skill sum
916                // (We'd normally have access to a skill map, but for now assign to smallest team)
917                let _ = skill_rating; // used for future skill-based balancing
918
919                for (i, team) in self.teams.iter().enumerate() {
920                    if team.is_full() { continue; }
921                    // Use player count as a proxy: prefer smaller teams
922                    let proxy = team.players.len() as f32;
923                    if proxy < best_skill {
924                        best_skill = proxy;
925                        best_idx = Some(i);
926                    }
927                }
928                if let Some(i) = best_idx {
929                    let tid = self.teams[i].id;
930                    self.teams[i].players.push(player_id);
931                    Ok(tid)
932                } else {
933                    Err(LobbyError::LobbyFull)
934                }
935            }
936        }
937    }
938
939    /// Remove a player from all teams.
940    pub fn remove_player(&mut self, player_id: PlayerId) {
941        for team in &mut self.teams {
942            team.players.retain(|&p| p != player_id);
943        }
944    }
945
946    /// Rebalance teams by moving one player from the largest team to the smallest.
947    /// Returns the player moved if any.
948    pub fn rebalance(&mut self) -> Option<(PlayerId, TeamId, TeamId)> {
949        if self.teams.len() < 2 { return None; }
950
951        let max_idx = self.teams.iter().enumerate().max_by_key(|(_, t)| t.players.len())?.0;
952        let min_idx = self.teams.iter().enumerate().min_by_key(|(_, t)| t.players.len())?.0;
953
954        if self.teams[max_idx].players.len() <= self.teams[min_idx].players.len() + 1 {
955            return None; // already balanced
956        }
957
958        let player = *self.teams[max_idx].players.last()?;
959        self.teams[max_idx].players.pop();
960        let to = self.teams[min_idx].id;
961        let from = self.teams[max_idx].id;
962        self.teams[min_idx].players.push(player);
963        Some((player, from, to))
964    }
965}
966
967// ─── ReadyCheck ───────────────────────────────────────────────────────────────
968
969/// Vote-based ready check before game start.
970#[derive(Debug, Clone, PartialEq, Eq)]
971pub enum ReadyCheckState {
972    Pending,
973    AllReady,
974    TimedOut,
975    Cancelled,
976}
977
978pub struct ReadyCheck {
979    pub votes: HashMap<PlayerId, bool>,
980    pub state: ReadyCheckState,
981    started_at: Instant,
982    timeout_secs: f32,
983}
984
985impl ReadyCheck {
986    pub fn new(timeout_secs: f32) -> Self {
987        Self {
988            votes: HashMap::new(),
989            state: ReadyCheckState::Pending,
990            started_at: Instant::now(),
991            timeout_secs,
992        }
993    }
994
995    /// Register a player as participating in this check.
996    pub fn add_participant(&mut self, player_id: PlayerId) {
997        self.votes.insert(player_id, false);
998    }
999
1000    /// Record a vote.
1001    pub fn vote(&mut self, player_id: PlayerId, ready: bool) {
1002        if let Some(v) = self.votes.get_mut(&player_id) {
1003            *v = ready;
1004        }
1005    }
1006
1007    /// Check if all participants voted ready.
1008    pub fn check(&mut self) -> ReadyCheckState {
1009        if self.state != ReadyCheckState::Pending {
1010            return self.state.clone();
1011        }
1012        if self.started_at.elapsed().as_secs_f32() >= self.timeout_secs {
1013            self.state = ReadyCheckState::TimedOut;
1014            return self.state.clone();
1015        }
1016        if self.votes.values().all(|&v| v) && !self.votes.is_empty() {
1017            self.state = ReadyCheckState::AllReady;
1018        }
1019        self.state.clone()
1020    }
1021
1022    pub fn cancel(&mut self) { self.state = ReadyCheckState::Cancelled; }
1023
1024    pub fn ready_count(&self) -> usize { self.votes.values().filter(|&&v| v).count() }
1025    pub fn total_count(&self) -> usize { self.votes.len() }
1026}
1027
1028// ─── Tests ────────────────────────────────────────────────────────────────────
1029
1030#[cfg(test)]
1031mod tests {
1032    use super::*;
1033
1034    fn pid(n: u64) -> PlayerId { PlayerId(n) }
1035    fn lid(n: u64) -> LobbyId { LobbyId(n) }
1036
1037    fn default_config() -> LobbyConfig {
1038        LobbyConfig {
1039            max_players: 4,
1040            min_players: 2,
1041            countdown_secs: 0.01, // very short for tests
1042            ..LobbyConfig::default()
1043        }
1044    }
1045
1046    // ── LobbyManager ─────────────────────────────────────────────────────────
1047
1048    #[test]
1049    fn test_create_and_destroy_lobby() {
1050        let mut mgr = LobbyManager::new();
1051        let lid = mgr.create_lobby(pid(1), "Alice", "Alice's lobby", default_config()).unwrap();
1052        assert_eq!(mgr.lobby_count(), 1);
1053        mgr.destroy_lobby(lid).unwrap();
1054        assert_eq!(mgr.lobby_count(), 0);
1055    }
1056
1057    #[test]
1058    fn test_join_leave() {
1059        let mut mgr = LobbyManager::new();
1060        let lid = mgr.create_lobby(pid(1), "Host", "Lobby", default_config()).unwrap();
1061        mgr.join(pid(2), "P2", lid, "").unwrap();
1062        assert_eq!(mgr.lobby(lid).unwrap().player_count(), 2);
1063        mgr.leave(pid(2)).unwrap();
1064        assert_eq!(mgr.lobby(lid).unwrap().player_count(), 1);
1065    }
1066
1067    #[test]
1068    fn test_kick() {
1069        let mut mgr = LobbyManager::new();
1070        let lid = mgr.create_lobby(pid(1), "Host", "Lobby", default_config()).unwrap();
1071        mgr.join(pid(2), "P2", lid, "").unwrap();
1072        mgr.kick(pid(1), pid(2)).unwrap();
1073        assert_eq!(mgr.lobby(lid).unwrap().player_count(), 1);
1074    }
1075
1076    #[test]
1077    fn test_start_game_requires_min_players() {
1078        let mut mgr = LobbyManager::new();
1079        let lid = mgr.create_lobby(pid(1), "Host", "Lobby", default_config()).unwrap();
1080        // Only 1 player, min is 2
1081        assert_eq!(mgr.start_game(pid(1)), Err(LobbyError::NotEnoughPlayers));
1082        mgr.join(pid(2), "P2", lid, "").unwrap();
1083        assert!(mgr.start_game(pid(1)).is_ok());
1084    }
1085
1086    #[test]
1087    fn test_lobby_password() {
1088        let mut mgr = LobbyManager::new();
1089        let config = LobbyConfig { password: "secret".into(), ..default_config() };
1090        let lid = mgr.create_lobby(pid(1), "Host", "Lobby", config).unwrap();
1091        assert_eq!(mgr.join(pid(2), "P2", lid, "wrong"), Err(LobbyError::WrongPassword));
1092        assert!(mgr.join(pid(2), "P2", lid, "secret").is_ok());
1093    }
1094
1095    #[test]
1096    fn test_lobby_full() {
1097        let mut mgr = LobbyManager::new();
1098        let config = LobbyConfig { max_players: 2, ..default_config() };
1099        let lid = mgr.create_lobby(pid(1), "Host", "Lobby", config).unwrap();
1100        mgr.join(pid(2), "P2", lid, "").unwrap();
1101        assert_eq!(mgr.join(pid(3), "P3", lid, ""), Err(LobbyError::LobbyFull));
1102    }
1103
1104    // ── TeamSystem ────────────────────────────────────────────────────────────
1105
1106    #[test]
1107    fn test_team_auto_balance() {
1108        let mut ts = TeamSystem::new(TeamBalance::Auto);
1109        ts.add_team(TeamId(0), "Red", 4);
1110        ts.add_team(TeamId(1), "Blue", 4);
1111
1112        for i in 0..4u64 {
1113            ts.assign(pid(i), 1000.0, None).unwrap();
1114        }
1115        let r = ts.team(TeamId(0)).unwrap().players.len();
1116        let b = ts.team(TeamId(1)).unwrap().players.len();
1117        assert_eq!(r + b, 4);
1118        assert!((r as i32 - b as i32).abs() <= 1);
1119    }
1120
1121    #[test]
1122    fn test_team_rebalance() {
1123        let mut ts = TeamSystem::new(TeamBalance::Manual);
1124        ts.add_team(TeamId(0), "Red", 8);
1125        ts.add_team(TeamId(1), "Blue", 8);
1126        // Put 3 on Red, 1 on Blue
1127        for p in [0u64, 1, 2] {
1128            ts.team_mut(TeamId(0)).unwrap().players.push(pid(p));
1129        }
1130        ts.team_mut(TeamId(1)).unwrap().players.push(pid(3));
1131        let result = ts.rebalance();
1132        assert!(result.is_some());
1133        let r = ts.team(TeamId(0)).unwrap().players.len();
1134        let b = ts.team(TeamId(1)).unwrap().players.len();
1135        assert_eq!(r, 2);
1136        assert_eq!(b, 2);
1137    }
1138
1139    // ── ReadyCheck ────────────────────────────────────────────────────────────
1140
1141    #[test]
1142    fn test_ready_check_all_ready() {
1143        let mut rc = ReadyCheck::new(30.0);
1144        rc.add_participant(pid(1));
1145        rc.add_participant(pid(2));
1146        rc.vote(pid(1), true);
1147        rc.vote(pid(2), true);
1148        assert_eq!(rc.check(), ReadyCheckState::AllReady);
1149    }
1150
1151    #[test]
1152    fn test_ready_check_not_all_ready() {
1153        let mut rc = ReadyCheck::new(30.0);
1154        rc.add_participant(pid(1));
1155        rc.add_participant(pid(2));
1156        rc.vote(pid(1), true);
1157        assert_eq!(rc.check(), ReadyCheckState::Pending);
1158    }
1159
1160    // ── MatchmakingQueue ──────────────────────────────────────────────────────
1161
1162    #[test]
1163    fn test_matchmaking_basic() {
1164        let mut q = MatchmakingQueue::new(2);
1165        for i in 0..2u64 {
1166            q.enqueue(QueueEntry {
1167                player_id:    pid(i),
1168                skill_rating: 1000.0,
1169                queue_time:   Instant::now(),
1170                party_id:     None,
1171                preferences:  MatchPreferences::default(),
1172            });
1173        }
1174        let matches = q.tick();
1175        assert_eq!(matches.len(), 1);
1176        assert_eq!(matches[0].players.len(), 2);
1177    }
1178
1179    // ── LobbyBrowser ─────────────────────────────────────────────────────────
1180
1181    #[test]
1182    fn test_lobby_browser_filter_and_sort() {
1183        let mut browser = LobbyBrowser::new(1000);
1184        browser.update_listings(vec![
1185            LobbyInfo { id: lid(1), name: "Alpha".into(), player_count: 3, max_players: 8,
1186                        map_id: 1, game_mode: GameMode::DEATHMATCH, ping_ms: 50, has_password: false },
1187            LobbyInfo { id: lid(2), name: "Beta".into(), player_count: 1, max_players: 8,
1188                        map_id: 2, game_mode: GameMode::TEAM_VS_TEAM, ping_ms: 200, has_password: true },
1189        ]);
1190        let filter = LobbyFilter { hide_private: true, ..LobbyFilter::default() };
1191        let list = browser.filtered_sorted(&filter, LobbySort::ByPlayerCount);
1192        assert_eq!(list.len(), 1);
1193        assert_eq!(list[0].id, lid(1));
1194    }
1195}