gameserv: model the domain with enums (GameType/Side/Status/Outcome/Board), zero-alloc grid moves
All checks were successful
CI / check (push) Successful in 3m54s
All checks were successful
CI / check (push) Successful in 3m54s
This commit is contained in:
parent
108c15e95a
commit
cfe05481d6
3 changed files with 472 additions and 337 deletions
|
|
@ -1,219 +1,314 @@
|
||||||
//! The referee: game state and the per-game rules for tic-tac-toe, Connect Four
|
//! The referee: strongly-typed game state and rules for tic-tac-toe, Connect Four
|
||||||
//! and chess. GameServ validates every move here and decides the winner, so a
|
//! and chess. Where the C++ original modelled everything as strings ("ttt", "X",
|
||||||
//! client can never cheat (the board never leaves the server except as a signed
|
//! "pending", a 9-char board), this uses enums so illegal states are
|
||||||
//! state line the web client only renders). Chess lives in `chess.rs`.
|
//! 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;
|
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] = [
|
const TTT_LINES: [[usize; 3]; 8] = [
|
||||||
[0, 1, 2], [3, 4, 5], [6, 7, 8],
|
[0, 1, 2], [3, 4, 5], [6, 7, 8],
|
||||||
[0, 3, 6], [1, 4, 7], [2, 5, 8],
|
[0, 3, 6], [1, 4, 7], [2, 5, 8],
|
||||||
[0, 4, 8], [2, 4, 6],
|
[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
|
/// Which game. `Copy`, so it flows without clones or string compares.
|
||||||
// guest); `side_*` is the piece/colour each side plays. A = challenger, moves first.
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum GameType {
|
||||||
|
Ttt,
|
||||||
|
C4,
|
||||||
|
Chess,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameType {
|
||||||
|
pub fn from_arg(s: &str) -> Option<Self> {
|
||||||
|
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 struct Game {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub gtype: String, // "ttt" | "c4" | "chess"
|
|
||||||
pub acc_a: String,
|
pub acc_a: String,
|
||||||
pub acc_b: String,
|
pub acc_b: String,
|
||||||
pub nick_a: String,
|
pub nick_a: String,
|
||||||
pub nick_b: String,
|
pub nick_b: String,
|
||||||
pub a_reg: bool,
|
pub a_reg: bool,
|
||||||
pub b_reg: bool,
|
pub b_reg: bool,
|
||||||
pub side_a: String,
|
pub board: Board,
|
||||||
pub side_b: String,
|
pub turn: Side,
|
||||||
pub board: String,
|
pub status: Status,
|
||||||
pub turn: String, // side to move
|
pub result: Option<Outcome>,
|
||||||
pub status: String, // "pending" | "active" | "over"
|
|
||||||
pub result: String, // "" | "<winner account>" | "draw"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One (account, game type) ladder row, materialised from the shared stat counters
|
impl Game {
|
||||||
// (`game.<type>.<w|l|d>.<account>`) that StatServ already persists. A player can be
|
#[allow(clippy::too_many_arguments)]
|
||||||
// Gold at chess and Bronze at Connect Four, so each type keeps its own record.
|
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 {
|
||||||
#[derive(Default, Clone)]
|
Self {
|
||||||
pub struct Stats {
|
id,
|
||||||
pub wins: u32,
|
acc_a,
|
||||||
pub losses: u32,
|
acc_b,
|
||||||
pub draws: u32,
|
nick_a,
|
||||||
}
|
nick_b,
|
||||||
|
a_reg,
|
||||||
impl Stats {
|
b_reg,
|
||||||
// Points are DERIVED from the win/loss counts (draws don't score), floored at
|
board: Board::new(gt),
|
||||||
// 0: win +10, loss -8. Deriving keeps the ladder to monotonic w/l/d counters,
|
turn: Side::A,
|
||||||
// so it rides StatServ's existing counter persistence with nothing to sync.
|
status: Status::Pending,
|
||||||
pub fn points(&self) -> i32 {
|
result: None,
|
||||||
(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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn any(&self) -> bool {
|
pub fn gtype(&self) -> GameType {
|
||||||
self.wins > 0 || self.losses > 0 || self.draws > 0
|
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 {
|
/// Apply `mv` for `side`; returns true iff it was legal and applied (mutating the
|
||||||
matches!(t, "ttt" | "c4" | "chess")
|
/// board and advancing the turn). Rejects out-of-turn and illegal moves.
|
||||||
}
|
pub fn apply_move(g: &mut Game, side: Side, mv: &str) -> bool {
|
||||||
|
|
||||||
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 {
|
|
||||||
if g.turn != side {
|
if g.turn != side {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let other = if side == g.side_a { g.side_b.clone() } else { g.side_a.clone() };
|
let glyph = g.gtype().glyph(side);
|
||||||
match g.gtype.as_str() {
|
match &mut g.board {
|
||||||
"ttt" => {
|
Board::Ttt(cells) => {
|
||||||
let c: i32 = mv.parse().unwrap_or(-1);
|
let Ok(c) = mv.parse::<usize>() else { return false };
|
||||||
let mut b = g.board.clone().into_bytes();
|
if c >= cells.len() || cells[c] != EMPTY {
|
||||||
if !(0..=8).contains(&c) || b[c as usize] != b' ' {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
b[c as usize] = side.as_bytes()[0];
|
cells[c] = glyph;
|
||||||
g.board = String::from_utf8(b).unwrap_or_default();
|
|
||||||
g.turn = other;
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
"c4" => {
|
Board::C4(cells) => {
|
||||||
let col: i32 = mv.parse().unwrap_or(-1);
|
let Ok(col) = mv.parse::<usize>() else { return false };
|
||||||
if col < 0 || col as usize >= C4_W {
|
if col >= C4_W {
|
||||||
return false;
|
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.
|
// row 0 = bottom; the piece drops to the lowest empty cell.
|
||||||
let i = r * C4_W + col;
|
match (0..C4_H).map(|r| r * C4_W + col).find(|&i| cells[i] == EMPTY) {
|
||||||
if b[i] == b' ' {
|
Some(i) => cells[i] = glyph,
|
||||||
b[i] = side.as_bytes()[0];
|
None => return false, // column full
|
||||||
g.board = String::from_utf8(b).unwrap_or_default();
|
}
|
||||||
g.turn = other;
|
}
|
||||||
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false // column full
|
g.turn = side.other();
|
||||||
}
|
|
||||||
"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
|
true
|
||||||
}
|
|
||||||
None => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// "" = ongoing, "draw", or the winning side string.
|
/// `None` while the game is ongoing, else how it ended.
|
||||||
pub fn terminal(g: &Game) -> String {
|
pub fn terminal(g: &Game) -> Option<Outcome> {
|
||||||
match g.gtype.as_str() {
|
match &g.board {
|
||||||
"ttt" => {
|
Board::Ttt(b) => {
|
||||||
let b = g.board.as_bytes();
|
|
||||||
for ln in TTT_LINES {
|
for ln in TTT_LINES {
|
||||||
let a = b[ln[0]];
|
let a = b[ln[0]];
|
||||||
if a != b' ' && a == b[ln[1]] && a == b[ln[2]] {
|
if a != EMPTY && a == b[ln[1]] && a == b[ln[2]] {
|
||||||
return (a as char).to_string();
|
return Some(Outcome::Win(GameType::Ttt.side_of_glyph(a)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if g.board.contains(' ') { String::new() } else { "draw".into() }
|
full_or_ongoing(b)
|
||||||
}
|
}
|
||||||
"c4" => {
|
Board::C4(b) => {
|
||||||
let b = g.board.as_bytes();
|
|
||||||
let dirs: [(i32, i32); 4] = [(1, 0), (0, 1), (1, 1), (1, -1)];
|
let dirs: [(i32, i32); 4] = [(1, 0), (0, 1), (1, 1), (1, -1)];
|
||||||
for c in 0..C4_W as i32 {
|
for c in 0..C4_W as i32 {
|
||||||
for r in 0..C4_H as i32 {
|
for r in 0..C4_H as i32 {
|
||||||
let p = b[r as usize * C4_W + c as usize];
|
let p = b[r as usize * C4_W + c as usize];
|
||||||
if p == b' ' {
|
if p == EMPTY {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (dc, dr) in dirs {
|
for (dc, dr) in dirs {
|
||||||
let (mut cc, mut rr) = (c, r);
|
let (mut cc, mut rr) = (c, r);
|
||||||
let mut ok = true;
|
let run = (0..4).all(|_| {
|
||||||
for _ in 0..4 {
|
let ok = (0..C4_W as i32).contains(&cc)
|
||||||
if cc < 0 || cc >= C4_W as i32 || rr < 0 || rr >= C4_H as i32
|
&& (0..C4_H as i32).contains(&rr)
|
||||||
|| b[rr as usize * C4_W + cc as usize] != p
|
&& b[rr as usize * C4_W + cc as usize] == p;
|
||||||
{
|
|
||||||
ok = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
cc += dc;
|
cc += dc;
|
||||||
rr += dr;
|
rr += dr;
|
||||||
}
|
ok
|
||||||
if ok {
|
});
|
||||||
return (p as char).to_string();
|
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)),
|
Board::Chess(st) => match chess::over(st).as_str() {
|
||||||
_ => String::new(),
|
"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;
|
fn full_or_ongoing(cells: &[u8]) -> Option<Outcome> {
|
||||||
// ttt/c4 map empty cells ' ' -> '.'.
|
if cells.iter().all(|&c| c != EMPTY) {
|
||||||
pub fn encode_board(g: &Game) -> String {
|
Some(Outcome::Draw)
|
||||||
if g.gtype == "chess" {
|
|
||||||
g.board.clone()
|
|
||||||
} else {
|
} else {
|
||||||
g.board.replace(' ', ".")
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IRCv3 message-tag value escaping. We build the state TAGMSG as a raw line, and
|
// 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
|
// 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.
|
// corrupt the s2s line (it once netsplit services under Anope). Escape it here.
|
||||||
pub fn escape_tag(v: &str) -> String {
|
pub fn escape_tag(v: &str) -> String {
|
||||||
let mut out = String::with_capacity(v.len());
|
let mut out = String::with_capacity(v.len());
|
||||||
for c in v.chars() {
|
for c in v.chars() {
|
||||||
|
|
@ -233,63 +328,53 @@ pub fn escape_tag(v: &str) -> String {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn game(gtype: &str) -> Game {
|
fn game(gt: GameType) -> Game {
|
||||||
let (sa, sb) = sides(gtype);
|
Game::new(1, gt, "a".into(), true, "b".into(), true, "a".into(), "b".into())
|
||||||
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(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ttt_rejects_out_of_turn_and_occupied_and_detects_a_win() {
|
fn ttt_rejects_out_of_turn_and_occupied_and_detects_a_win() {
|
||||||
let mut g = game("ttt");
|
let mut g = game(GameType::Ttt);
|
||||||
// O can't move first (X's turn); an out-of-range/occupied cell is rejected.
|
assert!(!apply_move(&mut g, Side::B, "0"), "not B's turn");
|
||||||
assert!(!apply_move(&mut g, "O", "0"), "not O's turn");
|
assert!(apply_move(&mut g, Side::A, "0"));
|
||||||
assert!(apply_move(&mut g, "X", "0"));
|
assert!(!apply_move(&mut g, Side::A, "0"), "cell taken");
|
||||||
assert!(!apply_move(&mut g, "X", "0"), "cell taken");
|
assert!(!apply_move(&mut g, Side::B, "9"), "off board");
|
||||||
assert!(!apply_move(&mut g, "O", "9"), "off board");
|
apply_move(&mut g, Side::B, "3");
|
||||||
// X top row: 0,1,2 with O elsewhere.
|
apply_move(&mut g, Side::A, "1");
|
||||||
apply_move(&mut g, "O", "3");
|
apply_move(&mut g, Side::B, "4");
|
||||||
apply_move(&mut g, "X", "1");
|
assert_eq!(terminal(&g), None);
|
||||||
apply_move(&mut g, "O", "4");
|
assert!(apply_move(&mut g, Side::A, "2"));
|
||||||
assert_eq!(terminal(&g), "");
|
assert_eq!(terminal(&g), Some(Outcome::Win(Side::A)), "top row wins for A (X)");
|
||||||
assert!(apply_move(&mut g, "X", "2"));
|
|
||||||
assert_eq!(terminal(&g), "X", "top row wins");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn c4_drops_to_bottom_and_detects_vertical_win() {
|
fn c4_drops_to_bottom_and_detects_vertical_win() {
|
||||||
let mut g = game("c4");
|
let mut g = game(GameType::C4);
|
||||||
// R stacks column 3; Y answers in column 4. Four R vertically wins.
|
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
assert!(apply_move(&mut g, "R", "3"));
|
assert!(apply_move(&mut g, Side::A, "3"));
|
||||||
assert_eq!(terminal(&g), "");
|
assert_eq!(terminal(&g), None);
|
||||||
assert!(apply_move(&mut g, "Y", "4"));
|
assert!(apply_move(&mut g, Side::B, "4"));
|
||||||
}
|
}
|
||||||
assert!(apply_move(&mut g, "R", "3"));
|
assert!(apply_move(&mut g, Side::A, "3"));
|
||||||
assert_eq!(terminal(&g), "R", "four in a column wins");
|
assert_eq!(terminal(&g), Some(Outcome::Win(Side::A)), "four in a column wins");
|
||||||
// The pieces fell to the bottom of column 3 (rows 0..3).
|
assert_eq!(&g.encode()[3..4], "R", "the pieces fell to the bottom of column 3");
|
||||||
assert_eq!(&g.board[3..4], "R");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chess_plays_a_legal_move_and_rejects_an_illegal_one() {
|
fn chess_plays_a_legal_move_and_rejects_an_illegal_one() {
|
||||||
let mut g = game("chess");
|
let mut g = game(GameType::Chess);
|
||||||
assert!(!apply_move(&mut g, "b", "e7e5"), "white to move");
|
assert!(!apply_move(&mut g, Side::B, "e7e5"), "white (A) to move");
|
||||||
assert!(apply_move(&mut g, "w", "e2e4"));
|
assert!(apply_move(&mut g, Side::A, "e2e4"));
|
||||||
assert_eq!(g.turn, "b");
|
assert_eq!(g.turn, Side::B);
|
||||||
assert!(!apply_move(&mut g, "b", "e7e9"), "off board / illegal");
|
assert!(!apply_move(&mut g, Side::B, "e7e9"), "off board / illegal");
|
||||||
assert!(apply_move(&mut g, "b", "e7e5"));
|
assert!(apply_move(&mut g, Side::B, "e7e5"));
|
||||||
assert_eq!(terminal(&g), "", "game ongoing");
|
assert_eq!(terminal(&g), None, "game ongoing");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn encode_board_is_space_free() {
|
fn encode_is_space_free_and_tags_escape() {
|
||||||
let g = game("ttt");
|
let g = game(GameType::Ttt);
|
||||||
assert!(!encode_board(&g).contains(' '), "ttt empty cells become dots");
|
assert!(!g.encode().contains(' '), "ttt empty cells become dots");
|
||||||
assert_eq!(escape_tag("GS# 1 g=ttt"), "GS#\\s1\\sg=ttt");
|
assert_eq!(escape_tag("GS# 1 g=ttt"), "GS#\\s1\\sg=ttt");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -569,7 +569,9 @@ pub fn encode(st: &State) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inverse of [`encode`]. On malformed input (fewer than 4 comma parts) returns
|
/// 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 {
|
pub fn decode(enc: &str) -> State {
|
||||||
let mut st = State {
|
let mut st = State {
|
||||||
board: [EMPTY; 64],
|
board: [EMPTY; 64],
|
||||||
|
|
|
||||||
|
|
@ -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
|
//! 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:
|
//! winner decided by the server, so a client can never cheat (games stay
|
||||||
//! games stay server-side). Players drive games entirely over `/msg GameServ`;
|
//! server-side). Players drive games over `/msg GamesServ`; the machine-readable
|
||||||
//! the machine-readable board is pushed to each player on an invisible client-only
|
//! board is pushed to each player on an invisible client-only TAGMSG
|
||||||
//! TAGMSG (`+tchatou.fr/gs`) that only the web client renders — IRC clients see
|
//! (`+tchatou.fr/gs`) that only the web client renders — IRC clients see just the
|
||||||
//! just the human notices.
|
//! 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;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
|
@ -17,12 +17,12 @@ mod chess;
|
||||||
#[path = "board.rs"]
|
#[path = "board.rs"]
|
||||||
mod board;
|
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
|
// Cap concurrent games per identity so a flood of never-finished challenges
|
||||||
// can't grow the map without bound (finished games are dropped immediately).
|
// can't grow the map without bound (finished games are dropped immediately).
|
||||||
const MAX_GAMES_PER_USER: usize = 5;
|
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).";
|
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)." },
|
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
|
// One (account, game type) ladder row, materialised from the shared stat counters
|
||||||
// `game.<type>.<w|l|d>.<account>`. Nothing is stored in GameServ itself.
|
// that StatServ persists. Points are DERIVED (win +10, loss -8, floored at 0), so
|
||||||
fn counter_key(gtype: &str, kind: char, account: &str) -> String {
|
// the ladder needs only monotonic w/l/d counters — nothing to keep in sync.
|
||||||
format!("game.{gtype}.{kind}.{account}")
|
#[derive(Default, Clone)]
|
||||||
|
struct Stats {
|
||||||
|
wins: u32,
|
||||||
|
losses: u32,
|
||||||
|
draws: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Materialise a player's record for one game type from a counter snapshot.
|
impl Stats {
|
||||||
fn read_stats(counters: &[(String, u64)], gtype: &str, account: &str) -> 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.<type>.<w|l|d>.<account>`.
|
||||||
|
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 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)
|
counters.iter().find(|(k, _)| *k == key).map(|(_, v)| *v as u32).unwrap_or(0)
|
||||||
};
|
};
|
||||||
Stats { wins: get('w'), losses: get('l'), draws: get('d') }
|
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<Outcome>) -> 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 struct GameServ {
|
||||||
pub uid: String,
|
pub uid: String,
|
||||||
games: HashMap<u64, Game>,
|
games: HashMap<u64, Game>,
|
||||||
|
|
@ -69,16 +113,26 @@ impl GameServ {
|
||||||
from.account.unwrap_or(from.nick)
|
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<Side> {
|
||||||
|
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) {
|
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>");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let gtype = game.to_lowercase();
|
let Some(gt) = GameType::from_arg(game) else {
|
||||||
if !board::valid_type(>ype) {
|
|
||||||
ctx.notice(me, from.uid, "Unknown game. Available: \x02ttt\x02 (tic-tac-toe), \x02c4\x02 (Connect Four), \x02chess\x02.");
|
ctx.notice(me, from.uid, "Unknown game. Available: \x02ttt\x02 (tic-tac-toe), \x02c4\x02 (Connect Four), \x02chess\x02.");
|
||||||
return;
|
return;
|
||||||
}
|
};
|
||||||
let Some(tuid) = net.uid_by_nick(target).map(str::to_string) else {
|
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."));
|
ctx.notice(me, from.uid, format!("\x02{target}\x02 is not online."));
|
||||||
return;
|
return;
|
||||||
|
|
@ -94,48 +148,41 @@ impl GameServ {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let target_acc = net.account_of(&tuid).map(str::to_string);
|
let target_acc = net.account_of(&tuid).map(str::to_string);
|
||||||
let (sa, sb) = board::sides(>ype);
|
|
||||||
let id = self.nextid;
|
let id = self.nextid;
|
||||||
self.nextid += 1;
|
self.nextid += 1;
|
||||||
let g = Game {
|
let g = Game::new(
|
||||||
id,
|
id,
|
||||||
gtype: gtype.clone(),
|
gt,
|
||||||
acc_a: meid,
|
meid,
|
||||||
a_reg: from.account.is_some(),
|
from.account.is_some(),
|
||||||
acc_b: target_acc.clone().unwrap_or_else(|| target.to_string()),
|
target_acc.clone().unwrap_or_else(|| target.to_string()),
|
||||||
b_reg: target_acc.is_some(),
|
target_acc.is_some(),
|
||||||
nick_a: from.nick.to_string(),
|
from.nick.to_string(),
|
||||||
nick_b: target.to_string(),
|
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(),
|
|
||||||
};
|
|
||||||
self.games.insert(id, g);
|
self.games.insert(id, g);
|
||||||
ctx.notice(me, from.uid, format!("Challenge \x02#{id}\x02 ({gtype}) sent to \x02{target}\x02."));
|
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 {gtype} (game #{id}). \x02/msg GamesServ ACCEPT {id}\x02", from.nick));
|
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];
|
let g = &self.games[&id];
|
||||||
push_one(g, 0, "pending", me, net, ctx); // challenger: waiting
|
push_one(g, Side::A, "pending", me, net, ctx); // challenger: waiting
|
||||||
push_one(g, 1, "offer", me, net, ctx); // target: can accept/decline
|
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) {
|
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 meid = Self::ident(from).to_string();
|
||||||
let id = match args.get(1) {
|
let id = match args.get(1) {
|
||||||
Some(&s) => s.parse::<u64>().ok().filter(|id| self.games.get(id).is_some_and(|g| g.status == "pending" && g.acc_b == meid)),
|
Some(&s) => s.parse::<u64>().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 == "pending" && g.acc_b == meid).map(|(id, _)| *id),
|
None => self.games.iter().find(|(_, g)| g.status == Status::Pending && g.acc_b == meid).map(|(id, _)| *id),
|
||||||
};
|
};
|
||||||
let Some(id) = id else {
|
let Some(id) = id else {
|
||||||
ctx.notice(me, from.uid, "No pending challenge to accept.");
|
ctx.notice(me, from.uid, "No pending challenge to accept.");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let g = self.games.get_mut(&id).unwrap();
|
let g = self.games.get_mut(&id).unwrap();
|
||||||
g.status = "active".to_string();
|
g.status = Status::Active;
|
||||||
g.nick_b = from.nick.to_string();
|
g.nick_b = from.nick.to_string();
|
||||||
let side = g.side_b.clone();
|
let glyph = g.glyph(Side::B);
|
||||||
ctx.notice(me, from.uid, format!("Game \x02#{id}\x02 started. You are \x02{side}\x02."));
|
ctx.notice(me, from.uid, format!("Game \x02#{id}\x02 started. You are \x02{glyph}\x02."));
|
||||||
push_state(&self.games[&id], me, net, ctx);
|
push_state(&self.games[&id], me, net, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,7 +190,7 @@ impl GameServ {
|
||||||
let meid = Self::ident(from).to_string();
|
let meid = Self::ident(from).to_string();
|
||||||
let want: Option<u64> = args.get(1).and_then(|s| s.parse().ok());
|
let want: Option<u64> = args.get(1).and_then(|s| s.parse().ok());
|
||||||
let id = self.games.iter().find(|(id, g)| {
|
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);
|
}).map(|(id, _)| *id);
|
||||||
let Some(id) = id else {
|
let Some(id) = id else {
|
||||||
ctx.notice(me, from.uid, "No pending challenge to decline.");
|
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.");
|
ctx.notice(me, from.uid, "No active game with that id.");
|
||||||
return;
|
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.");
|
ctx.notice(me, from.uid, "No active game with that id.");
|
||||||
return;
|
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 !board::apply_move(g, side, mv) {
|
||||||
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) {
|
|
||||||
ctx.notice(me, from.uid, "Illegal move (or not your turn).");
|
ctx.notice(me, from.uid, "Illegal move (or not your turn).");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
board::terminal(g)
|
board::terminal(g)
|
||||||
};
|
};
|
||||||
if term.is_empty() {
|
match term {
|
||||||
push_state(&self.games[&id], me, net, ctx);
|
Some(outcome) => self.finish(id, outcome, me, net, ctx),
|
||||||
} else {
|
None => push_state(&self.games[&id], me, net, ctx),
|
||||||
self.finish(id, &term, me, net, ctx);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,71 +244,59 @@ impl GameServ {
|
||||||
};
|
};
|
||||||
let id: u64 = ids.parse().unwrap_or(0);
|
let id: u64 = ids.parse().unwrap_or(0);
|
||||||
let meid = Self::ident(from).to_string();
|
let meid = Self::ident(from).to_string();
|
||||||
let winner_side = match self.games.get(&id) {
|
let winner = match self.games.get(&id) {
|
||||||
Some(g) if g.status == "active" && (g.acc_a == meid || g.acc_b == meid) => {
|
Some(g) if g.status == Status::Active => match Self::side_of(g, &meid) {
|
||||||
if g.acc_a == meid { g.side_b.clone() } else { g.side_a.clone() }
|
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.");
|
ctx.notice(me, from.uid, "No active game with that id.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
ctx.notice(me, from.uid, format!("You resigned game #{id}."));
|
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
|
// 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
|
// registered), push the final state + refreshed stats, and drop the game.
|
||||||
// state + refreshed stats, and drop the game.
|
fn finish(&mut self, id: u64, outcome: Outcome, me: &str, net: &dyn NetView, ctx: &mut ServiceCtx) {
|
||||||
fn finish(&mut self, id: u64, winner_side: &str, me: &str, net: &dyn NetView, ctx: &mut ServiceCtx) {
|
|
||||||
let Some(mut g) = self.games.remove(&id) else { return };
|
let Some(mut g) = self.games.remove(&id) else { return };
|
||||||
g.status = "over".to_string();
|
g.status = Status::Over;
|
||||||
let a_won = winner_side != "draw" && winner_side == g.side_a;
|
g.result = Some(outcome);
|
||||||
g.result = if winner_side == "draw" {
|
let gt = g.gtype();
|
||||||
"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.
|
|
||||||
let ranked = g.a_reg && g.b_reg;
|
let ranked = g.a_reg && g.b_reg;
|
||||||
if ranked {
|
if ranked {
|
||||||
if winner_side == "draw" {
|
match outcome {
|
||||||
ctx.count(counter_key(&g.gtype, 'd', &g.acc_a));
|
Outcome::Draw => {
|
||||||
ctx.count(counter_key(&g.gtype, 'd', &g.acc_b));
|
ctx.count(counter_key(gt, 'd', &g.acc_a));
|
||||||
} else {
|
ctx.count(counter_key(gt, 'd', &g.acc_b));
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
}
|
Outcome::Win(w) => {
|
||||||
// The counter bumps land AFTER this command, so a snapshot here is pre-game;
|
let (wacc, lacc) = match w {
|
||||||
// fold in this game's delta so the pushed rank already reflects the result.
|
Side::A => (&g.acc_a, &g.acc_b),
|
||||||
let counters = net.stat_counters();
|
Side::B => (&g.acc_b, &g.acc_a),
|
||||||
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
|
|
||||||
};
|
};
|
||||||
|
ctx.count(counter_key(gt, 'w', wacc));
|
||||||
|
ctx.count(counter_key(gt, 'l', lacc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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);
|
push_state(&g, me, net, ctx);
|
||||||
if g.a_reg {
|
if g.a_reg {
|
||||||
let s = with_delta(&g.acc_a, true);
|
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((&g.gtype, &s)));
|
push_stats(&counters, &g.acc_a, &g.nick_a, me, net, ctx, Some((gt, &s)));
|
||||||
}
|
}
|
||||||
if g.b_reg {
|
if g.b_reg {
|
||||||
let s = with_delta(&g.acc_b, false);
|
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((&g.gtype, &s)));
|
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 {
|
for id in ids {
|
||||||
let g = &self.games[&id];
|
let g = &self.games[&id];
|
||||||
let opp = if g.acc_a == meid { &g.nick_b } else { &g.nick_a };
|
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
|
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;
|
||||||
for ty in TYPES {
|
for gt in TYPES {
|
||||||
let s = read_stats(&counters, ty, &who);
|
let s = read_stats(&counters, gt, &who);
|
||||||
if s.any() {
|
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
|
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) {
|
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());
|
let gt = match args.get(1) {
|
||||||
if !board::valid_type(&ty) {
|
Some(&t) => match GameType::from_arg(t) {
|
||||||
|
Some(gt) => gt,
|
||||||
|
None => {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
None => GameType::Chess,
|
||||||
|
};
|
||||||
// Aggregate the per-account w/l/d counters for this game type.
|
// Aggregate the per-account w/l/d counters for this game type.
|
||||||
let counters = net.stat_counters();
|
let counters = net.stat_counters();
|
||||||
let prefix = format!("game.{ty}.");
|
let prefix = format!("game.{}.", gt.wire());
|
||||||
let mut map: HashMap<String, Stats> = HashMap::new();
|
let mut map: HashMap<String, Stats> = HashMap::new();
|
||||||
for (k, v) in &counters {
|
for (k, v) in &counters {
|
||||||
let Some(rest) = k.strip_prefix(&prefix) else { continue };
|
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();
|
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()));
|
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() {
|
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;
|
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() {
|
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 {} {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
|
// The GS# state line for one side. `status_override` lets a challenge show as
|
||||||
// challenge show as "pending" to the challenger and "offer" to the target.
|
// "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) {
|
fn push_one(g: &Game, viewer: Side, status_override: &str, me: &str, net: &dyn NetView, ctx: &mut ServiceCtx) {
|
||||||
let (nick, side, oppnick, myacc) = if s == 0 {
|
let (nick, opp) = match viewer {
|
||||||
(&g.nick_a, &g.side_a, &g.nick_b, &g.acc_a)
|
Side::A => (&g.nick_a, &g.nick_b),
|
||||||
} else {
|
Side::B => (&g.nick_b, &g.nick_a),
|
||||||
(&g.nick_b, &g.side_b, &g.nick_a, &g.acc_b)
|
|
||||||
};
|
};
|
||||||
let status = if status_override.is_empty() { g.status.as_str() } else { status_override };
|
let status = if status_override.is_empty() { g.status.wire() } else { status_override };
|
||||||
let res = if g.status == "over" {
|
let res = match (g.status, g.result) {
|
||||||
if g.result == "draw" { "draw" } else if &g.result == myacc { "win" } else { "loss" }
|
(Status::Over, Some(Outcome::Draw)) => "draw",
|
||||||
} else {
|
(Status::Over, Some(Outcome::Win(w))) => if w == viewer { "win" } else { "loss" },
|
||||||
"none"
|
_ => "none",
|
||||||
};
|
};
|
||||||
let payload = format!(
|
let payload = format!(
|
||||||
"GS# {} g={} s={} t={} me={} opp={} st={} r={}",
|
"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);
|
push_tag(me, net, nick, &payload, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_state(g: &Game, me: &str, net: &dyn NetView, ctx: &mut ServiceCtx) {
|
fn push_state(g: &Game, me: &str, net: &dyn NetView, ctx: &mut ServiceCtx) {
|
||||||
push_one(g, 0, "", me, net, ctx);
|
push_one(g, Side::A, "", me, net, ctx);
|
||||||
push_one(g, 1, "", 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),
|
// 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
|
// read from the counter snapshot. `override_ty` shows a just-finished game's
|
||||||
// show its post-game record even though the counter bump hasn't landed yet.
|
// 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)>) {
|
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 ty in TYPES {
|
for gt in TYPES {
|
||||||
let s = match override_ty {
|
let s = match override_ty {
|
||||||
Some((oty, os)) if oty == ty => os.clone(),
|
Some((oty, os)) if oty == gt => os.clone(),
|
||||||
_ => read_stats(counters, ty, account),
|
_ => 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);
|
push_tag(me, net, to_nick, &payload, ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -442,19 +479,30 @@ mod tests {
|
||||||
("game.chess.l.Reverse".to_string(), 2u64),
|
("game.chess.l.Reverse".to_string(), 2u64),
|
||||||
("game.chess.d.Reverse".to_string(), 1u64),
|
("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.wins, s.losses, s.draws), (5, 2, 1));
|
||||||
assert_eq!(s.points(), 34, "10*5 - 8*2");
|
assert_eq!(s.points(), 34, "10*5 - 8*2");
|
||||||
assert_eq!(s.rank(), "Bronze");
|
assert_eq!(s.rank(), "Bronze");
|
||||||
|
|
||||||
// A different game type is a separate ladder (empty here).
|
// A different game type is a separate ladder (empty here).
|
||||||
let c4 = read_stats(&counters, "c4", "Reverse");
|
assert!(!read_stats(&counters, GameType::C4, "Reverse").any());
|
||||||
assert!(!c4.any());
|
|
||||||
|
|
||||||
// Losses alone floor points at 0.
|
// 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!(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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue