gameserv: server-authoritative ttt/c4/chess referee with ranked ladder
All checks were successful
CI / check (push) Successful in 3m56s
All checks were successful
CI / check (push) Successful in 3m56s
This commit is contained in:
parent
e3b83f4a92
commit
a7a37d1277
8 changed files with 1398 additions and 1 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
|
@ -287,6 +287,7 @@ dependencies = [
|
||||||
"echo-debugserv",
|
"echo-debugserv",
|
||||||
"echo-diceserv",
|
"echo-diceserv",
|
||||||
"echo-example",
|
"echo-example",
|
||||||
|
"echo-gameserv",
|
||||||
"echo-groupserv",
|
"echo-groupserv",
|
||||||
"echo-helpserv",
|
"echo-helpserv",
|
||||||
"echo-hostserv",
|
"echo-hostserv",
|
||||||
|
|
@ -366,6 +367,13 @@ dependencies = [
|
||||||
"echo-api",
|
"echo-api",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "echo-gameserv"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"echo-api",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "echo-groupserv"
|
name = "echo-groupserv"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ members = [
|
||||||
"modules/hostserv",
|
"modules/hostserv",
|
||||||
"modules/operserv",
|
"modules/operserv",
|
||||||
"modules/diceserv",
|
"modules/diceserv",
|
||||||
|
"modules/gameserv",
|
||||||
"modules/infoserv",
|
"modules/infoserv",
|
||||||
"modules/reportserv",
|
"modules/reportserv",
|
||||||
"modules/groupserv",
|
"modules/groupserv",
|
||||||
|
|
@ -40,6 +41,7 @@ echo-statserv = { path = "modules/statserv" }
|
||||||
echo-hostserv = { path = "modules/hostserv" }
|
echo-hostserv = { path = "modules/hostserv" }
|
||||||
echo-operserv = { path = "modules/operserv" }
|
echo-operserv = { path = "modules/operserv" }
|
||||||
echo-diceserv = { path = "modules/diceserv" }
|
echo-diceserv = { path = "modules/diceserv" }
|
||||||
|
echo-gameserv = { path = "modules/gameserv" }
|
||||||
echo-infoserv = { path = "modules/infoserv" }
|
echo-infoserv = { path = "modules/infoserv" }
|
||||||
echo-reportserv = { path = "modules/reportserv" }
|
echo-reportserv = { path = "modules/reportserv" }
|
||||||
echo-groupserv = { path = "modules/groupserv" }
|
echo-groupserv = { path = "modules/groupserv" }
|
||||||
|
|
|
||||||
8
modules/gameserv/Cargo.toml
Normal file
8
modules/gameserv/Cargo.toml
Normal file
|
|
@ -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" }
|
||||||
283
modules/gameserv/src/board.rs
Normal file
283
modules/gameserv/src/board.rs
Normal file
|
|
@ -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, // "" | "<winner account>" | "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");
|
||||||
|
}
|
||||||
|
}
|
||||||
695
modules/gameserv/src/chess.rs
Normal file
695
modules/gameserv/src/chess.rs
Normal file
|
|
@ -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: `"<rows>,<turn>,<castling>,<ep>"`.
|
||||||
|
|
||||||
|
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<Move>, 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<Move> {
|
||||||
|
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<Move> {
|
||||||
|
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<Move> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
397
modules/gameserv/src/lib.rs
Normal file
397
modules/gameserv/src/lib.rs
Normal file
|
|
@ -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 <nick> <ttt|c4|chess>\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 <id> <move>\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 <id>\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<String, PlayerLadder>;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct PlayerLadder {
|
||||||
|
display: String,
|
||||||
|
by_type: HashMap<String, Stats>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GameServ {
|
||||||
|
pub uid: String,
|
||||||
|
games: HashMap<u64, Game>,
|
||||||
|
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 <nick> <ttt|c4|chess>");
|
||||||
|
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::<u64>().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<u64> = 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 <id> <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 <id>");
|
||||||
|
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<u64> = 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 <nick> <game>\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.")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -152,7 +152,7 @@ impl Default for Modules {
|
||||||
fn default_services() -> Vec<String> {
|
fn default_services() -> Vec<String> {
|
||||||
[
|
[
|
||||||
"nickserv", "chanserv", "botserv", "hostserv", "memoserv", "operserv", "statserv",
|
"nickserv", "chanserv", "botserv", "hostserv", "memoserv", "operserv", "statserv",
|
||||||
"groupserv", "infoserv", "reportserv", "helpserv", "chanfix", "diceserv", "debugserv",
|
"groupserv", "infoserv", "reportserv", "helpserv", "chanfix", "diceserv", "gameserv", "debugserv",
|
||||||
]
|
]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ use echo_statserv::StatServ;
|
||||||
use echo_hostserv::HostServ;
|
use echo_hostserv::HostServ;
|
||||||
use echo_operserv::OperServ;
|
use echo_operserv::OperServ;
|
||||||
use echo_diceserv::DiceServ;
|
use echo_diceserv::DiceServ;
|
||||||
|
use echo_gameserv::GameServ;
|
||||||
use echo_infoserv::InfoServ;
|
use echo_infoserv::InfoServ;
|
||||||
use echo_reportserv::ReportServ;
|
use echo_reportserv::ReportServ;
|
||||||
use echo_groupserv::GroupServ;
|
use echo_groupserv::GroupServ;
|
||||||
|
|
@ -132,6 +133,9 @@ async fn main() -> Result<()> {
|
||||||
uid: format!("{}AAAAAI", cfg.server.sid),
|
uid: format!("{}AAAAAI", cfg.server.sid),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
if enabled("gameserv") {
|
||||||
|
services.push(Box::new(GameServ::new(format!("{}AAAAAP", cfg.server.sid))));
|
||||||
|
}
|
||||||
if enabled("infoserv") {
|
if enabled("infoserv") {
|
||||||
services.push(Box::new(InfoServ {
|
services.push(Box::new(InfoServ {
|
||||||
uid: format!("{}AAAAAJ", cfg.server.sid),
|
uid: format!("{}AAAAAJ", cfg.server.sid),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue