gameserv: ranked ladder rides StatServ's persisted counters, no separate store
All checks were successful
CI / check (push) Successful in 3m50s
All checks were successful
CI / check (push) Successful in 3m50s
This commit is contained in:
parent
b763eab9ed
commit
108c15e95a
3 changed files with 156 additions and 51 deletions
|
|
@ -32,28 +32,40 @@ pub struct Game {
|
||||||
pub result: String, // "" | "<winner account>" | "draw"
|
pub result: String, // "" | "<winner account>" | "draw"
|
||||||
}
|
}
|
||||||
|
|
||||||
// One (account, game type) ladder row. A player can be Gold at chess and Bronze
|
// One (account, game type) ladder row, materialised from the shared stat counters
|
||||||
// at Connect Four, so each type keeps its own record.
|
// (`game.<type>.<w|l|d>.<account>`) 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)]
|
#[derive(Default, Clone)]
|
||||||
pub struct Stats {
|
pub struct Stats {
|
||||||
pub wins: u32,
|
pub wins: u32,
|
||||||
pub losses: u32,
|
pub losses: u32,
|
||||||
pub draws: u32,
|
pub draws: u32,
|
||||||
pub points: i32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Stats {
|
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 {
|
pub fn rank(&self) -> &'static str {
|
||||||
if self.points >= 800 {
|
let p = self.points();
|
||||||
|
if p >= 800 {
|
||||||
"Master"
|
"Master"
|
||||||
} else if self.points >= 400 {
|
} else if p >= 400 {
|
||||||
"Gold"
|
"Gold"
|
||||||
} else if self.points >= 150 {
|
} else if p >= 150 {
|
||||||
"Silver"
|
"Silver"
|
||||||
} else {
|
} else {
|
||||||
"Bronze"
|
"Bronze"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn any(&self) -> bool {
|
||||||
|
self.wins > 0 || self.losses > 0 || self.draws > 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn valid_type(t: &str) -> bool {
|
pub fn valid_type(t: &str) -> bool {
|
||||||
|
|
|
||||||
|
|
@ -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)." },
|
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.
|
// The ladder lives in the shared stat counters (StatServ's persistence), keyed
|
||||||
type Ladder = HashMap<String, PlayerLadder>;
|
// `game.<type>.<w|l|d>.<account>`. Nothing is stored in GameServ itself.
|
||||||
|
fn counter_key(gtype: &str, kind: char, account: &str) -> String {
|
||||||
|
format!("game.{gtype}.{kind}.{account}")
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
// Materialise a player's record for one game type from a counter snapshot.
|
||||||
struct PlayerLadder {
|
fn read_stats(counters: &[(String, u64)], gtype: &str, account: &str) -> Stats {
|
||||||
display: String,
|
let get = |kind: char| {
|
||||||
by_type: HashMap<String, Stats>,
|
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 struct GameServ {
|
||||||
pub uid: String,
|
pub uid: String,
|
||||||
games: HashMap<u64, Game>,
|
games: HashMap<u64, Game>,
|
||||||
ladder: Ladder,
|
|
||||||
nextid: u64,
|
nextid: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GameServ {
|
impl GameServ {
|
||||||
pub fn new(uid: String) -> Self {
|
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
|
// A player's ranked identity: their account if registered, else their nick
|
||||||
|
|
@ -64,15 +69,6 @@ impl GameServ {
|
||||||
from.account.unwrap_or(from.nick)
|
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) {
|
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 {
|
let (Some(&target), Some(&game)) = (args.get(1), args.get(2)) else {
|
||||||
ctx.notice(me, from.uid, "Syntax: CHALLENGE <nick> <ttt|c4|chess>");
|
ctx.notice(me, from.uid, "Syntax: CHALLENGE <nick> <ttt|c4|chess>");
|
||||||
|
|
@ -230,26 +226,44 @@ impl GameServ {
|
||||||
} else {
|
} else {
|
||||||
g.acc_b.clone()
|
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" {
|
if winner_side == "draw" {
|
||||||
self.stat(&g.acc_a, &g.gtype).draws += 1;
|
ctx.count(counter_key(&g.gtype, 'd', &g.acc_a));
|
||||||
self.stat(&g.acc_b, &g.gtype).draws += 1;
|
ctx.count(counter_key(&g.gtype, 'd', &g.acc_b));
|
||||||
} else {
|
} 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 (wacc, lacc) = if a_won { (&g.acc_a, &g.acc_b) } else { (&g.acc_b, &g.acc_a) };
|
||||||
let w = self.stat(&wacc, &g.gtype);
|
ctx.count(counter_key(&g.gtype, 'w', wacc));
|
||||||
w.wins += 1;
|
ctx.count(counter_key(&g.gtype, 'l', lacc));
|
||||||
w.points += 10;
|
|
||||||
let l = self.stat(&lacc, &g.gtype);
|
|
||||||
l.losses += 1;
|
|
||||||
l.points = (l.points - 8).max(0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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);
|
push_state(&g, me, net, ctx);
|
||||||
if g.a_reg {
|
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 {
|
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.");
|
ctx.notice(me, from.uid, "Log in or specify a nick.");
|
||||||
return;
|
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"));
|
ctx.notice(me, from.uid, format!("\x02{who}\x02 — classement Jeux"));
|
||||||
let mut any = false;
|
let mut any = false;
|
||||||
let pl = self.ladder.get(&who.to_lowercase());
|
|
||||||
for ty in TYPES {
|
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;
|
any = true;
|
||||||
ctx.notice(me, from.uid, format!(
|
ctx.notice(me, from.uid, format!(
|
||||||
" \x02{}\x02 \x02{}\x02 — {} pts ({}V {}D {}N)",
|
" \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.");
|
ctx.notice(me, from.uid, "Unknown game. Use \x02TOP ttt|c4|chess\x02.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut all: Vec<(&str, &Stats)> = self.ladder.values()
|
// Aggregate the per-account w/l/d counters for this game type.
|
||||||
.filter_map(|pl| pl.by_type.get(&ty).filter(|s| s.points > 0).map(|s| (pl.display.as_str(), s)))
|
let counters = net.stat_counters();
|
||||||
.collect();
|
let prefix = format!("game.{ty}.");
|
||||||
all.sort_by_key(|&(_, s)| std::cmp::Reverse(s.points));
|
let mut map: HashMap<String, Stats> = 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);
|
push_tag(me, net, from.nick, &format!("GS$TOPSTART {ty}"), ctx);
|
||||||
if all.is_empty() {
|
if all.is_empty() {
|
||||||
ctx.notice(me, from.uid, format!("Classement \x02{}\x02 vide. Sois le premier à gagner !", board::type_label(&ty)));
|
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)));
|
ctx.notice(me, from.uid, format!("\x02Classement {} :\x02", board::type_label(&ty)));
|
||||||
for (rank, (acc, s)) in all.iter().take(10).enumerate() {
|
for (rank, (acc, s)) in all.iter().take(10).enumerate() {
|
||||||
let rank = rank + 1;
|
let rank = rank + 1;
|
||||||
ctx.notice(me, from.uid, format!("{rank:2}. {acc:<16} {} {} pts", s.rank(), s.points));
|
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);
|
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);
|
push_one(g, 1, "", me, net, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// One GS$ stats line per game type (for the client's rank strip / profile badge).
|
// 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) {
|
// read from the shared counter snapshot. `override_ty` lets a just-finished game
|
||||||
let pl = ladder.get(&account.to_lowercase());
|
// 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 {
|
for ty in TYPES {
|
||||||
let s = pl.and_then(|p| p.by_type.get(ty)).cloned().unwrap_or_default();
|
let s = match override_ty {
|
||||||
let payload = format!("GS$ {} {} r={} p={} w={} l={} d={}", account, ty, s.rank(), s.points, s.wins, s.losses, s.draws);
|
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);
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)");
|
ctx.notice(me, from.uid, " (no activity counters yet)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (key, value) in counters {
|
// The generic service counters. GameServ's ranked-ladder counters
|
||||||
|
// (`game.<type>.<w|l|d>.<account>`) 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}"));
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue