diff --git a/Cargo.lock b/Cargo.lock index 257dd49..2c62a43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -287,6 +287,7 @@ dependencies = [ "echo-debugserv", "echo-diceserv", "echo-example", + "echo-gameserv", "echo-groupserv", "echo-helpserv", "echo-hostserv", @@ -366,6 +367,13 @@ dependencies = [ "echo-api", ] +[[package]] +name = "echo-gameserv" +version = "0.0.1" +dependencies = [ + "echo-api", +] + [[package]] name = "echo-groupserv" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index c9eca50..d6719c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "modules/hostserv", "modules/operserv", "modules/diceserv", + "modules/gameserv", "modules/infoserv", "modules/reportserv", "modules/groupserv", @@ -40,6 +41,7 @@ echo-statserv = { path = "modules/statserv" } echo-hostserv = { path = "modules/hostserv" } echo-operserv = { path = "modules/operserv" } echo-diceserv = { path = "modules/diceserv" } +echo-gameserv = { path = "modules/gameserv" } echo-infoserv = { path = "modules/infoserv" } echo-reportserv = { path = "modules/reportserv" } echo-groupserv = { path = "modules/groupserv" } diff --git a/modules/gameserv/Cargo.toml b/modules/gameserv/Cargo.toml new file mode 100644 index 0000000..78bcb8c --- /dev/null +++ b/modules/gameserv/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "echo-gameserv" +version = "0.0.1" +edition = "2021" +description = "GameServ: a server-authoritative referee for tic-tac-toe, Connect Four and chess, with a ranked ladder." + +[dependencies] +echo-api = { path = "../../api" } diff --git a/modules/gameserv/src/board.rs b/modules/gameserv/src/board.rs new file mode 100644 index 0000000..217858a --- /dev/null +++ b/modules/gameserv/src/board.rs @@ -0,0 +1,283 @@ +//! 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`. + +use crate::chess; + +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. +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" +} + +// One (account, game type) ladder row. A player can be Gold at chess and Bronze +// at Connect Four, so each type keeps its own record. +#[derive(Default, Clone)] +pub struct Stats { + pub wins: u32, + pub losses: u32, + pub draws: u32, + pub points: i32, +} + +impl Stats { + pub fn rank(&self) -> &'static str { + if self.points >= 800 { + "Master" + } else if self.points >= 400 { + "Gold" + } else if self.points >= 150 { + "Silver" + } else { + "Bronze" + } + } +} + +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 { + 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' ' { + return false; + } + b[c as usize] = side.as_bytes()[0]; + g.board = String::from_utf8(b).unwrap_or_default(); + g.turn = other; + true + } + "c4" => { + let col: i32 = mv.parse().unwrap_or(-1); + if col < 0 || col as usize >= 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, + } + } + _ => false, + } +} + +// "" = 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(); + 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 g.board.contains(' ') { String::new() } else { "draw".into() } + } + "c4" => { + let b = g.board.as_bytes(); + 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' ' { + 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; + } + cc += dc; + rr += dr; + } + if ok { + return (p as char).to_string(); + } + } + } + } + if g.board.contains(' ') { String::new() } else { "draw".into() } + } + "chess" => chess::over(&chess::decode(&g.board)), + _ => String::new(), + } +} + +// 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() + } else { + g.board.replace(' ', ".") + } +} + +// 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. +pub fn escape_tag(v: &str) -> String { + let mut out = String::with_capacity(v.len()); + for c in v.chars() { + match c { + ';' => out.push_str("\\:"), + ' ' => out.push_str("\\s"), + '\\' => out.push_str("\\\\"), + '\r' => out.push_str("\\r"), + '\n' => out.push_str("\\n"), + _ => out.push(c), + } + } + out +} + +#[cfg(test)] +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(), + } + } + + #[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"); + } + + #[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. + 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, "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"); + } + + #[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"); + } + + #[test] + fn encode_board_is_space_free() { + let g = game("ttt"); + assert!(!encode_board(&g).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 new file mode 100644 index 0000000..ccda0aa --- /dev/null +++ b/modules/gameserv/src/chess.rs @@ -0,0 +1,695 @@ +//! Self-contained chess engine for the GameServ referee. +//! +//! Pure-logic port of the perft-verified `gschess` C++ engine (no I/O, no +//! external crates). Board is 64 bytes, index = rank*8 + file +//! (a1=0, h1=7, a8=56, h8=63), `b' '` = empty, pieces `PNBRQK` (white) / +//! `pnbrqk` (black). The wire form ([`encode`]/[`decode`]) is a comma-separated +//! FEN so it carries no spaces: `",,,"`. + +const EMPTY: u8 = b' '; + +#[inline] +fn cf(s: i32) -> i32 { + s & 7 +} + +#[inline] +fn cr(s: i32) -> i32 { + s >> 3 +} + +#[inline] +fn csq(f: i32, r: i32) -> i32 { + r * 8 + f +} + +/// Colour of a piece byte: `b'w'` for uppercase (white), `b'b'` otherwise. +#[inline] +fn ccolor(p: u8) -> u8 { + if p.is_ascii_uppercase() { + b'w' + } else { + b'b' + } +} + +/// True if `p` is a non-empty piece owned by colour `c`. +#[inline] +fn cown(p: u8, c: u8) -> bool { + p != EMPTY && ccolor(p) == c +} + +#[inline] +fn upper(p: u8) -> u8 { + p.to_ascii_uppercase() +} + +const KN: [(i32, i32); 8] = [ + (1, 2), + (2, 1), + (2, -1), + (1, -2), + (-1, -2), + (-2, -1), + (-2, 1), + (-1, 2), +]; +const KG: [(i32, i32); 8] = [ + (1, 0), + (1, 1), + (0, 1), + (-1, 1), + (-1, 0), + (-1, -1), + (0, -1), + (1, -1), +]; +const BD: [(i32, i32); 4] = [(1, 1), (-1, 1), (-1, -1), (1, -1)]; +const RD: [(i32, i32); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)]; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Move { + pub from: usize, + pub to: usize, + /// 0 or `b'q'` / `b'r'` / `b'b'` / `b'n'`. + pub promo: u8, + /// en-passant capture + pub ep: bool, + /// pawn double-push + pub dbl: bool, + /// 0, `b'K'` or `b'Q'` + pub castle: u8, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct State { + /// 64 bytes, index = rank*8 + file. + pub board: [u8; 64], + /// `b'w'` or `b'b'`. + pub turn: u8, + /// White king-side castling right. + pub c_k: bool, + /// White queen-side castling right. + pub c_q: bool, + /// Black king-side castling right. + pub c_k_black: bool, + /// Black queen-side castling right. + pub c_q_black: bool, + /// En-passant target square, -1 = none. + pub ep: i32, +} + +impl Default for State { + fn default() -> Self { + State { + board: [EMPTY; 64], + turn: b'w', + c_k: true, + c_q: true, + c_k_black: true, + c_q_black: true, + ep: -1, + } + } +} + +pub fn initial() -> State { + let mut st = State::default(); + let back = b"RNBQKBNR"; + for f in 0..8 { + st.board[csq(f, 0) as usize] = back[f as usize]; + st.board[csq(f, 1) as usize] = b'P'; + st.board[csq(f, 6) as usize] = b'p'; + st.board[csq(f, 7) as usize] = back[f as usize].to_ascii_lowercase(); + } + st +} + +/// True if square `target` is attacked by any piece of colour `by`. +fn attacked(b: &[u8; 64], target: i32, by: u8) -> bool { + let tf = cf(target); + let tr = cr(target); + let pr = if by == b'w' { tr - 1 } else { tr + 1 }; + + // Pawn attacks (a pawn of colour `by` on `pr` attacking diagonally). + for df in [-1, 1] { + let f = tf + df; + if (0..8).contains(&f) && (0..8).contains(&pr) { + let p = b[csq(f, pr) as usize]; + if p != EMPTY && ccolor(p) == by && upper(p) == b'P' { + return true; + } + } + } + // Knight attacks. + for (dx, dy) in KN { + let f = tf + dx; + let r = tr + dy; + if (0..8).contains(&f) && (0..8).contains(&r) { + let p = b[csq(f, r) as usize]; + if p != EMPTY && ccolor(p) == by && upper(p) == b'N' { + return true; + } + } + } + // King attacks. + for (dx, dy) in KG { + let f = tf + dx; + let r = tr + dy; + if (0..8).contains(&f) && (0..8).contains(&r) { + let p = b[csq(f, r) as usize]; + if p != EMPTY && ccolor(p) == by && upper(p) == b'K' { + return true; + } + } + } + // Bishop / queen (diagonal) attacks. + for (dx, dy) in BD { + let mut f = tf + dx; + let mut r = tr + dy; + while (0..8).contains(&f) && (0..8).contains(&r) { + let p = b[csq(f, r) as usize]; + if p != EMPTY { + let u = upper(p); + if ccolor(p) == by && (u == b'B' || u == b'Q') { + return true; + } + break; + } + f += dx; + r += dy; + } + } + // Rook / queen (orthogonal) attacks. + for (dx, dy) in RD { + let mut f = tf + dx; + let mut r = tr + dy; + while (0..8).contains(&f) && (0..8).contains(&r) { + let p = b[csq(f, r) as usize]; + if p != EMPTY { + let u = upper(p); + if ccolor(p) == by && (u == b'R' || u == b'Q') { + return true; + } + break; + } + f += dx; + r += dy; + } + } + false +} + +fn king_sq(b: &[u8; 64], c: u8) -> i32 { + let k = if c == b'w' { b'K' } else { b'k' }; + b.iter().position(|&p| p == k).map(|i| i as i32).unwrap_or(-1) +} + +fn in_check(st: &State, c: u8) -> bool { + let by = if c == b'w' { b'b' } else { b'w' }; + attacked(&st.board, king_sq(&st.board, c), by) +} + +fn add_pawn(mv: &mut Vec, from: i32, to: i32, promo: bool, ep: bool, dbl: bool) { + if promo { + for pr in *b"qrbn" { + mv.push(Move { + from: from as usize, + to: to as usize, + promo: pr, + ..Move::default() + }); + } + } else { + mv.push(Move { + from: from as usize, + to: to as usize, + ep, + dbl, + ..Move::default() + }); + } +} + +/// Pseudo-legal moves (may leave the mover's own king in check). +fn pseudo(st: &State) -> Vec { + let mut mv = Vec::new(); + let c = st.turn; + let dir = if c == b'w' { 1 } else { -1 }; + let start_rank = if c == b'w' { 1 } else { 6 }; + let promo_rank = if c == b'w' { 7 } else { 0 }; + + for s in 0..64 { + let p = st.board[s as usize]; + if p == EMPTY || ccolor(p) != c { + continue; + } + let f = cf(s); + let r = cr(s); + let t = upper(p); + if t == b'P' { + let r1 = r + dir; + if (0..8).contains(&r1) && st.board[csq(f, r1) as usize] == EMPTY { + add_pawn(&mut mv, s, csq(f, r1), r1 == promo_rank, false, false); + if r == start_rank && st.board[csq(f, r + 2 * dir) as usize] == EMPTY { + add_pawn(&mut mv, s, csq(f, r + 2 * dir), false, false, true); + } + } + for df in [-1, 1] { + let cf2 = f + df; + let cr2 = r + dir; + if !(0..8).contains(&cf2) || !(0..8).contains(&cr2) { + continue; + } + let to = csq(cf2, cr2); + let tp = st.board[to as usize]; + if tp != EMPTY && ccolor(tp) != c { + add_pawn(&mut mv, s, to, cr2 == promo_rank, false, false); + } else if to == st.ep { + mv.push(Move { + from: s as usize, + to: to as usize, + ep: true, + ..Move::default() + }); + } + } + } else if t == b'N' { + for (dx, dy) in KN { + let cf2 = f + dx; + let cr2 = r + dy; + if !(0..8).contains(&cf2) || !(0..8).contains(&cr2) { + continue; + } + let to = csq(cf2, cr2); + if !cown(st.board[to as usize], c) { + mv.push(Move { + from: s as usize, + to: to as usize, + ..Move::default() + }); + } + } + } else if t == b'K' { + for (dx, dy) in KG { + let cf2 = f + dx; + let cr2 = r + dy; + if !(0..8).contains(&cf2) || !(0..8).contains(&cr2) { + continue; + } + let to = csq(cf2, cr2); + if !cown(st.board[to as usize], c) { + mv.push(Move { + from: s as usize, + to: to as usize, + ..Move::default() + }); + } + } + let enemy = if c == b'w' { b'b' } else { b'w' }; + let rank = if c == b'w' { 0 } else { 7 }; + let k_from = csq(4, rank); + if s == k_from && !attacked(&st.board, k_from, enemy) { + let kr = if c == b'w' { st.c_k } else { st.c_k_black }; + let qr = if c == b'w' { st.c_q } else { st.c_q_black }; + let rook = if c == b'w' { b'R' } else { b'r' }; + if kr + && st.board[csq(5, rank) as usize] == EMPTY + && st.board[csq(6, rank) as usize] == EMPTY + && st.board[csq(7, rank) as usize] == rook + && !attacked(&st.board, csq(5, rank), enemy) + && !attacked(&st.board, csq(6, rank), enemy) + { + mv.push(Move { + from: k_from as usize, + to: csq(6, rank) as usize, + castle: b'K', + ..Move::default() + }); + } + if qr + && st.board[csq(3, rank) as usize] == EMPTY + && st.board[csq(2, rank) as usize] == EMPTY + && st.board[csq(1, rank) as usize] == EMPTY + && st.board[csq(0, rank) as usize] == rook + && !attacked(&st.board, csq(3, rank), enemy) + && !attacked(&st.board, csq(2, rank), enemy) + { + mv.push(Move { + from: k_from as usize, + to: csq(2, rank) as usize, + castle: b'Q', + ..Move::default() + }); + } + } + } else { + // Sliding pieces: bishop (BD), rook (RD), queen (both). + let dirs: &[(i32, i32)] = match t { + b'B' => &BD, + b'R' => &RD, + _ => &[BD[0], BD[1], BD[2], BD[3], RD[0], RD[1], RD[2], RD[3]], + }; + for &(dx, dy) in dirs { + let mut cf2 = f + dx; + let mut cr2 = r + dy; + while (0..8).contains(&cf2) && (0..8).contains(&cr2) { + let to = csq(cf2, cr2); + let tp = st.board[to as usize]; + if tp != EMPTY { + if ccolor(tp) != c { + mv.push(Move { + from: s as usize, + to: to as usize, + ..Move::default() + }); + } + break; + } + mv.push(Move { + from: s as usize, + to: to as usize, + ..Move::default() + }); + cf2 += dx; + cr2 += dy; + } + } + } + } + mv +} + +pub fn apply(st: &State, m: &Move) -> State { + let mut n = st.clone(); + let c = st.turn; + let dir = if c == b'w' { 1 } else { -1 }; + let piece = n.board[m.from]; + n.board[m.from] = EMPTY; + + if m.ep { + // Remove the pawn captured en passant: same file as target, same rank + // as the moving pawn's origin. + n.board[csq(cf(m.to as i32), cr(m.from as i32)) as usize] = EMPTY; + } + if m.promo != 0 { + n.board[m.to] = if c == b'w' { + m.promo.to_ascii_uppercase() + } else { + m.promo + }; + } else { + n.board[m.to] = piece; + } + if m.castle != 0 { + let rk = if c == b'w' { 0 } else { 7 }; + let rook = if c == b'w' { b'R' } else { b'r' }; + if m.castle == b'K' { + n.board[csq(7, rk) as usize] = EMPTY; + n.board[csq(5, rk) as usize] = rook; + } else { + n.board[csq(0, rk) as usize] = EMPTY; + n.board[csq(3, rk) as usize] = rook; + } + } + + let pu = upper(piece); + if pu == b'K' { + if c == b'w' { + n.c_k = false; + n.c_q = false; + } else { + n.c_k_black = false; + n.c_q_black = false; + } + } + if piece == b'R' { + if m.from == csq(0, 0) as usize { + n.c_q = false; + } + if m.from == csq(7, 0) as usize { + n.c_k = false; + } + } + if piece == b'r' { + if m.from == csq(0, 7) as usize { + n.c_q_black = false; + } + if m.from == csq(7, 7) as usize { + n.c_k_black = false; + } + } + // Capturing a rook on its home square removes that castling right. + if m.to == csq(0, 0) as usize { + n.c_q = false; + } + if m.to == csq(7, 0) as usize { + n.c_k = false; + } + if m.to == csq(0, 7) as usize { + n.c_q_black = false; + } + if m.to == csq(7, 7) as usize { + n.c_k_black = false; + } + + n.turn = if c == b'w' { b'b' } else { b'w' }; + n.ep = if m.dbl { + csq(cf(m.from as i32), cr(m.from as i32) + dir) + } else { + -1 + }; + n +} + +/// Fully legal moves: pseudo-legal filtered so the mover's king is not left +/// attacked. +pub fn legal(st: &State) -> Vec { + let c = st.turn; + let by = if c == b'w' { b'b' } else { b'w' }; + pseudo(st) + .into_iter() + .filter(|m| { + let ns = apply(st, m); + !attacked(&ns.board, king_sq(&ns.board, c), by) + }) + .collect() +} + +/// `""` ongoing; `"draw"` for stalemate; or the winning side (`"w"`/`"b"`) on +/// checkmate. +pub fn over(st: &State) -> String { + if !legal(st).is_empty() { + return String::new(); + } + if in_check(st, st.turn) { + return if st.turn == b'w' { "b" } else { "w" }.to_string(); + } + "draw".to_string() +} + +fn name_sq(s: i32) -> String { + let mut r = String::new(); + r.push((b'a' + cf(s) as u8) as char); + r.push((b'1' + cr(s) as u8) as char); + r +} + +fn sq_name(n: &[u8]) -> i32 { + csq((n[0] - b'a') as i32, (n[1] - b'1') as i32) +} + +/// Find the legal move matching a UCI string (`e2e4`, `e7e8q`). `None` if not +/// legal. +pub fn parse(st: &State, uci: &str) -> Option { + let bytes = uci.as_bytes(); + if bytes.len() < 4 { + return None; + } + let from = sq_name(&bytes[0..2]) as usize; + let to = sq_name(&bytes[2..4]) as usize; + let promo = if bytes.len() > 4 { + bytes[4].to_ascii_lowercase() + } else { + 0 + }; + legal(st) + .into_iter() + .find(|m| m.from == from && m.to == to && m.promo == promo) +} + +/// Comma-separated FEN (no spaces) for the wire. +pub fn encode(st: &State) -> String { + let mut rows = String::new(); + for r in (0..8).rev() { + let mut e = 0; + let mut row = String::new(); + for f in 0..8 { + let p = st.board[csq(f, r) as usize]; + if p != EMPTY { + if e > 0 { + row.push_str(&e.to_string()); + e = 0; + } + row.push(p as char); + } else { + e += 1; + } + } + if e > 0 { + row.push_str(&e.to_string()); + } + if r < 7 { + rows.push('/'); + } + rows.push_str(&row); + } + let mut cc = String::new(); + if st.c_k { + cc.push('K'); + } + if st.c_q { + cc.push('Q'); + } + if st.c_k_black { + cc.push('k'); + } + if st.c_q_black { + cc.push('q'); + } + if cc.is_empty() { + cc.push('-'); + } + let ep = if st.ep < 0 { + "-".to_string() + } else { + name_sq(st.ep) + }; + format!("{},{},{},{}", rows, st.turn as char, cc, ep) +} + +/// Inverse of [`encode`]. On malformed input (fewer than 4 comma parts) returns +/// [`initial`]. +pub fn decode(enc: &str) -> State { + let mut st = State { + board: [EMPTY; 64], + turn: b'w', + c_k: false, + c_q: false, + c_k_black: false, + c_q_black: false, + ep: -1, + }; + + // Collect up to 4 comma-separated fields, mirroring the C++ manual scan: + // each step takes the segment up to the next comma, or the whole remainder + // when no comma is left. + let mut parts: Vec<&str> = Vec::new(); + let mut pos = 0; + while parts.len() < 4 { + match enc[pos..].find(',') { + Some(rel) => { + let comma = pos + rel; + parts.push(&enc[pos..comma]); + pos = comma + 1; + } + None => { + parts.push(&enc[pos..]); + break; + } + } + } + if parts.len() < 4 { + return initial(); + } + + // Board. + let mut r: i32 = 7; + let mut f: i32 = 0; + for ch in parts[0].bytes() { + if ch == b'/' { + r -= 1; + f = 0; + } else if (b'1'..=b'8').contains(&ch) { + f += (ch - b'0') as i32; + } else { + if (0..8).contains(&r) && (0..8).contains(&f) { + st.board[csq(f, r) as usize] = ch; + } + f += 1; + } + } + st.turn = parts[1].bytes().next().unwrap_or(b'w'); + for ch in parts[2].bytes() { + match ch { + b'K' => st.c_k = true, + b'Q' => st.c_q = true, + b'k' => st.c_k_black = true, + b'q' => st.c_q_black = true, + _ => {} + } + } + st.ep = if parts[3] == "-" || parts[3].is_empty() { + -1 + } else { + sq_name(parts[3].as_bytes()) + }; + st +} + +#[cfg(test)] +mod tests { + use super::*; + + fn perft(st: &State, depth: u32) -> u64 { + if depth == 0 { + return 1; + } + let moves = legal(st); + if depth == 1 { + return moves.len() as u64; + } + moves.iter().map(|m| perft(&apply(st, m), depth - 1)).sum() + } + + #[test] + fn perft_start_position() { + let st = initial(); + assert_eq!(perft(&st, 1), 20); + assert_eq!(perft(&st, 2), 400); + assert_eq!(perft(&st, 3), 8902); + assert_eq!(perft(&st, 4), 197281); + } + + #[test] + fn encode_initial() { + let enc = encode(&initial()); + assert_eq!( + enc, + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR,w,KQkq,-" + ); + } + + #[test] + fn encode_decode_roundtrip() { + let st = initial(); + assert_eq!(decode(&encode(&st)), st); + } + + #[test] + fn decode_malformed_returns_initial() { + assert_eq!(decode("garbage"), initial()); + assert_eq!(decode("a,b,c"), initial()); + } + + #[test] + fn parse_and_apply_e2e4() { + let st = initial(); + let m = parse(&st, "e2e4").expect("e2e4 legal"); + assert!(m.dbl); + let ns = apply(&st, &m); + assert_eq!(ns.turn, b'b'); + assert_eq!(ns.ep, csq(4, 2)); // e3 + assert!(parse(&st, "e2e5").is_none()); + } +} diff --git a/modules/gameserv/src/lib.rs b/modules/gameserv/src/lib.rs new file mode 100644 index 0000000..4311e5f --- /dev/null +++ b/modules/gameserv/src/lib.rs @@ -0,0 +1,397 @@ +//! GameServ — 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. +//! +//! `board.rs` is the referee (rules + state); `chess.rs` is the chess engine. + +use std::collections::HashMap; + +use echo_api::{HelpEntry, NetAction, NetView, Sender, Service, ServiceCtx, Store}; + +#[path = "chess.rs"] +mod chess; +#[path = "board.rs"] +mod board; + +use board::{Game, Stats}; + +// 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 BLURB: &str = "GameServ 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 TOPICS: &[HelpEntry] = &[ + HelpEntry { cmd: "CHALLENGE", summary: "challenge someone to a game", detail: "Syntax: \x02CHALLENGE \x02\nChallenges a user. They accept with \x02ACCEPT\x02; you move first." }, + HelpEntry { cmd: "ACCEPT", summary: "accept a challenge", detail: "Syntax: \x02ACCEPT [id]\x02\nAccepts a pending challenge (the most recent one if no id is given)." }, + HelpEntry { cmd: "DECLINE", summary: "decline a challenge", detail: "Syntax: \x02DECLINE [id]\x02\nDeclines a pending challenge." }, + HelpEntry { cmd: "MOVE", summary: "make a move", detail: "Syntax: \x02MOVE \x02\nMakes a move: ttt cell \x020-8\x02, c4 column \x020-6\x02, chess \x02UCI\x02 like e2e4 (e7e8q to promote)." }, + HelpEntry { cmd: "RESIGN", summary: "resign a game", detail: "Syntax: \x02RESIGN \x02\nResigns an active game; your opponent wins." }, + HelpEntry { cmd: "GAMES", summary: "list your games", detail: "Syntax: \x02GAMES\x02\nLists your pending and active games with their ids." }, + HelpEntry { cmd: "STATS", summary: "show ranked stats", detail: "Syntax: \x02STATS [nick]\x02\nShows a player's ranked record per game type (yours if no nick)." }, + HelpEntry { cmd: "TOP", summary: "show the leaderboard", detail: "Syntax: \x02TOP [ttt|c4|chess]\x02\nShows the top ranked players for a game type (chess by default)." }, +]; + +// account key (lowercased) -> that player's ladder across game types. +type Ladder = HashMap; + +#[derive(Default)] +struct PlayerLadder { + display: String, + by_type: HashMap, +} + +pub struct GameServ { + pub uid: String, + games: HashMap, + ladder: Ladder, + nextid: u64, +} + +impl GameServ { + pub fn new(uid: String) -> Self { + Self { uid, games: HashMap::new(), ladder: Ladder::new(), nextid: 1 } + } + + // A player's ranked identity: their account if registered, else their nick + // (guests play casually under their nick; ranked points need an account). + fn ident<'a>(from: &'a Sender) -> &'a str { + from.account.unwrap_or(from.nick) + } + + // Get (creating if needed) a mutable ladder row, remembering the display name. + fn stat(&mut self, account: &str, gtype: &str) -> &mut Stats { + let pl = self.ladder.entry(account.to_lowercase()).or_default(); + if pl.display.is_empty() { + pl.display = account.to_string(); + } + pl.by_type.entry(gtype.to_string()).or_default() + } + + fn do_challenge(&mut self, me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) { + let (Some(&target), Some(&game)) = (args.get(1), args.get(2)) else { + ctx.notice(me, from.uid, "Syntax: CHALLENGE "); + return; + }; + let gtype = game.to_lowercase(); + if !board::valid_type(>ype) { + 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; + }; + if tuid == from.uid { + ctx.notice(me, from.uid, "You cannot challenge yourself."); + return; + } + let meid = Self::ident(from).to_string(); + let mine = self.games.values().filter(|g| g.acc_a == meid || g.acc_b == meid).count(); + if mine >= MAX_GAMES_PER_USER { + ctx.notice(me, from.uid, format!("You have too many games in progress (max {MAX_GAMES_PER_USER}). Finish or resign one first.")); + 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 { + 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(), + }; + 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 GameServ ACCEPT {id}\x02", from.nick)); + 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 + } + + 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), + }; + 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.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.")); + push_state(&self.games[&id], me, net, ctx); + } + + fn do_decline(&mut self, me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) { + 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) + }).map(|(id, _)| *id); + let Some(id) = id else { + ctx.notice(me, from.uid, "No pending challenge to decline."); + return; + }; + let g = self.games.remove(&id).unwrap(); + if let Some(auid) = net.uid_by_nick(&g.nick_a).map(str::to_string) { + ctx.notice(me, &auid, format!("{} declined your challenge #{id}.", from.nick)); + } + ctx.notice(me, from.uid, "Declined."); + } + + fn do_move(&mut self, me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) { + let (Some(&ids), Some(&mv)) = (args.get(1), args.get(2)) else { + ctx.notice(me, from.uid, "Syntax: MOVE "); + return; + }; + let id: u64 = ids.parse().unwrap_or(0); + let meid = Self::ident(from).to_string(); + // Apply under a scoped mutable borrow, then release it before pushing. + let term = { + let Some(g) = self.games.get_mut(&id) else { + ctx.notice(me, from.uid, "No active game with that id."); + return; + }; + if g.status != "active" || (g.acc_a != meid && g.acc_b != meid) { + ctx.notice(me, from.uid, "No active game with that id."); + return; + } + 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) { + 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); + } + } + + fn do_resign(&mut self, me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) { + let Some(&ids) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: RESIGN "); + return; + }; + 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() } + } + _ => { + 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); + } + + // 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) { + 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() + }; + if g.a_reg && g.b_reg { + if winner_side == "draw" { + self.stat(&g.acc_a, &g.gtype).draws += 1; + self.stat(&g.acc_b, &g.gtype).draws += 1; + } else { + let (wacc, lacc) = if a_won { (g.acc_a.clone(), g.acc_b.clone()) } else { (g.acc_b.clone(), g.acc_a.clone()) }; + let w = self.stat(&wacc, &g.gtype); + w.wins += 1; + w.points += 10; + let l = self.stat(&lacc, &g.gtype); + l.losses += 1; + l.points = (l.points - 8).max(0); + } + } + push_state(&g, me, net, ctx); + if g.a_reg { + push_stats(&self.ladder, &g.acc_a, &g.nick_a, me, net, ctx); + } + if g.b_reg { + push_stats(&self.ladder, &g.acc_b, &g.nick_b, me, net, ctx); + } + } + + fn do_games(&self, me: &str, from: &Sender, ctx: &mut ServiceCtx) { + let meid = Self::ident(from).to_string(); + let mut ids: Vec = self.games.iter().filter(|(_, g)| g.acc_a == meid || g.acc_b == meid).map(|(id, _)| *id).collect(); + ids.sort_unstable(); + if ids.is_empty() { + ctx.notice(me, from.uid, "You have no games. Use \x02CHALLENGE \x02."); + return; + } + 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)); + } + } + + fn do_stats(&self, me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) { + let who = if let Some(&t) = args.get(1) { + net.uid_by_nick(t).and_then(|u| net.account_of(u)).map(str::to_string).unwrap_or_else(|| t.to_string()) + } else if let Some(a) = from.account { + a.to_string() + } else { + ctx.notice(me, from.uid, "Log in or specify a nick."); + return; + }; + push_stats(&self.ladder, &who, from.nick, me, net, ctx); // machine lines for the UI + ctx.notice(me, from.uid, format!("\x02{who}\x02 — classement Jeux")); + let mut any = false; + let pl = self.ladder.get(&who.to_lowercase()); + for ty in TYPES { + if let Some(s) = pl.and_then(|p| p.by_type.get(ty)) { + 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 + )); + } + } + if !any { + ctx.notice(me, from.uid, " (aucune partie classée pour l'instant)"); + } + } + + 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 mut all: Vec<(&str, &Stats)> = self.ladder.values() + .filter_map(|pl| pl.by_type.get(&ty).filter(|s| s.points > 0).map(|s| (pl.display.as_str(), s))) + .collect(); + all.sort_by_key(|&(_, s)| std::cmp::Reverse(s.points)); + push_tag(me, net, from.nick, &format!("GS$TOPSTART {ty}"), ctx); + if all.is_empty() { + ctx.notice(me, from.uid, format!("Classement \x02{}\x02 vide. Sois le premier à gagner !", board::type_label(&ty))); + return; + } + ctx.notice(me, from.uid, format!("\x02Classement {} :\x02", board::type_label(&ty))); + for (rank, (acc, s)) in all.iter().take(10).enumerate() { + let rank = rank + 1; + ctx.notice(me, from.uid, format!("{rank:2}. {acc:<16} {} {} pts", s.rank(), s.points)); + push_tag(me, net, from.nick, &format!("GS$TOPROW {ty} {rank} {acc} {} {}", s.rank(), s.points), ctx); + } + } +} + +// ── web-sync pushes (free functions so they never borrow the GameServ state) ── + +// Client-only tag on a TAGMSG: invisible in chat, read only by the web client. +fn push_tag(me: &str, net: &dyn NetView, nick: &str, payload: &str, ctx: &mut ServiceCtx) { + if let Some(uid) = net.uid_by_nick(nick) { + ctx.actions.push(NetAction::Raw(format!("@+tchatou.fr/gs={} :{} TAGMSG {}", board::escape_tag(payload), me, uid))); + } +} + +// 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) + }; + 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 payload = format!( + "GS# {} g={} s={} t={} me={} opp={} st={} r={}", + g.id, g.gtype, board::encode_board(g), g.turn, side, oppnick, 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); +} + +// One GS$ stats line per game type (for the client's rank strip / profile badge). +fn push_stats(ladder: &Ladder, account: &str, to_nick: &str, me: &str, net: &dyn NetView, ctx: &mut ServiceCtx) { + let pl = ladder.get(&account.to_lowercase()); + for ty in TYPES { + let s = pl.and_then(|p| p.by_type.get(ty)).cloned().unwrap_or_default(); + let payload = format!("GS$ {} {} r={} p={} w={} l={} d={}", account, ty, s.rank(), s.points, s.wins, s.losses, s.draws); + push_tag(me, net, to_nick, &payload, ctx); + } +} + +impl Service for GameServ { + fn nick(&self) -> &str { + "GameServ" + } + fn uid(&self) -> &str { + &self.uid + } + fn gecos(&self) -> &str { + "Game Service" + } + + fn help_topics(&self) -> (&'static str, &'static [HelpEntry]) { + (BLURB, TOPICS) + } + + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, _store: &mut dyn Store) { + let me = self.uid.clone(); + match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { + Some("CHALLENGE") => self.do_challenge(&me, from, args, ctx, net), + Some("ACCEPT") => self.do_accept(&me, from, args, ctx, net), + Some("DECLINE") => self.do_decline(&me, from, args, ctx, net), + Some("MOVE") => self.do_move(&me, from, args, ctx, net), + Some("RESIGN") => self.do_resign(&me, from, args, ctx, net), + Some("GAMES") => self.do_games(&me, from, ctx), + Some("STATS") => self.do_stats(&me, from, args, ctx, net), + Some("TOP") => self.do_top(&me, from, args, ctx, net), + Some("HELP") | None => echo_api::help(&me, from, ctx, BLURB, TOPICS, args.get(1).copied()), + Some(other) => ctx.notice(&me, from.uid, format!("I don't know \x02{other}\x02. Try \x02HELP\x02.")), + } + } +} diff --git a/src/config.rs b/src/config.rs index 5b9e773..5dacb5d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -152,7 +152,7 @@ impl Default for Modules { fn default_services() -> Vec { [ "nickserv", "chanserv", "botserv", "hostserv", "memoserv", "operserv", "statserv", - "groupserv", "infoserv", "reportserv", "helpserv", "chanfix", "diceserv", "debugserv", + "groupserv", "infoserv", "reportserv", "helpserv", "chanfix", "diceserv", "gameserv", "debugserv", ] .iter() .map(|s| s.to_string()) diff --git a/src/main.rs b/src/main.rs index 69edc1e..0831809 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,7 @@ use echo_statserv::StatServ; use echo_hostserv::HostServ; use echo_operserv::OperServ; use echo_diceserv::DiceServ; +use echo_gameserv::GameServ; use echo_infoserv::InfoServ; use echo_reportserv::ReportServ; use echo_groupserv::GroupServ; @@ -132,6 +133,9 @@ async fn main() -> Result<()> { uid: format!("{}AAAAAI", cfg.server.sid), })); } + if enabled("gameserv") { + services.push(Box::new(GameServ::new(format!("{}AAAAAP", cfg.server.sid)))); + } if enabled("infoserv") { services.push(Box::new(InfoServ { uid: format!("{}AAAAAJ", cfg.server.sid),