From cfe05481d61dac7f3b7d6ed8adcd48135c13214f Mon Sep 17 00:00:00 2001 From: Jean Date: Fri, 17 Jul 2026 12:20:26 +0000 Subject: [PATCH] gameserv: model the domain with enums (GameType/Side/Status/Outcome/Board), zero-alloc grid moves --- modules/gameserv/src/board.rs | 467 ++++++++++++++++++++-------------- modules/gameserv/src/chess.rs | 4 +- modules/gameserv/src/lib.rs | 338 +++++++++++++----------- 3 files changed, 472 insertions(+), 337 deletions(-) diff --git a/modules/gameserv/src/board.rs b/modules/gameserv/src/board.rs index 2abfbb6..d1c8f44 100644 --- a/modules/gameserv/src/board.rs +++ b/modules/gameserv/src/board.rs @@ -1,219 +1,314 @@ -//! The referee: game state and the per-game rules for tic-tac-toe, Connect Four -//! and chess. GameServ validates every move here and decides the winner, so a -//! client can never cheat (the board never leaves the server except as a signed -//! state line the web client only renders). Chess lives in `chess.rs`. +//! The referee: strongly-typed game state and rules for tic-tac-toe, Connect Four +//! and chess. Where the C++ original modelled everything as strings ("ttt", "X", +//! "pending", a 9-char board), this uses enums so illegal states are +//! unrepresentable, the per-game dispatch is exhaustively checked by the compiler +//! (no silent fallthrough), sides/status flow as `Copy` with no allocation, and a +//! grid move mutates a fixed array in place instead of cloning a String. Chess +//! lives in `chess.rs`. use crate::chess; +pub const C4_W: usize = 7; +pub const C4_H: usize = 6; +const C4_CELLS: usize = C4_W * C4_H; +const EMPTY: u8 = b' '; + const TTT_LINES: [[usize; 3]; 8] = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6], ]; -pub const C4_W: usize = 7; -pub const C4_H: usize = 6; -// One live game. `acc_*` is the ranked identity (account name, or the nick for a -// guest); `side_*` is the piece/colour each side plays. A = challenger, moves first. +/// Which game. `Copy`, so it flows without clones or string compares. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum GameType { + Ttt, + C4, + Chess, +} + +impl GameType { + pub fn from_arg(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "ttt" => Some(Self::Ttt), + "c4" => Some(Self::C4), + "chess" => Some(Self::Chess), + _ => None, + } + } + + /// Wire/config token: the GS# `g=` field, the counter-key segment, the TOP arg. + pub fn wire(self) -> &'static str { + match self { + Self::Ttt => "ttt", + Self::C4 => "c4", + Self::Chess => "chess", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Ttt => "Morpion", + Self::C4 => "Puissance 4", + Self::Chess => "Échecs", + } + } + + /// The piece/colour glyph a side plays; the challenger (A) moves first. + fn glyph(self, side: Side) -> u8 { + let (a, b) = match self { + Self::Ttt => (b'X', b'O'), + Self::C4 => (b'R', b'Y'), + Self::Chess => (b'w', b'b'), + }; + match side { + Side::A => a, + Side::B => b, + } + } + + fn side_of_glyph(self, glyph: u8) -> Side { + if glyph == self.glyph(Side::A) { + Side::A + } else { + Side::B + } + } +} + +/// The two players. A is the challenger and always moves first. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Side { + A, + B, +} + +impl Side { + pub fn other(self) -> Self { + match self { + Self::A => Self::B, + Self::B => Self::A, + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Status { + Pending, + Active, + Over, +} + +impl Status { + pub fn wire(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Active => "active", + Self::Over => "over", + } + } +} + +/// How a finished game ended. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Outcome { + Draw, + Win(Side), +} + +/// The board — also the single source of truth for the game kind. Grid games are +/// fixed byte arrays (`EMPTY` = free), so a move mutates in place with no alloc. +pub enum Board { + Ttt([u8; 9]), + C4([u8; C4_CELLS]), + Chess(chess::State), +} + +impl Board { + fn new(gt: GameType) -> Self { + match gt { + GameType::Ttt => Self::Ttt([EMPTY; 9]), + GameType::C4 => Self::C4([EMPTY; C4_CELLS]), + GameType::Chess => Self::Chess(chess::initial()), + } + } + + pub fn gtype(&self) -> GameType { + match self { + Self::Ttt(_) => GameType::Ttt, + Self::C4(_) => GameType::C4, + Self::Chess(_) => GameType::Chess, + } + } + + fn grid(&self) -> Option<&[u8]> { + match self { + Self::Ttt(c) => Some(c), + Self::C4(c) => Some(c), + Self::Chess(_) => None, + } + } + + /// Space-free wire form: grid empty cells become '.', chess a comma-FEN. + fn encode(&self) -> String { + match self { + Self::Chess(st) => chess::encode(st), + _ => self + .grid() + .unwrap() + .iter() + .map(|&b| if b == EMPTY { '.' } else { b as char }) + .collect(), + } + } +} + +/// One live game. `acc_*`/`nick_*` are the ranked identity and current nick of +/// each side; A is the challenger. The board carries the game kind, so `gtype()` +/// derives from it — no separate field to keep consistent. pub struct Game { pub id: u64, - pub gtype: String, // "ttt" | "c4" | "chess" pub acc_a: String, pub acc_b: String, pub nick_a: String, pub nick_b: String, pub a_reg: bool, pub b_reg: bool, - pub side_a: String, - pub side_b: String, - pub board: String, - pub turn: String, // side to move - pub status: String, // "pending" | "active" | "over" - pub result: String, // "" | "" | "draw" + pub board: Board, + pub turn: Side, + pub status: Status, + pub result: Option, } -// 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, -} - -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 { - let p = self.points(); - if p >= 800 { - "Master" - } else if p >= 400 { - "Gold" - } else if p >= 150 { - "Silver" - } else { - "Bronze" +impl Game { + #[allow(clippy::too_many_arguments)] + pub fn new(id: u64, gt: GameType, acc_a: String, a_reg: bool, acc_b: String, b_reg: bool, nick_a: String, nick_b: String) -> Self { + Self { + id, + acc_a, + acc_b, + nick_a, + nick_b, + a_reg, + b_reg, + board: Board::new(gt), + turn: Side::A, + status: Status::Pending, + result: None, } } - pub fn any(&self) -> bool { - self.wins > 0 || self.losses > 0 || self.draws > 0 + pub fn gtype(&self) -> GameType { + self.board.gtype() + } + + /// Space-free board for the wire. + pub fn encode(&self) -> String { + self.board.encode() + } + + /// The glyph the given side plays (GS# `t=`/`me=`). + pub fn glyph(&self, side: Side) -> char { + self.gtype().glyph(side) as char } } -pub fn valid_type(t: &str) -> bool { - matches!(t, "ttt" | "c4" | "chess") -} - -pub fn type_label(t: &str) -> &'static str { - match t { - "chess" => "Échecs", - "c4" => "Puissance 4", - "ttt" => "Morpion", - _ => "?", - } -} - -// The two sides for a game type; the challenger takes the first, moves first. -pub fn sides(gtype: &str) -> (&'static str, &'static str) { - match gtype { - "ttt" => ("X", "O"), - "c4" => ("R", "Y"), - _ => ("w", "b"), - } -} - -pub fn init_board(gtype: &str) -> String { - match gtype { - "ttt" => " ".repeat(9), - "c4" => " ".repeat(C4_W * C4_H), - "chess" => chess::encode(&chess::initial()), - _ => String::new(), - } -} - -// Apply `mv` for `side`; returns true iff it was legal and applied (mutating the -// board and flipping the turn). Rejects out-of-turn and illegal moves. -pub fn apply_move(g: &mut Game, side: &str, mv: &str) -> bool { +/// Apply `mv` for `side`; returns true iff it was legal and applied (mutating the +/// board and advancing the turn). Rejects out-of-turn and illegal moves. +pub fn apply_move(g: &mut Game, side: Side, mv: &str) -> bool { if g.turn != side { return false; } - let other = if side == g.side_a { g.side_b.clone() } else { g.side_a.clone() }; - match g.gtype.as_str() { - "ttt" => { - let c: i32 = mv.parse().unwrap_or(-1); - let mut b = g.board.clone().into_bytes(); - if !(0..=8).contains(&c) || b[c as usize] != b' ' { + let glyph = g.gtype().glyph(side); + match &mut g.board { + Board::Ttt(cells) => { + let Ok(c) = mv.parse::() else { return false }; + if c >= cells.len() || cells[c] != EMPTY { return false; } - b[c as usize] = side.as_bytes()[0]; - g.board = String::from_utf8(b).unwrap_or_default(); - g.turn = other; - true + cells[c] = glyph; } - "c4" => { - let col: i32 = mv.parse().unwrap_or(-1); - if col < 0 || col as usize >= C4_W { + Board::C4(cells) => { + let Ok(col) = mv.parse::() else { return false }; + if col >= C4_W { return false; } - let col = col as usize; - let mut b = g.board.clone().into_bytes(); - for r in 0..C4_H { - // row 0 = bottom; the piece drops to the lowest empty cell. - let i = r * C4_W + col; - if b[i] == b' ' { - b[i] = side.as_bytes()[0]; - g.board = String::from_utf8(b).unwrap_or_default(); - g.turn = other; - return true; - } - } - false // column full - } - "chess" => { - let st = chess::decode(&g.board); - match chess::parse(&st, mv) { - Some(m) => { - let ns = chess::apply(&st, &m); - g.board = chess::encode(&ns); - g.turn = (ns.turn as char).to_string(); - true - } - None => false, + // row 0 = bottom; the piece drops to the lowest empty cell. + match (0..C4_H).map(|r| r * C4_W + col).find(|&i| cells[i] == EMPTY) { + Some(i) => cells[i] = glyph, + None => return false, // column full } } - _ => false, + Board::Chess(st) => { + let Some(m) = chess::parse(st, mv) else { return false }; + *st = chess::apply(st, &m); + // Chess owns the side to move; keep the game turn in step with it. + g.turn = if st.turn == b'w' { Side::A } else { Side::B }; + return true; + } } + g.turn = side.other(); + true } -// "" = ongoing, "draw", or the winning side string. -pub fn terminal(g: &Game) -> String { - match g.gtype.as_str() { - "ttt" => { - let b = g.board.as_bytes(); +/// `None` while the game is ongoing, else how it ended. +pub fn terminal(g: &Game) -> Option { + match &g.board { + Board::Ttt(b) => { for ln in TTT_LINES { let a = b[ln[0]]; - if a != b' ' && a == b[ln[1]] && a == b[ln[2]] { - return (a as char).to_string(); + if a != EMPTY && a == b[ln[1]] && a == b[ln[2]] { + return Some(Outcome::Win(GameType::Ttt.side_of_glyph(a))); } } - if g.board.contains(' ') { String::new() } else { "draw".into() } + full_or_ongoing(b) } - "c4" => { - let b = g.board.as_bytes(); + Board::C4(b) => { let dirs: [(i32, i32); 4] = [(1, 0), (0, 1), (1, 1), (1, -1)]; for c in 0..C4_W as i32 { for r in 0..C4_H as i32 { let p = b[r as usize * C4_W + c as usize]; - if p == b' ' { + if p == EMPTY { continue; } for (dc, dr) in dirs { let (mut cc, mut rr) = (c, r); - let mut ok = true; - for _ in 0..4 { - if cc < 0 || cc >= C4_W as i32 || rr < 0 || rr >= C4_H as i32 - || b[rr as usize * C4_W + cc as usize] != p - { - ok = false; - break; - } + let run = (0..4).all(|_| { + let ok = (0..C4_W as i32).contains(&cc) + && (0..C4_H as i32).contains(&rr) + && b[rr as usize * C4_W + cc as usize] == p; cc += dc; rr += dr; - } - if ok { - return (p as char).to_string(); + ok + }); + if run { + return Some(Outcome::Win(GameType::C4.side_of_glyph(p))); } } } } - if g.board.contains(' ') { String::new() } else { "draw".into() } + full_or_ongoing(b) } - "chess" => chess::over(&chess::decode(&g.board)), - _ => String::new(), + Board::Chess(st) => match chess::over(st).as_str() { + "w" => Some(Outcome::Win(Side::A)), + "b" => Some(Outcome::Win(Side::B)), + "draw" => Some(Outcome::Draw), + _ => None, + }, } } -// The board rendered space-free for the wire: chess is already a comma-FEN; -// ttt/c4 map empty cells ' ' -> '.'. -pub fn encode_board(g: &Game) -> String { - if g.gtype == "chess" { - g.board.clone() +fn full_or_ongoing(cells: &[u8]) -> Option { + if cells.iter().all(|&c| c != EMPTY) { + Some(Outcome::Draw) } else { - g.board.replace(' ', ".") + None } } -// IRCv3 message-tag value escaping. We build the state TAGMSG as a raw line, and -// the serializer only strips CR/LF/NUL — so a space or ';' in the value would -// corrupt the s2s line (it once caused a netsplit under Anope). Escape here. +// IRCv3 message-tag value escaping. The state TAGMSG is built as a raw line and +// the serializer only strips CR/LF/NUL, so a space or ';' in the value would +// corrupt the s2s line (it once netsplit services under Anope). Escape it here. pub fn escape_tag(v: &str) -> String { let mut out = String::with_capacity(v.len()); for c in v.chars() { @@ -233,63 +328,53 @@ pub fn escape_tag(v: &str) -> String { mod tests { use super::*; - fn game(gtype: &str) -> Game { - let (sa, sb) = sides(gtype); - Game { - id: 1, gtype: gtype.into(), acc_a: "a".into(), acc_b: "b".into(), - nick_a: "a".into(), nick_b: "b".into(), a_reg: true, b_reg: true, - side_a: sa.into(), side_b: sb.into(), board: init_board(gtype), - turn: sa.into(), status: "active".into(), result: String::new(), - } + fn game(gt: GameType) -> Game { + Game::new(1, gt, "a".into(), true, "b".into(), true, "a".into(), "b".into()) } #[test] fn ttt_rejects_out_of_turn_and_occupied_and_detects_a_win() { - let mut g = game("ttt"); - // O can't move first (X's turn); an out-of-range/occupied cell is rejected. - assert!(!apply_move(&mut g, "O", "0"), "not O's turn"); - assert!(apply_move(&mut g, "X", "0")); - assert!(!apply_move(&mut g, "X", "0"), "cell taken"); - assert!(!apply_move(&mut g, "O", "9"), "off board"); - // X top row: 0,1,2 with O elsewhere. - apply_move(&mut g, "O", "3"); - apply_move(&mut g, "X", "1"); - apply_move(&mut g, "O", "4"); - assert_eq!(terminal(&g), ""); - assert!(apply_move(&mut g, "X", "2")); - assert_eq!(terminal(&g), "X", "top row wins"); + let mut g = game(GameType::Ttt); + assert!(!apply_move(&mut g, Side::B, "0"), "not B's turn"); + assert!(apply_move(&mut g, Side::A, "0")); + assert!(!apply_move(&mut g, Side::A, "0"), "cell taken"); + assert!(!apply_move(&mut g, Side::B, "9"), "off board"); + apply_move(&mut g, Side::B, "3"); + apply_move(&mut g, Side::A, "1"); + apply_move(&mut g, Side::B, "4"); + assert_eq!(terminal(&g), None); + assert!(apply_move(&mut g, Side::A, "2")); + assert_eq!(terminal(&g), Some(Outcome::Win(Side::A)), "top row wins for A (X)"); } #[test] fn c4_drops_to_bottom_and_detects_vertical_win() { - let mut g = game("c4"); - // R stacks column 3; Y answers in column 4. Four R vertically wins. + let mut g = game(GameType::C4); for _ in 0..3 { - assert!(apply_move(&mut g, "R", "3")); - assert_eq!(terminal(&g), ""); - assert!(apply_move(&mut g, "Y", "4")); + assert!(apply_move(&mut g, Side::A, "3")); + assert_eq!(terminal(&g), None); + assert!(apply_move(&mut g, Side::B, "4")); } - assert!(apply_move(&mut g, "R", "3")); - assert_eq!(terminal(&g), "R", "four in a column wins"); - // The pieces fell to the bottom of column 3 (rows 0..3). - assert_eq!(&g.board[3..4], "R"); + assert!(apply_move(&mut g, Side::A, "3")); + assert_eq!(terminal(&g), Some(Outcome::Win(Side::A)), "four in a column wins"); + assert_eq!(&g.encode()[3..4], "R", "the pieces fell to the bottom of column 3"); } #[test] fn chess_plays_a_legal_move_and_rejects_an_illegal_one() { - let mut g = game("chess"); - assert!(!apply_move(&mut g, "b", "e7e5"), "white to move"); - assert!(apply_move(&mut g, "w", "e2e4")); - assert_eq!(g.turn, "b"); - assert!(!apply_move(&mut g, "b", "e7e9"), "off board / illegal"); - assert!(apply_move(&mut g, "b", "e7e5")); - assert_eq!(terminal(&g), "", "game ongoing"); + let mut g = game(GameType::Chess); + assert!(!apply_move(&mut g, Side::B, "e7e5"), "white (A) to move"); + assert!(apply_move(&mut g, Side::A, "e2e4")); + assert_eq!(g.turn, Side::B); + assert!(!apply_move(&mut g, Side::B, "e7e9"), "off board / illegal"); + assert!(apply_move(&mut g, Side::B, "e7e5")); + assert_eq!(terminal(&g), None, "game ongoing"); } #[test] - fn encode_board_is_space_free() { - let g = game("ttt"); - assert!(!encode_board(&g).contains(' '), "ttt empty cells become dots"); + fn encode_is_space_free_and_tags_escape() { + let g = game(GameType::Ttt); + assert!(!g.encode().contains(' '), "ttt empty cells become dots"); assert_eq!(escape_tag("GS# 1 g=ttt"), "GS#\\s1\\sg=ttt"); } } diff --git a/modules/gameserv/src/chess.rs b/modules/gameserv/src/chess.rs index ccda0aa..e415f53 100644 --- a/modules/gameserv/src/chess.rs +++ b/modules/gameserv/src/chess.rs @@ -569,7 +569,9 @@ pub fn encode(st: &State) -> String { } /// Inverse of [`encode`]. On malformed input (fewer than 4 comma parts) returns -/// [`initial`]. +/// [`initial`]. The referee keeps a live `State`, so this is only exercised by the +/// roundtrip test today — kept as part of the complete, perft-verified engine. +#[allow(dead_code)] pub fn decode(enc: &str) -> State { let mut st = State { board: [EMPTY; 64], diff --git a/modules/gameserv/src/lib.rs b/modules/gameserv/src/lib.rs index 983f9bb..640cdc6 100644 --- a/modules/gameserv/src/lib.rs +++ b/modules/gameserv/src/lib.rs @@ -1,12 +1,12 @@ -//! GameServ — a server-authoritative referee for tic-tac-toe, Connect Four and +//! GamesServ — a server-authoritative referee for tic-tac-toe, Connect Four and //! chess, with a per-game ranked ladder. Every move is validated here and the -//! winner decided by the server, so a client can never cheat (see the hard rule: -//! games stay server-side). Players drive games entirely over `/msg GameServ`; -//! the machine-readable board is pushed to each player on an invisible client-only -//! TAGMSG (`+tchatou.fr/gs`) that only the web client renders — IRC clients see -//! just the human notices. +//! winner decided by the server, so a client can never cheat (games stay +//! server-side). Players drive games over `/msg GamesServ`; the machine-readable +//! board is pushed to each player on an invisible client-only TAGMSG +//! (`+tchatou.fr/gs`) that only the web client renders — IRC clients see just the +//! human notices. The ranked ladder rides StatServ's persisted counters. //! -//! `board.rs` is the referee (rules + state); `chess.rs` is the chess engine. +//! `board.rs` is the referee (typed rules + state); `chess.rs` is the chess engine. use std::collections::HashMap; @@ -17,12 +17,12 @@ mod chess; #[path = "board.rs"] mod board; -use board::{Game, Stats}; +use board::{GameType, Game, Outcome, Side, Status}; // Cap concurrent games per identity so a flood of never-finished challenges // can't grow the map without bound (finished games are dropped immediately). const MAX_GAMES_PER_USER: usize = 5; -const TYPES: [&str; 3] = ["chess", "c4", "ttt"]; +const TYPES: [GameType; 3] = [GameType::Chess, GameType::C4, GameType::Ttt]; const BLURB: &str = "GamesServ referees \x02tic-tac-toe\x02, \x02Connect Four\x02 and \x02chess\x02 — it checks every move and keeps a ranked ladder. Challenge someone, then take turns. Moves: ttt cell \x020-8\x02, c4 column \x020-6\x02, chess \x02UCI\x02 (e2e4)."; @@ -37,21 +37,65 @@ 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)." }, ]; -// 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}") +// One (account, game type) ladder row, materialised from the shared stat counters +// that StatServ persists. Points are DERIVED (win +10, loss -8, floored at 0), so +// the ladder needs only monotonic w/l/d counters — nothing to keep in sync. +#[derive(Default, Clone)] +struct Stats { + wins: u32, + losses: u32, + draws: u32, } -// Materialise a player's record for one game type from a counter snapshot. -fn read_stats(counters: &[(String, u64)], gtype: &str, account: &str) -> Stats { +impl Stats { + fn points(&self) -> i32 { + (10 * self.wins as i32 - 8 * self.losses as i32).max(0) + } + + fn rank(&self) -> &'static str { + let p = self.points(); + if p >= 800 { + "Master" + } else if p >= 400 { + "Gold" + } else if p >= 150 { + "Silver" + } else { + "Bronze" + } + } + + fn any(&self) -> bool { + self.wins > 0 || self.losses > 0 || self.draws > 0 + } +} + +// The ladder lives in the shared counters, keyed `game...`. +fn counter_key(gt: GameType, kind: char, account: &str) -> String { + format!("game.{}.{kind}.{account}", gt.wire()) +} + +fn read_stats(counters: &[(String, u64)], gt: GameType, account: &str) -> Stats { let get = |kind: char| { - let key = counter_key(gtype, kind, account); + let key = counter_key(gt, 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') } } +// A player's post-game record: the counter bumps land after the command, so fold +// this game's delta into the snapshot for the immediate rank push. +fn read_stats_with_delta(counters: &[(String, u64)], gt: GameType, account: &str, side: Side, outcome: Option) -> Stats { + let mut s = read_stats(counters, gt, account); + match outcome { + Some(Outcome::Draw) => s.draws += 1, + Some(Outcome::Win(w)) if w == side => s.wins += 1, + Some(Outcome::Win(_)) => s.losses += 1, + None => {} + } + s +} + pub struct GameServ { pub uid: String, games: HashMap, @@ -69,16 +113,26 @@ impl GameServ { from.account.unwrap_or(from.nick) } + // The side an account plays in a game (A = challenger), or None if not a party. + fn side_of(g: &Game, account: &str) -> Option { + if g.acc_a == account { + Some(Side::A) + } else if g.acc_b == account { + Some(Side::B) + } else { + None + } + } + 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 "); return; }; - let gtype = game.to_lowercase(); - if !board::valid_type(>ype) { + let Some(gt) = GameType::from_arg(game) else { ctx.notice(me, from.uid, "Unknown game. Available: \x02ttt\x02 (tic-tac-toe), \x02c4\x02 (Connect Four), \x02chess\x02."); return; - } + }; let Some(tuid) = net.uid_by_nick(target).map(str::to_string) else { ctx.notice(me, from.uid, format!("\x02{target}\x02 is not online.")); return; @@ -94,48 +148,41 @@ impl GameServ { return; } let target_acc = net.account_of(&tuid).map(str::to_string); - let (sa, sb) = board::sides(>ype); let id = self.nextid; self.nextid += 1; - let g = Game { + let g = Game::new( id, - gtype: gtype.clone(), - acc_a: meid, - a_reg: from.account.is_some(), - acc_b: target_acc.clone().unwrap_or_else(|| target.to_string()), - b_reg: target_acc.is_some(), - nick_a: from.nick.to_string(), - nick_b: target.to_string(), - side_a: sa.to_string(), - side_b: sb.to_string(), - board: board::init_board(>ype), - turn: sa.to_string(), - status: "pending".to_string(), - result: String::new(), - }; + gt, + meid, + from.account.is_some(), + target_acc.clone().unwrap_or_else(|| target.to_string()), + target_acc.is_some(), + from.nick.to_string(), + target.to_string(), + ); self.games.insert(id, g); - ctx.notice(me, from.uid, format!("Challenge \x02#{id}\x02 ({gtype}) sent to \x02{target}\x02.")); - ctx.notice(me, &tuid, format!("{} challenges you to {gtype} (game #{id}). \x02/msg GamesServ ACCEPT {id}\x02", from.nick)); + ctx.notice(me, from.uid, format!("Challenge \x02#{id}\x02 ({}) sent to \x02{target}\x02.", gt.wire())); + ctx.notice(me, &tuid, format!("{} challenges you to {} (game #{id}). \x02/msg GamesServ ACCEPT {id}\x02", from.nick, gt.wire())); let g = &self.games[&id]; - push_one(g, 0, "pending", me, net, ctx); // challenger: waiting - push_one(g, 1, "offer", me, net, ctx); // target: can accept/decline + push_one(g, Side::A, "pending", me, net, ctx); // challenger: waiting + push_one(g, Side::B, "offer", me, net, ctx); // target: can accept/decline } fn do_accept(&mut self, me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) { let meid = Self::ident(from).to_string(); let id = match args.get(1) { - Some(&s) => s.parse::().ok().filter(|id| self.games.get(id).is_some_and(|g| g.status == "pending" && g.acc_b == meid)), - None => self.games.iter().find(|(_, g)| g.status == "pending" && g.acc_b == meid).map(|(id, _)| *id), + Some(&s) => s.parse::().ok().filter(|id| self.games.get(id).is_some_and(|g| g.status == Status::Pending && g.acc_b == meid)), + None => self.games.iter().find(|(_, g)| g.status == Status::Pending && g.acc_b == meid).map(|(id, _)| *id), }; let Some(id) = id else { ctx.notice(me, from.uid, "No pending challenge to accept."); return; }; let g = self.games.get_mut(&id).unwrap(); - g.status = "active".to_string(); + g.status = Status::Active; g.nick_b = from.nick.to_string(); - let side = g.side_b.clone(); - ctx.notice(me, from.uid, format!("Game \x02#{id}\x02 started. You are \x02{side}\x02.")); + let glyph = g.glyph(Side::B); + ctx.notice(me, from.uid, format!("Game \x02#{id}\x02 started. You are \x02{glyph}\x02.")); push_state(&self.games[&id], me, net, ctx); } @@ -143,7 +190,7 @@ impl GameServ { let meid = Self::ident(from).to_string(); let want: Option = args.get(1).and_then(|s| s.parse().ok()); let id = self.games.iter().find(|(id, g)| { - g.status == "pending" && g.acc_b == meid && want.is_none_or(|w| w == **id) + g.status == Status::Pending && g.acc_b == meid && want.is_none_or(|w| w == **id) }).map(|(id, _)| *id); let Some(id) = id else { ctx.notice(me, from.uid, "No pending challenge to decline."); @@ -169,26 +216,24 @@ impl GameServ { ctx.notice(me, from.uid, "No active game with that id."); return; }; - if g.status != "active" || (g.acc_a != meid && g.acc_b != meid) { + let side = Self::side_of(g, &meid); + let (Some(side), true) = (side, g.status == Status::Active) else { ctx.notice(me, from.uid, "No active game with that id."); return; + }; + match side { + Side::A => g.nick_a = from.nick.to_string(), + Side::B => g.nick_b = from.nick.to_string(), } - let side = if g.acc_a == meid { g.side_a.clone() } else { g.side_b.clone() }; - if g.acc_a == meid { - g.nick_a = from.nick.to_string(); - } else { - g.nick_b = from.nick.to_string(); - } - if !board::apply_move(g, &side, mv) { + if !board::apply_move(g, side, mv) { ctx.notice(me, from.uid, "Illegal move (or not your turn)."); return; } board::terminal(g) }; - if term.is_empty() { - push_state(&self.games[&id], me, net, ctx); - } else { - self.finish(id, &term, me, net, ctx); + match term { + Some(outcome) => self.finish(id, outcome, me, net, ctx), + None => push_state(&self.games[&id], me, net, ctx), } } @@ -199,71 +244,59 @@ impl GameServ { }; let id: u64 = ids.parse().unwrap_or(0); let meid = Self::ident(from).to_string(); - let winner_side = match self.games.get(&id) { - Some(g) if g.status == "active" && (g.acc_a == meid || g.acc_b == meid) => { - if g.acc_a == meid { g.side_b.clone() } else { g.side_a.clone() } - } + let winner = match self.games.get(&id) { + Some(g) if g.status == Status::Active => match Self::side_of(g, &meid) { + Some(side) => Outcome::Win(side.other()), + None => { + ctx.notice(me, from.uid, "No active game with that id."); + return; + } + }, _ => { ctx.notice(me, from.uid, "No active game with that id."); return; } }; ctx.notice(me, from.uid, format!("You resigned game #{id}.")); - self.finish(id, &winner_side, me, net, ctx); + self.finish(id, winner, me, net, ctx); } // End a game: record the result, update the ladder (only when BOTH players are - // registered — casual guest games leave the ladder untouched), push the final - // state + refreshed stats, and drop the game. - fn finish(&mut self, id: u64, winner_side: &str, me: &str, net: &dyn NetView, ctx: &mut ServiceCtx) { + // registered), push the final state + refreshed stats, and drop the game. + fn finish(&mut self, id: u64, outcome: Outcome, me: &str, net: &dyn NetView, ctx: &mut ServiceCtx) { let Some(mut g) = self.games.remove(&id) else { return }; - g.status = "over".to_string(); - let a_won = winner_side != "draw" && winner_side == g.side_a; - g.result = if winner_side == "draw" { - "draw".to_string() - } else if a_won { - g.acc_a.clone() - } else { - g.acc_b.clone() - }; - // 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. + g.status = Status::Over; + g.result = Some(outcome); + let gt = g.gtype(); let ranked = g.a_reg && g.b_reg; if ranked { - if winner_side == "draw" { - 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, &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; + match outcome { + Outcome::Draw => { + ctx.count(counter_key(gt, 'd', &g.acc_a)); + ctx.count(counter_key(gt, 'd', &g.acc_b)); + } + Outcome::Win(w) => { + let (wacc, lacc) = match w { + Side::A => (&g.acc_a, &g.acc_b), + Side::B => (&g.acc_b, &g.acc_a), + }; + ctx.count(counter_key(gt, 'w', wacc)); + ctx.count(counter_key(gt, 'l', lacc)); } } - s - }; + } + // The counter bumps land AFTER this command, so fold this game's delta into + // the pushed rank (only when ranked; casual games leave the ladder be). + let counters = net.stat_counters(); + let delta = ranked.then_some(outcome); push_state(&g, me, net, ctx); if g.a_reg { - let s = with_delta(&g.acc_a, true); - push_stats(&counters, &g.acc_a, &g.nick_a, me, net, ctx, Some((&g.gtype, &s))); + let s = read_stats_with_delta(&counters, gt, &g.acc_a, Side::A, delta); + push_stats(&counters, &g.acc_a, &g.nick_a, me, net, ctx, Some((gt, &s))); } if g.b_reg { - let s = with_delta(&g.acc_b, false); - push_stats(&counters, &g.acc_b, &g.nick_b, me, net, ctx, Some((&g.gtype, &s))); + let s = read_stats_with_delta(&counters, gt, &g.acc_b, Side::B, delta); + push_stats(&counters, &g.acc_b, &g.nick_b, me, net, ctx, Some((gt, &s))); } } @@ -278,7 +311,7 @@ impl GameServ { for id in ids { let g = &self.games[&id]; let opp = if g.acc_a == meid { &g.nick_b } else { &g.nick_a }; - ctx.notice(me, from.uid, format!("#{} {} vs {} [{}]", g.id, g.gtype, opp, g.status)); + ctx.notice(me, from.uid, format!("#{} {} vs {} [{}]", g.id, g.gtype().wire(), opp, g.status.wire())); } } @@ -295,13 +328,13 @@ impl GameServ { 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; - for ty in TYPES { - let s = read_stats(&counters, ty, &who); + for gt in TYPES { + let s = read_stats(&counters, gt, &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 + gt.label(), s.rank(), s.points(), s.wins, s.losses, s.draws )); } } @@ -311,14 +344,19 @@ impl GameServ { } fn do_top(&self, me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) { - let ty = args.get(1).map(|s| s.to_lowercase()).unwrap_or_else(|| "chess".to_string()); - if !board::valid_type(&ty) { - ctx.notice(me, from.uid, "Unknown game. Use \x02TOP ttt|c4|chess\x02."); - return; - } + let gt = match args.get(1) { + Some(&t) => match GameType::from_arg(t) { + Some(gt) => gt, + None => { + ctx.notice(me, from.uid, "Unknown game. Use \x02TOP ttt|c4|chess\x02."); + return; + } + }, + None => GameType::Chess, + }; // Aggregate the per-account w/l/d counters for this game type. let counters = net.stat_counters(); - let prefix = format!("game.{ty}."); + let prefix = format!("game.{}.", gt.wire()); let mut map: HashMap = HashMap::new(); for (k, v) in &counters { let Some(rest) = k.strip_prefix(&prefix) else { continue }; @@ -333,16 +371,16 @@ impl GameServ { } 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 {}", gt.wire()), ctx); 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 !", gt.label())); return; } - ctx.notice(me, from.uid, format!("\x02Classement {} :\x02", board::type_label(&ty))); + ctx.notice(me, from.uid, format!("\x02Classement {} :\x02", gt.label())); 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); + push_tag(me, net, from.nick, &format!("GS$TOPROW {} {rank} {acc} {} {}", gt.wire(), s.rank(), s.points()), ctx); } } } @@ -356,42 +394,41 @@ fn push_tag(me: &str, net: &dyn NetView, nick: &str, payload: &str, ctx: &mut Se } } -// The GS# state line for one side (0 = A, 1 = B). `status_override` lets a -// challenge show as "pending" to the challenger and "offer" to the target. -fn push_one(g: &Game, s: usize, status_override: &str, me: &str, net: &dyn NetView, ctx: &mut ServiceCtx) { - let (nick, side, oppnick, myacc) = if s == 0 { - (&g.nick_a, &g.side_a, &g.nick_b, &g.acc_a) - } else { - (&g.nick_b, &g.side_b, &g.nick_a, &g.acc_b) +// The GS# state line for one side. `status_override` lets a challenge show as +// "pending" to the challenger and "offer" to the target. +fn push_one(g: &Game, viewer: Side, status_override: &str, me: &str, net: &dyn NetView, ctx: &mut ServiceCtx) { + let (nick, opp) = match viewer { + Side::A => (&g.nick_a, &g.nick_b), + Side::B => (&g.nick_b, &g.nick_a), }; - let status = if status_override.is_empty() { g.status.as_str() } else { status_override }; - let res = if g.status == "over" { - if g.result == "draw" { "draw" } else if &g.result == myacc { "win" } else { "loss" } - } else { - "none" + let status = if status_override.is_empty() { g.status.wire() } else { status_override }; + let res = match (g.status, g.result) { + (Status::Over, Some(Outcome::Draw)) => "draw", + (Status::Over, Some(Outcome::Win(w))) => if w == viewer { "win" } else { "loss" }, + _ => "none", }; let payload = format!( "GS# {} g={} s={} t={} me={} opp={} st={} r={}", - g.id, g.gtype, board::encode_board(g), g.turn, side, oppnick, status, res + g.id, g.gtype().wire(), g.encode(), g.glyph(g.turn), g.glyph(viewer), opp, status, res ); push_tag(me, net, nick, &payload, ctx); } fn push_state(g: &Game, me: &str, net: &dyn NetView, ctx: &mut ServiceCtx) { - push_one(g, 0, "", me, net, ctx); - push_one(g, 1, "", me, net, ctx); + push_one(g, Side::A, "", me, net, ctx); + push_one(g, Side::B, "", me, net, ctx); } // 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 { +// read from the counter snapshot. `override_ty` shows a just-finished game's +// 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<(GameType, &Stats)>) { + for gt in TYPES { let s = match override_ty { - Some((oty, os)) if oty == ty => os.clone(), - _ => read_stats(counters, ty, account), + Some((oty, os)) if oty == gt => os.clone(), + _ => read_stats(counters, gt, account), }; - let payload = format!("GS$ {} {} r={} p={} w={} l={} d={}", account, ty, s.rank(), s.points(), s.wins, s.losses, s.draws); + let payload = format!("GS$ {} {} r={} p={} w={} l={} d={}", account, gt.wire(), s.rank(), s.points(), s.wins, s.losses, s.draws); push_tag(me, net, to_nick, &payload, ctx); } } @@ -442,19 +479,30 @@ mod tests { ("game.chess.l.Reverse".to_string(), 2u64), ("game.chess.d.Reverse".to_string(), 1u64), ]; - let s = read_stats(&counters, "chess", "Reverse"); + let s = read_stats(&counters, GameType::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()); + assert!(!read_stats(&counters, GameType::C4, "Reverse").any()); // Losses alone floor points at 0. - let loser = read_stats(&[("game.ttt.l.Sad".to_string(), 9)], "ttt", "Sad"); + let loser = read_stats(&[("game.ttt.l.Sad".to_string(), 9)], GameType::Ttt, "Sad"); assert_eq!(loser.points(), 0); - assert_eq!(counter_key("c4", 'w', "Bob"), "game.c4.w.Bob"); + assert_eq!(counter_key(GameType::C4, 'w', "Bob"), "game.c4.w.Bob"); + } + + // A win folds its delta into the immediate rank push (counter bump is deferred). + #[test] + fn delta_reflects_the_just_finished_game() { + let counters = vec![("game.ttt.w.Win".to_string(), 3u64)]; + let winner = read_stats_with_delta(&counters, GameType::Ttt, "Win", Side::A, Some(Outcome::Win(Side::A))); + assert_eq!(winner.wins, 4); + let loser = read_stats_with_delta(&counters, GameType::Ttt, "Lose", Side::B, Some(Outcome::Win(Side::A))); + assert_eq!(loser.losses, 1); + // Casual (unranked) game: no delta. + assert_eq!(read_stats_with_delta(&counters, GameType::Ttt, "Win", Side::A, None).wins, 3); } }