diff --git a/modules/gameserv/src/board.rs b/modules/gameserv/src/board.rs index 217858a..2abfbb6 100644 --- a/modules/gameserv/src/board.rs +++ b/modules/gameserv/src/board.rs @@ -32,28 +32,40 @@ pub struct Game { pub result: String, // "" | "" | "draw" } -// One (account, game type) ladder row. A player can be Gold at chess and Bronze -// at Connect Four, so each type keeps its own record. +// One (account, game type) ladder row, materialised from the shared stat counters +// (`game...`) that StatServ already persists. A player can be +// Gold at chess and Bronze at Connect Four, so each type keeps its own record. #[derive(Default, Clone)] pub struct Stats { pub wins: u32, pub losses: u32, pub draws: u32, - pub points: i32, } impl Stats { + // Points are DERIVED from the win/loss counts (draws don't score), floored at + // 0: win +10, loss -8. Deriving keeps the ladder to monotonic w/l/d counters, + // so it rides StatServ's existing counter persistence with nothing to sync. + pub fn points(&self) -> i32 { + (10 * self.wins as i32 - 8 * self.losses as i32).max(0) + } + pub fn rank(&self) -> &'static str { - if self.points >= 800 { + let p = self.points(); + if p >= 800 { "Master" - } else if self.points >= 400 { + } else if p >= 400 { "Gold" - } else if self.points >= 150 { + } else if p >= 150 { "Silver" } else { "Bronze" } } + + pub fn any(&self) -> bool { + self.wins > 0 || self.losses > 0 || self.draws > 0 + } } pub fn valid_type(t: &str) -> bool { diff --git a/modules/gameserv/src/lib.rs b/modules/gameserv/src/lib.rs index ff9dba1..983f9bb 100644 --- a/modules/gameserv/src/lib.rs +++ b/modules/gameserv/src/lib.rs @@ -37,25 +37,30 @@ const TOPICS: &[HelpEntry] = &[ HelpEntry { cmd: "TOP", summary: "show the leaderboard", detail: "Syntax: \x02TOP [ttt|c4|chess]\x02\nShows the top ranked players for a game type (chess by default)." }, ]; -// account key (lowercased) -> that player's ladder across game types. -type Ladder = HashMap; +// The ladder lives in the shared stat counters (StatServ's persistence), keyed +// `game...`. Nothing is stored in GameServ itself. +fn counter_key(gtype: &str, kind: char, account: &str) -> String { + format!("game.{gtype}.{kind}.{account}") +} -#[derive(Default)] -struct PlayerLadder { - display: String, - by_type: HashMap, +// Materialise a player's record for one game type from a counter snapshot. +fn read_stats(counters: &[(String, u64)], gtype: &str, account: &str) -> Stats { + let get = |kind: char| { + let key = counter_key(gtype, kind, account); + counters.iter().find(|(k, _)| *k == key).map(|(_, v)| *v as u32).unwrap_or(0) + }; + Stats { wins: get('w'), losses: get('l'), draws: get('d') } } pub struct GameServ { pub uid: String, games: HashMap, - ladder: Ladder, nextid: u64, } impl GameServ { pub fn new(uid: String) -> Self { - Self { uid, games: HashMap::new(), ladder: Ladder::new(), nextid: 1 } + Self { uid, games: HashMap::new(), nextid: 1 } } // A player's ranked identity: their account if registered, else their nick @@ -64,15 +69,6 @@ impl GameServ { from.account.unwrap_or(from.nick) } - // Get (creating if needed) a mutable ladder row, remembering the display name. - fn stat(&mut self, account: &str, gtype: &str) -> &mut Stats { - let pl = self.ladder.entry(account.to_lowercase()).or_default(); - if pl.display.is_empty() { - pl.display = account.to_string(); - } - pl.by_type.entry(gtype.to_string()).or_default() - } - fn do_challenge(&mut self, me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) { let (Some(&target), Some(&game)) = (args.get(1), args.get(2)) else { ctx.notice(me, from.uid, "Syntax: CHALLENGE "); @@ -230,26 +226,44 @@ impl GameServ { } else { g.acc_b.clone() }; - if g.a_reg && g.b_reg { + // Ranked only when both sides are registered accounts (casual guest games + // leave the ladder untouched). Record the outcome as monotonic counters — + // StatServ persists them; points are derived on read. + let ranked = g.a_reg && g.b_reg; + if ranked { if winner_side == "draw" { - self.stat(&g.acc_a, &g.gtype).draws += 1; - self.stat(&g.acc_b, &g.gtype).draws += 1; + ctx.count(counter_key(&g.gtype, 'd', &g.acc_a)); + ctx.count(counter_key(&g.gtype, 'd', &g.acc_b)); } else { - let (wacc, lacc) = if a_won { (g.acc_a.clone(), g.acc_b.clone()) } else { (g.acc_b.clone(), g.acc_a.clone()) }; - let w = self.stat(&wacc, &g.gtype); - w.wins += 1; - w.points += 10; - let l = self.stat(&lacc, &g.gtype); - l.losses += 1; - l.points = (l.points - 8).max(0); + let (wacc, lacc) = if a_won { (&g.acc_a, &g.acc_b) } else { (&g.acc_b, &g.acc_a) }; + ctx.count(counter_key(&g.gtype, 'w', wacc)); + ctx.count(counter_key(&g.gtype, 'l', lacc)); } } + // The counter bumps land AFTER this command, so a snapshot here is pre-game; + // fold in this game's delta so the pushed rank already reflects the result. + let counters = net.stat_counters(); + let with_delta = |account: &str, is_a: bool| -> Stats { + let mut s = read_stats(&counters, &g.gtype, account); + if ranked { + if winner_side == "draw" { + s.draws += 1; + } else if a_won == is_a { + s.wins += 1; + } else { + s.losses += 1; + } + } + s + }; push_state(&g, me, net, ctx); if g.a_reg { - push_stats(&self.ladder, &g.acc_a, &g.nick_a, me, net, ctx); + let s = with_delta(&g.acc_a, true); + push_stats(&counters, &g.acc_a, &g.nick_a, me, net, ctx, Some((&g.gtype, &s))); } if g.b_reg { - push_stats(&self.ladder, &g.acc_b, &g.nick_b, me, net, ctx); + let s = with_delta(&g.acc_b, false); + push_stats(&counters, &g.acc_b, &g.nick_b, me, net, ctx, Some((&g.gtype, &s))); } } @@ -277,16 +291,17 @@ impl GameServ { ctx.notice(me, from.uid, "Log in or specify a nick."); return; }; - push_stats(&self.ladder, &who, from.nick, me, net, ctx); // machine lines for the UI + let counters = net.stat_counters(); + push_stats(&counters, &who, from.nick, me, net, ctx, None); // machine lines for the UI ctx.notice(me, from.uid, format!("\x02{who}\x02 — classement Jeux")); let mut any = false; - let pl = self.ladder.get(&who.to_lowercase()); for ty in TYPES { - if let Some(s) = pl.and_then(|p| p.by_type.get(ty)) { + let s = read_stats(&counters, ty, &who); + if s.any() { any = true; ctx.notice(me, from.uid, format!( " \x02{}\x02 \x02{}\x02 — {} pts ({}V {}D {}N)", - board::type_label(ty), s.rank(), s.points, s.wins, s.losses, s.draws + board::type_label(ty), s.rank(), s.points(), s.wins, s.losses, s.draws )); } } @@ -301,10 +316,23 @@ impl GameServ { ctx.notice(me, from.uid, "Unknown game. Use \x02TOP ttt|c4|chess\x02."); return; } - let mut all: Vec<(&str, &Stats)> = self.ladder.values() - .filter_map(|pl| pl.by_type.get(&ty).filter(|s| s.points > 0).map(|s| (pl.display.as_str(), s))) - .collect(); - all.sort_by_key(|&(_, s)| std::cmp::Reverse(s.points)); + // Aggregate the per-account w/l/d counters for this game type. + let counters = net.stat_counters(); + let prefix = format!("game.{ty}."); + let mut map: HashMap = HashMap::new(); + for (k, v) in &counters { + let Some(rest) = k.strip_prefix(&prefix) else { continue }; + let Some((kind, acct)) = rest.split_once('.') else { continue }; + let e = map.entry(acct.to_string()).or_default(); + match kind { + "w" => e.wins = *v as u32, + "l" => e.losses = *v as u32, + "d" => e.draws = *v as u32, + _ => {} + } + } + let mut all: Vec<(String, Stats)> = map.into_iter().filter(|(_, s)| s.points() > 0).collect(); + all.sort_by_key(|(_, s)| std::cmp::Reverse(s.points())); push_tag(me, net, from.nick, &format!("GS$TOPSTART {ty}"), ctx); if all.is_empty() { ctx.notice(me, from.uid, format!("Classement \x02{}\x02 vide. Sois le premier à gagner !", board::type_label(&ty))); @@ -313,8 +341,8 @@ impl GameServ { ctx.notice(me, from.uid, format!("\x02Classement {} :\x02", board::type_label(&ty))); for (rank, (acc, s)) in all.iter().take(10).enumerate() { let rank = rank + 1; - ctx.notice(me, from.uid, format!("{rank:2}. {acc:<16} {} {} pts", s.rank(), s.points)); - push_tag(me, net, from.nick, &format!("GS$TOPROW {ty} {rank} {acc} {} {}", s.rank(), s.points), ctx); + ctx.notice(me, from.uid, format!("{rank:2}. {acc:<16} {} {} pts", s.rank(), s.points())); + push_tag(me, net, from.nick, &format!("GS$TOPROW {ty} {rank} {acc} {} {}", s.rank(), s.points()), ctx); } } } @@ -354,12 +382,16 @@ fn push_state(g: &Game, me: &str, net: &dyn NetView, ctx: &mut ServiceCtx) { push_one(g, 1, "", me, net, ctx); } -// One GS$ stats line per game type (for the client's rank strip / profile badge). -fn push_stats(ladder: &Ladder, account: &str, to_nick: &str, me: &str, net: &dyn NetView, ctx: &mut ServiceCtx) { - let pl = ladder.get(&account.to_lowercase()); +// One GS$ stats line per game type (for the client's rank strip / profile badge), +// read from the shared counter snapshot. `override_ty` lets a just-finished game +// show its post-game record even though the counter bump hasn't landed yet. +fn push_stats(counters: &[(String, u64)], account: &str, to_nick: &str, me: &str, net: &dyn NetView, ctx: &mut ServiceCtx, override_ty: Option<(&str, &Stats)>) { for ty in TYPES { - let s = pl.and_then(|p| p.by_type.get(ty)).cloned().unwrap_or_default(); - let payload = format!("GS$ {} {} r={} p={} w={} l={} d={}", account, ty, s.rank(), s.points, s.wins, s.losses, s.draws); + let s = match override_ty { + Some((oty, os)) if oty == ty => os.clone(), + _ => read_stats(counters, ty, account), + }; + let payload = format!("GS$ {} {} r={} p={} w={} l={} d={}", account, ty, s.rank(), s.points(), s.wins, s.losses, s.draws); push_tag(me, net, to_nick, &payload, ctx); } } @@ -397,3 +429,32 @@ impl Service for GameServ { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // The ladder is materialised from the shared counters; points derive from w/l. + #[test] + fn ladder_reads_counters_and_derives_points() { + let counters = vec![ + ("game.chess.w.Reverse".to_string(), 5u64), + ("game.chess.l.Reverse".to_string(), 2u64), + ("game.chess.d.Reverse".to_string(), 1u64), + ]; + let s = read_stats(&counters, "chess", "Reverse"); + assert_eq!((s.wins, s.losses, s.draws), (5, 2, 1)); + assert_eq!(s.points(), 34, "10*5 - 8*2"); + assert_eq!(s.rank(), "Bronze"); + + // A different game type is a separate ladder (empty here). + let c4 = read_stats(&counters, "c4", "Reverse"); + assert!(!c4.any()); + + // Losses alone floor points at 0. + let loser = read_stats(&[("game.ttt.l.Sad".to_string(), 9)], "ttt", "Sad"); + assert_eq!(loser.points(), 0); + + assert_eq!(counter_key("c4", 'w', "Bob"), "game.c4.w.Bob"); + } +} diff --git a/modules/statserv/src/global.rs b/modules/statserv/src/global.rs index f0f0fe0..3be61a0 100644 --- a/modules/statserv/src/global.rs +++ b/modules/statserv/src/global.rs @@ -15,7 +15,39 @@ pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, net: &dyn NetView, ctx.notice(me, from.uid, " (no activity counters yet)"); return; } - for (key, value) in counters { + // The generic service counters. GameServ's ranked-ladder counters + // (`game...`) live in the same registry but would flood + // this list, so they are summarised in the GAMES section instead. + for (key, value) in &counters { + if key.starts_with("game.") { + continue; + } ctx.notice(me, from.uid, format!(" {key}: {value}")); } + // GAMES: per game type, ranked games played and distinct players. + let mut header = false; + for ty in ["chess", "c4", "ttt"] { + let prefix = format!("game.{ty}."); + let (mut wins, mut draws) = (0u64, 0u64); + let mut players = std::collections::BTreeSet::new(); + for (key, value) in &counters { + let Some(rest) = key.strip_prefix(&prefix) else { continue }; + let Some((kind, account)) = rest.split_once('.') else { continue }; + players.insert(account); + match kind { + "w" => wins += value, + "d" => draws += value, + _ => {} + } + } + // Each decisive game records one win; each draw records `d` for both sides. + let games = wins + draws / 2; + if games > 0 { + if !header { + ctx.notice(me, from.uid, " games:"); + header = true; + } + ctx.notice(me, from.uid, format!(" {ty}: {games} games, {} players", players.len())); + } + } }