diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/conn.rs | 191 | ||||
-rw-r--r-- | src/livepos.rs | 78 | ||||
-rw-r--r-- | src/main.rs | 328 | ||||
-rw-r--r-- | src/minesweeper.rs | 286 | ||||
-rw-r--r-- | src/types.rs | 135 |
5 files changed, 1018 insertions, 0 deletions
diff --git a/src/conn.rs b/src/conn.rs new file mode 100644 index 0000000..addf3c5 --- /dev/null +++ b/src/conn.rs @@ -0,0 +1,191 @@ +use crate::types::*; +use std::{ + sync::Arc, + net::SocketAddr, +}; +use tokio::sync::RwLock; +use tokio::sync::mpsc as tokio_mpsc; +use futures::{SinkExt, StreamExt, TryStreamExt, stream::SplitStream}; +use warp::ws::{ WebSocket, Message }; +use crate::livepos; + +const MAX_IN: usize = 2048; + +pub async fn lobby(socket: WebSocket, addr: SocketAddr, rinfo: (RoomId,Arc<RwLock<Room>>)) { + let (room_id, room) = rinfo; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + + // server <-> client comms + let (mut outgoing, incoming) = socket.split(); + + println!("{room_id} I: Incoming TCP connection from: {}", addr); + + let full = { + let rl = room.read().await; + let pcap = rl.conf.player_cap; + let pl = rl.players.read().await; + pl.len() >= pcap + }; + if full { return } + let drive_game = handle_room((incoming,tx), addr, (room_id.clone(),room.clone())); + let send_to_client = { + let room_id = room_id.clone(); + async move { + while let Some(m) = rx.recv().await { + if let Err(e) = outgoing.send(m).await { + println!("{room_id} E: something went bad lol: {e}"); + } + } + } + }; + + tokio::select! { + _ = drive_game => (), + _ = send_to_client => { println!("{room_id} E: anomalous close for {addr}"); } + }; + + let room_lock = room.read().await; + let mut players = room_lock.players.write().await; + if let Some(disconn_p) = players.remove(&addr) { + if let Err(e) = room_lock.pos_stream.send(livepos::Req { id: disconn_p.uid, data: livepos::ReqData::Quit }) { + println!("{room_id} E: couldn't send removal request for {disconn_p} from the live position system: {e}"); + } + for p in players.values() { + if let Err(e) = p.conn.tx.send(Message::text(format!("logoff {}", disconn_p.uid))) { + println!("{room_id} E: couldn't deliver logoff info to {}: {}", p, e); + } + } + println!("{room_id} I: {disconn_p} disconnected"); + } else { + println!("{room_id} I: {addr} disconnected"); + } +} + +type RoomStreams = (SplitStream<WebSocket>,tokio_mpsc::UnboundedSender<Message>); + +pub async fn handle_room(streams: RoomStreams, addr: SocketAddr, rinfo: (RoomId, Arc<RwLock<Room>>)) { + let (mut incoming, tx) = streams; + let (room_id, room) = rinfo; + let (players, cmd_tx, pos_tx, room_conf) = { + let room = room.read().await; + (room.players.clone(), room.cmd_stream.clone(), room.pos_stream.clone(), room.conf.clone()) + }; + while let Ok(cmd) = incoming.try_next().await { + if let Some(cmd) = cmd { + // if it ain't text we can't handle it + let cmd = match cmd.to_str() { + Ok(cmd) => { if cmd.len() > MAX_IN { + println!("{room_id} E: string too big: {cmd}"); + return + } else { cmd.to_owned() } }, + Err(_) => return + }; + + let mut fields = cmd.split(" "); + let parse_pos = |mut fields: std::str::Split<&str>| -> Option<(usize, usize)> { + let x = fields.next().and_then(|xstr| xstr.parse::<usize>().ok()); + let y = fields.next().and_then(|ystr| ystr.parse::<usize>().ok()); + x.zip(y) + }; + if let Some(cmd_name) = fields.next() { + use crate::minesweeper::{Move,MoveType}; + let mut players_lock = players.write().await; + match players_lock.get_mut(&addr) { + Some(me) => match cmd_name { + "pos" => { + if let Some(pos) = parse_pos(fields) { + if let Err(e) = pos_tx.send(livepos::Req { id: me.uid, data: livepos::ReqData::Pos(pos) }) { + println!("{room_id} E: couldn't process {me}'s position update: {e}"); + }; + } + }, + "reveal" => { + match parse_pos(fields) { + Some(pos) => { + if let Err(e) = cmd_tx.send(MetaMove::Move(Move { t: MoveType::Reveal, pos }, addr)) { + println!("{room_id} E: couldn't process {me}'s reveal command: {e}"); + }; + }, + None => { + println!("{room_id} E: bad reveal from {me}"); + } + } + }, + "flag" => { + match parse_pos(fields) { + Some(pos) => { + if let Err(e) = cmd_tx.send(MetaMove::Move(Move { t: MoveType::ToggleFlag, pos }, addr)) { + println!("{room_id} E: couldn't process {me}'s flag command: {e}"); + }; + }, + None => { + println!("{room_id} E: bad flag from {me}"); + } + } + }, + "reset" => { + if let Err(e) = cmd_tx.send(MetaMove::Reset) { + println!("{room_id} E: couldn't request game dump in behalf of {me}: {e}"); + } + }, + e => println!("{room_id} E: unknown command {e:?} from {me}: \"{cmd}\""), + }, + None => { + if cmd_name == "register" { + let mut all_fields = fields.collect::<Vec<&str>>(); + let clr = all_fields.pop().expect("register without color").chars().filter(|c| c.is_digit(16) || *c == '#').collect::<String>(); + let name = { + let def = "anon".to_string(); + if all_fields.is_empty() { def } + else { + let n = ammonia::clean(&all_fields.join(" ")); + if n.is_empty() { def } else { n } + } + }; + println!("{room_id} I: registered \"{name}@{addr}\""); + drop(players_lock); + let uid = { + // new scope cuz paranoid bout deadlocks + let conn = Conn { addr, tx: tx.clone() }; + room.write().await.players.insert_conn(conn, name.clone(), clr).await + }; + let players_lock = players.read().await; + let me = players_lock.get(&addr).unwrap(); + tx.send(Message::text(format!("regack {} {} {} {}", + room_conf.name.replace(' ', " "), name.replace(' ', " "), uid, room_conf.board_conf)) + ).expect("couldn't send register ack"); + + { + let msg = Message::text(format!("players {}", + jsonenc_players(players_lock.values()) + .expect("couldn't JSONify players"))); + for p in players_lock.values() { + if let Err(e) = p.conn.tx.send(msg.clone()) { + println!("{room_id} E: couldn't dump players for {me}: {e}"); + } + } + } + if let Err(e) = pos_tx.send(livepos::Req { id: uid, data: livepos::ReqData::StateDump }) { + println!("{room_id} E: couldn't request position dump for {me}: {e}"); + } + if let Err(e) = cmd_tx.send(MetaMove::Dump) { + println!("{room_id} E: couldn't request game dump for {me}: {e}"); + } + } + } + } + } + } else { + println!("{room_id} E: reached end of stream for {addr}"); + break; + } + } +} + +fn jsonenc_players<'a, I: IntoIterator<Item=&'a Player>>(players: I) -> Result<String, serde_json::Error> { + let mut pairs = Vec::new(); + for player in players { + pairs.push((player.uid, player.name.replace(' ', " "), player.clr.clone())); + } + serde_json::to_string(&pairs) +} diff --git a/src/livepos.rs b/src/livepos.rs new file mode 100644 index 0000000..9112755 --- /dev/null +++ b/src/livepos.rs @@ -0,0 +1,78 @@ +use crate::types::*; +use tokio::sync::mpsc as tokio_mpsc; +use tokio::sync::Mutex; +use std::collections::{HashMap,HashSet}; +use tokio::time::{self, Duration}; +use warp::ws::Message; + +pub enum ReqData { + Pos((usize,usize)), + StateDump, + Quit, +} + +pub struct Req { + pub id: usize, + pub data: ReqData, +} + +pub async fn livepos(players: PlayerMapData, mut recv: tokio_mpsc::UnboundedReceiver<Req>) { + let positions = Mutex::new(HashMap::new()); + let dirty = Mutex::new(HashSet::new()); + let process_upds = async { + while let Some(update) = recv.recv().await { + let mut dirty = dirty.lock().await; + let mut positions = positions.lock().await; + match update.data { + ReqData::Pos(p) => { + let old = positions.get(&update.id).unwrap_or(&(0,0)); + if p != *old { + dirty.insert(update.id); + } + positions.insert(update.id, p); + }, + ReqData::StateDump => { + dirty.clear(); + dirty.extend(positions.keys().copied()); + }, + ReqData::Quit => { + positions.remove(&update.id); + dirty.retain(|x| *x != update.id); + } + } + } + }; + let periodic_send = async { + let mut interv = tokio::time::interval(Duration::from_millis(16)); + interv.set_missed_tick_behavior(time::MissedTickBehavior::Skip); + loop { + interv.tick().await; + let mut dirty = dirty.lock().await; + if dirty.len() > 0 { + let mut positions = positions.lock().await; + let msg = jsonenc_ids(&mut positions, &*dirty).expect("couldn't JSONify player positions"); + dirty.clear(); + let plock = players.read().await; + for player in plock.values() { + if let Err(e) = player.conn.tx.send(Message::text(format!("pos {}", msg))) { + println!("E: couldn't send livepos update to {}: {}", player, e); + } + } + } + } + }; + + tokio::select!( + _ = process_upds => (), + _ = periodic_send => () + ); +} + +fn jsonenc_ids<'a, I: IntoIterator<Item=&'a usize>>(positions: &mut HashMap<usize, (usize,usize)>, ids: I) -> Result<String, serde_json::Error> { + let mut pairs = Vec::new(); + for id in ids { + pairs.push((id, positions[id])); + }; + serde_json::to_string(&pairs) +} + diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..07ba00e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,328 @@ +use std::{ + error::Error, + net::SocketAddr, + sync::Arc, + collections::HashMap, + num::NonZeroUsize, +}; +use futures::stream::StreamExt; + +mod types; +mod livepos; +mod conn; +mod minesweeper; +use types::*; + +use tokio::sync::RwLock; + +const FONT_FILE: &[u8] = include_bytes!("../assets/VT323-Regular.ttf"); +const CONF_FILE: &str = "./conf.json"; + +fn main() -> Result<(), Box<dyn Error>> { + let conf = Config { + cert: "./cert.pem".to_owned(), + pkey: "./cert.rsa".to_owned(), + index_pg: "./assets/index.html".to_owned(), + room_pg: "./assets/room.html".to_owned(), + client_code: "./assets/client.js".to_owned(), + stylesheet: "./assets/style.css".to_owned(), + socket_addr: ([0,0,0,0],31235).into(), + }; + + tokio_main(conf) +} + +#[tokio::main] +async fn tokio_main(conf: Config) -> Result<(), Box<dyn Error>> { + let conf_file: serde_json::Value = serde_json::from_str(&tokio::fs::read_to_string(CONF_FILE).await?)?; + let area_limit: usize = conf_file.get("area_limit") + .expect("no area_limit field in the conf.json file") + .as_u64().expect("area_limit not a number") as usize; + let room_limit: usize = conf_file.get("room_limit") + .expect("no room_limit field in the conf.json file") + .as_u64().expect("room_limit not a number") as usize; + let rooms: RoomMap = Arc::new(RwLock::new(HashMap::new())); + let public_rooms = Arc::new(RwLock::new(HashMap::new())); + use warp::*; + + let index = path::end().and(fs::file(conf.index_pg.clone())); + let style = path!("s.css").and(fs::file(conf.stylesheet.clone())); + let code = path!("c.js").and(fs::file(conf.client_code.clone())); + let font = path!("f.ttf").map(|| FONT_FILE); + let listing = { + let rooms = rooms.clone(); + let pubs = public_rooms.clone(); + path!("rlist").and_then(move || { + let rooms = rooms.clone(); + let pubs = pubs.clone(); + async move { + let roomsl = rooms.read().await; + let pubsl = pubs.read().await; + let rooms_pcount = futures::stream::iter(pubsl.iter()) + .then(|(id, _):(&RoomId,_)| { + let roomsl = roomsl.clone(); + async move { + let room = roomsl.get(id).unwrap().read().await; + let pcount = room.players.read().await.len(); + (id.clone(), (pcount, room.conf.player_cap)) + } + }) + .collect::<HashMap<RoomId,_>>().await; + let resp = (&*pubsl, rooms_pcount); + Ok::<_,std::convert::Infallible>( + reply::json(&resp) + ) + } + }) + }; + let roomspace = { + let rooms = rooms.clone(); + + path!("rspace").and_then(move || { + let r = rooms.clone(); + async move { + let empty_len = empty_rooms(r.clone()).await.len(); + let space = room_limit - r.read().await.len() + empty_len; + Ok::<_,std::convert::Infallible>( + hyper::Response::builder() + .status(hyper::StatusCode::OK) + .body(hyper::Body::from(space.to_string())) + .unwrap() + ) + } + }) + }; + let rform_recv = { + let rooms = rooms.clone(); + let pubs = public_rooms.clone(); + post().and(path("r")).and(body::content_length_limit(4096)).and(body::form()) + .and_then(move |rinfo: HashMap<String, String>| { + println!("{:?}", rinfo); + let rooms = rooms.clone(); + let pubs = pubs.clone(); + async move { + let slots_available = room_limit - rooms.read().await.len(); + let empty = empty_rooms(rooms.clone()).await; + if slots_available < 1 { + if slots_available + empty.len() > 0 { + remove_room(rooms.clone(), pubs.clone(), empty[0].clone()).await; + } else { + return Err(reject::custom(NoRoomSlots)); + } + } + + if let (Some(w),Some(h),Some(num),Some(denom),access,asfm,limit) = ( + rinfo.get("rwidth").and_then(|wt| wt.parse::<NonZeroUsize>().ok()), + rinfo.get("rheight").and_then(|ht| ht.parse::<NonZeroUsize>().ok()), + rinfo.get("rration").and_then(|nt| nt.parse::<usize>().ok()), + rinfo.get("rratiod").and_then(|dt| dt.parse::<NonZeroUsize>().ok()), + rinfo.get("raccess"), + rinfo.get("ralwayssafe1move"), + rinfo.get("rlimit").and_then(|l| l.parse::<usize>().ok()), + ) { + if w.get()*h.get() > area_limit { + return Err(reject::custom(BoardTooBig)) + } + let board_conf = minesweeper::BoardConf { w, h, mine_ratio: (num,denom), always_safe_first_move: asfm.is_some() }; + let mut rooms = rooms.write().await; + let uid = types::RoomId::new_in(&rooms); + let name = { + let n = rinfo.get("rname").unwrap().to_owned(); + if n.is_empty() { uid.to_string() } else { n } + }; + + let players = PlayerMap::default(); + + let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel(); + let game_handle = tokio::spawn(gameloop(cmd_rx, players.clone(), board_conf)); + + let (pos_tx, pos_rx) = tokio::sync::mpsc::unbounded_channel(); + let livepos_handle = tokio::spawn(livepos::livepos(players.clone(), pos_rx)); + + let room_conf = RoomConf { + name, + player_cap: match limit { Some(i) => i, None => usize::MAX }, + public: access.is_some(), + board_conf, + }; + let new_room = Room { + conf: room_conf, + players, + game_driver: game_handle, + cmd_stream: cmd_tx, + livepos_driver: livepos_handle, + pos_stream: pos_tx, + }; + if access.is_some() { + pubs.write().await.insert(uid.clone(), serde_json::to_string(&new_room.conf).unwrap()); + } + rooms.insert(uid.clone(), Arc::new(RwLock::new(new_room))); + + Ok( + hyper::Response::builder() + .status(hyper::StatusCode::SEE_OTHER) + .header(hyper::header::LOCATION, format!("./room/{uid}")) + .body(hyper::Body::empty()) + .unwrap() + ) + } else { Err(reject::custom(BadFormData)) } + } + }) + }; + let room = { + let rooms_ws = rooms.clone(); + let rooms_lobby = rooms.clone(); + let prefix = get().and(path!("room" / String / ..)); + + // Fixme: better errors + prefix.and(path!("ws")) + .and(ws()) + .and(addr::remote()) + .and_then(move |id: String, websocket: warp::ws::Ws, saddr: Option<SocketAddr>| { + let rooms = rooms_ws.clone(); + async move { + let id = RoomId(id); + match rooms.read().await.get(&id).cloned() { + Some(r) => { + println!("{id} I: conn from {saddr:?}"); + Ok(websocket.on_upgrade(move |socket| { + conn::lobby(socket, saddr.expect("socket without address"), (id,r)) + })) + }, + None => { + println!("I: conn from {saddr:?} into inexistent room {id}"); + Err(reject()) + } + } + } + }) + .or(prefix.and(path::end()) + .and(fs::file(conf.room_pg.clone())) + .then(move |id: String, f: fs::File| { + let rooms = rooms_lobby.clone(); + async move { + if rooms.read().await.contains_key(&RoomId(id)) { + f.into_response() + } else { + reply::with_status("No such room", http::StatusCode::BAD_REQUEST).into_response() + } + } + }) + ) + }; + + + let route = get() + .and(index) + .or(style) + .or(code) + .or(font) + .or(listing) + .or(roomspace) + .or(rform_recv) + .or(room) + .recover(error_handler); + + let server = warp::serve(route) + .tls() + .cert_path(conf.cert) + .key_path(conf.pkey) + .run(conf.socket_addr); + println!("Serving on {}", conf.socket_addr); + server.await; + Ok(()) +} + +// If a move is made, broadcast new board, else just send current board +async fn gameloop(mut move_rx: tokio::sync::mpsc::UnboundedReceiver<MetaMove>, players: PlayerMapData, bconf: minesweeper::BoardConf) { + use minesweeper::*; + use flate2::{ Compression, write::DeflateEncoder }; + use std::io::Write; + let mut game = Game::new(bconf); + let mut latest_player_name = None; + while let Some(req) = move_rx.recv().await { + let done = game.phase == Phase::Die || game.phase == Phase::Win; + match req { + MetaMove::Move(m, o) => if !done { + game = game.act(m); + if game.phase == Phase::Win || game.phase == Phase::Die { + game.board = game.board.grade(); + } + latest_player_name = players.read().await.get(&o).map(|p| p.name.clone()); + }, + MetaMove::Dump => (), + MetaMove::Reset => { game = Game::new(bconf); }, + } + use warp::ws::Message; + let mut board_encoder = DeflateEncoder::new(Vec::new(), Compression::default()); + board_encoder.write_all(&game.board.render()).unwrap(); + let compressed_board = board_encoder.finish().unwrap(); + let mut reply = vec![Message::binary(compressed_board)]; + let lpname = latest_player_name.as_deref().unwrap_or("unknown player").replace(' ', " "); + match game.phase { + Phase::Win => { reply.push(Message::text(format!("win {lpname}"))); }, + Phase::Die => { reply.push(Message::text(format!("lose {lpname}"))); }, + _ => (), + } + { + let peers = players.read().await; + for (addr, p) in peers.iter() { + for r in reply.iter() { + if let Err(e) = p.conn.tx.send(r.clone()) { + println!("couldn't send game update {r:?} to {addr}: {e}"); + } + } + } + } + } +} + +use warp::{ reject::{ Reject, Rejection }, reply::{ self, Reply }, http::StatusCode }; +#[derive(Debug)] +struct BadFormData; +impl Reject for BadFormData {} + +#[derive(Debug)] +struct BoardTooBig; +impl Reject for BoardTooBig {} + +#[derive(Debug)] +struct NoRoomSlots; +impl Reject for NoRoomSlots {} + +async fn error_handler(err: Rejection) -> Result<impl Reply, std::convert::Infallible> { + if err.is_not_found() { Ok(reply::with_status("No such file", StatusCode::NOT_FOUND)) } + else if let Some(_e) = err.find::<BadFormData>() { + Ok(reply::with_status("Bad form data", StatusCode::BAD_REQUEST)) + } else if let Some(_e) = err.find::<BoardTooBig>() { + Ok(reply::with_status("Board too big", StatusCode::BAD_REQUEST)) + } else if let Some(_e) = err.find::<NoRoomSlots>() { + Ok(reply::with_status("No more rooms slots", StatusCode::BAD_REQUEST)) + } else { + println!("unhandled rejection: {err:?}"); + Ok(reply::with_status("Server error", StatusCode::INTERNAL_SERVER_ERROR)) + } +} + +async fn empty_rooms(rooms: RoomMap) -> Vec<RoomId> { + let rl = rooms.read().await; + futures::stream::iter(rl.iter()) + .filter_map(|(id,roomarc)| async move { + let rrl = roomarc.read().await; + let rrrl = rrl.players.read().await; + if rrrl.len() == 0 { Some(id.clone()) } else { None } + }) + .collect::<Vec<RoomId>>().await +} + +async fn remove_room<T>(rooms: RoomMap, pubs: Arc<RwLock<HashMap<RoomId,T>>>, id: RoomId) { + { + let mut rwl = rooms.write().await; + rwl.remove(&id); + } + { + let mut pwl = pubs.write().await; + pwl.remove(&id); + } +} + diff --git a/src/minesweeper.rs b/src/minesweeper.rs new file mode 100644 index 0000000..9e362dc --- /dev/null +++ b/src/minesweeper.rs @@ -0,0 +1,286 @@ +use std::{ + convert::TryInto, + num::NonZeroUsize, +}; +use rand::{ thread_rng, Rng, distributions::Uniform }; +use serde::Serialize; + +const HIDDEN_BIT: u8 = 1 << 7; +pub const FLAGGED_BIT: u8 = 1 << 6; +const CORRECT_BIT: u8 = 1 << 5; // grading for a rightly flagged mine +// all the bits that aren't flags +const TILE_NUMBITS: u8 = !(HIDDEN_BIT | FLAGGED_BIT | CORRECT_BIT); +const MINED: u8 = HIDDEN_BIT | TILE_NUMBITS; +const NEIGH_OFFS: &[(isize,isize)] = &[ + (-1,-1),(0,-1),(1,-1), + (-1, 0), (1, 0), + (-1, 1),(0, 1),(1, 1), +]; +#[derive(PartialEq)] +pub enum Phase { + SafeFirstMove, + FirstMoveFail, + Run, + Die, + Win, +// Leave, +} +pub struct Game { + pub phase: Phase, + pub board: Board, + pub board_conf: BoardConf, +} + +#[derive(Debug, Clone, Copy, Serialize)] +pub struct BoardConf { + pub w: NonZeroUsize, + pub h: NonZeroUsize, + /// mines/tiles, expressed as (numerator, denominator) + pub mine_ratio: (usize,NonZeroUsize), + pub always_safe_first_move: bool, +} + +impl std::fmt::Display for BoardConf { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}x{} {}/{}", self.w, self.h, self.mine_ratio.0, self.mine_ratio.1) + } +} + +pub struct Board { + pub data: Vec<u8>, + pub width: NonZeroUsize, + pub height: NonZeroUsize, + pub hidden_tiles: usize, + pub mine_count: usize, +} +#[derive(Debug)] +pub enum MoveType { + Reveal, + ToggleFlag, +} +#[derive(Debug)] +pub struct Move { + pub t: MoveType, + pub pos: (usize,usize), +} + +pub struct MoveResult(pub Board, pub bool); +impl Game { + pub fn new(conf: BoardConf) -> Self { + let board = Board::new(conf); + Game { + phase: if conf.always_safe_first_move { Phase::SafeFirstMove } else { Phase::Run }, + board, + board_conf: conf + } + } + pub fn act(mut self, m: Move) -> Self { + let lost_phase = | phase | { + match phase { + Phase::SafeFirstMove => Phase::FirstMoveFail, + Phase::Run => Phase::Die, + _ => unreachable!(), + } + }; + + match m.t { + MoveType::Reveal => { + let kaboom: bool; + self.board = { + let mr = self.board.reveal(m.pos); + kaboom = mr.1; + mr.0 + }; + if kaboom { self.phase = lost_phase(self.phase) } + if self.phase == Phase::SafeFirstMove { self.phase = Phase::Run } + }, + MoveType::ToggleFlag => self.board = self.board.flag(m.pos).0, + }; + + if self.phase == Phase::FirstMoveFail { + let winnable = self.board.mine_count < (self.board.width.get() * self.board.height.get()); + if winnable { + self.board.move_mine_elsewhere(m.pos); + self.phase = Phase::Run; + self = self.act(m); + } else { + self.phase = Phase::Die; + } + } else if self.phase != Phase::Die && self.board.hidden_tiles == self.board.mine_count { + self.phase = Phase::Win; + } + self + } +} +impl Board { + pub fn new(conf: BoardConf) -> Self { + let (w,h) = (conf.w,conf.h); + let area = w.get()*h.get(); + let mine_count = ((conf.mine_ratio.0 * area) / conf.mine_ratio.1.get()).clamp(0, area); + let b = Board { + data: [HIDDEN_BIT].repeat(area), + width: w, + height: h, + hidden_tiles: area, + mine_count, + }; + b.spread_mines(mine_count) + } + pub fn spread_mines(mut self, mut count: usize) -> Self { + let mut rng = thread_rng(); + let w = self.width.get(); + let h = self.height.get(); + while count > 0 { + let randpos: (usize, usize) = (rng.sample(Uniform::new(0,w)), rng.sample(Uniform::new(0,h))); + let o = self.pos_to_off_unchecked(randpos); + if self.data[o] == MINED { continue } + else { + self.data[o] = MINED; + count -= 1; + let minepos = pos_u2i(randpos).unwrap(); + self.map_neighs(minepos, |neigh| { + if neigh != MINED { + neigh + 1 + } else { neigh } + }); + } + } + self + } + + fn neighs<T>(&self, pos: (T,T)) -> Option<Vec<(usize,usize)>> + where T: TryInto<isize> + { + if let (Ok(ox),Ok(oy)) = (pos.0.try_into(),pos.1.try_into()) { + Some(NEIGH_OFFS + .iter() + .map(|(x,y)| (*x + ox, *y + oy)).filter_map(|p| self.bounded(p)) + .collect()) + } else { + None + } + } + fn map_neighs<T>(&mut self, pos: (T,T), mut f: impl FnMut(u8) -> u8) where T: TryInto<isize> { + if let Some(neighs) = self.neighs(pos) { + let npos = neighs.iter().filter_map(|pos| self.pos_to_off(*pos)).collect::<Vec<usize>>(); + npos.iter().for_each(|o| { + self.data[*o] = f(self.data[*o]); + }); + } + } + + pub fn pos_to_off(&self, pos: (usize,usize)) -> Option<usize> + { + self.bounded(pos).map(|x| self.pos_to_off_unchecked(x)) + } + pub fn pos_to_off_unchecked(&self, pos: (usize, usize)) -> usize { + pos.0 + pos.1 * self.width.get() + } + pub fn bounded<T>(&self, pos: (T,T)) -> Option<(usize, usize)> + where T: TryInto<usize> + { + if let (Ok(x),Ok(y)) = ( + pos.0.try_into(), + pos.1.try_into(), + ) { + (x < self.width.get() && y < self.height.get()).then(|| (x,y)) + } else { None } + } + pub fn flood_reveal(&mut self, pos: (usize,usize)) { + let mut queue = vec![pos]; + while let Some(pos) = queue.pop() { + if let Some(off) = self.pos_to_off(pos) { + let c = &mut self.data[off]; + if *c & HIDDEN_BIT > 0 { + *c &= !(HIDDEN_BIT | FLAGGED_BIT); + self.hidden_tiles -= 1; + if *c > 0 { continue; } + if let Some(mut adj) = self.neighs(pos) { + queue.append(&mut adj); + } + } + } + } + } + pub fn reveal(mut self, pos: (usize,usize)) -> MoveResult { + if let Some(off) = self.pos_to_off(pos) { + self.flood_reveal(pos); + let c = self.data[off]; + MoveResult(self, (c & !(FLAGGED_BIT | CORRECT_BIT)) == TILE_NUMBITS) + } else { + MoveResult(self, false) + } + } + pub fn grade(mut self) -> Board { + for i in &mut self.data { + if *i == TILE_NUMBITS | FLAGGED_BIT | HIDDEN_BIT { + *i |= CORRECT_BIT; + } + } + self + } + pub fn flag(mut self, pos: (usize,usize)) -> MoveResult { + if let Some(off) = self.pos_to_off(pos) { + self.data[off] ^= FLAGGED_BIT; + } + MoveResult(self, false) + } + + pub fn render(&self) -> Vec<u8> { + let mut ret = vec![]; + for y in 0..self.height.get() { + for x in 0..self.width.get() { + let c = &self.data[self.pos_to_off_unchecked((x,y))]; + match *c { + 0 => ret.push(b' '), + _ if *c <= 8 => ret.push(b'0' + c), + _ if (*c & CORRECT_BIT) > 0 => ret.push(b'C'), + _ if (*c & FLAGGED_BIT) > 0 => ret.push(b'F'), + _ if (*c & HIDDEN_BIT) > 0 => ret.push(b'#'), + _ if *c == TILE_NUMBITS => ret.push(b'O'), + _ => ret.push(b'?'), + } + } + ret.extend_from_slice(b"<br>"); + } + ret + } + + pub fn move_mine_elsewhere(&mut self, pos: (usize, usize)) { + let mut surround_count = 0; + self.map_neighs(pos, |val| { + if (val & !FLAGGED_BIT) == MINED { + surround_count += 1; + val + } else { + val - 1 + }}); + let off = self.pos_to_off(pos).unwrap(); + let vacant_pos = { + let v = self.data.iter() + .enumerate() + .filter(|(_,val)| (*val & TILE_NUMBITS) != TILE_NUMBITS) + .map(|(p,_)| p) + .next() + .unwrap(); // there must be at least one + (v%self.width.get(), v/self.width.get()) + }; + let voff = self.pos_to_off_unchecked(vacant_pos); + debug_assert!(voff != off, "swapped mine to the same position in a FirstMoveFail/grace'd first move (???)"); + + { // swap 'em (keep these together, pls kthnx (bugs were had)) + self.data[voff] |= MINED; + self.data[off] = surround_count; + } + + self.map_neighs(vacant_pos, |val| { + if (val & !FLAGGED_BIT) == MINED { val } else { val + 1 } + }); + } +} + +fn pos_u2i(pos: (usize, usize)) -> Option<(isize, isize)> { + if let (Ok(x),Ok(y)) = (pos.0.try_into(), pos.1.try_into()) + { Some((x,y)) } else { None } +} + diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..f9f166a --- /dev/null +++ b/src/types.rs @@ -0,0 +1,135 @@ +use std::{ + collections::HashMap, + net::SocketAddr, + sync::{ + Arc, + atomic::{ AtomicUsize, Ordering }, + }, + fmt::Display, + ops::{ Deref, DerefMut }, +}; +use warp::ws::Message; +use tokio::sync::RwLock; +use serde::Serialize; +use crate::minesweeper; +use crate::livepos; + +#[derive(Debug, Clone)] +pub struct Config { + pub cert: String, + pub pkey: String, + pub index_pg: String, + pub room_pg: String, + pub client_code: String, + pub stylesheet: String, + pub socket_addr: SocketAddr, +} + +#[derive(Debug, Serialize, Clone)] +pub struct RoomConf { + pub name: String, + pub player_cap: usize, + pub public: bool, + pub board_conf: minesweeper::BoardConf, +} + +pub struct Room { + pub conf: RoomConf, + pub players: PlayerMap, + pub game_driver: tokio::task::JoinHandle<()>, + pub cmd_stream: CmdTx, + pub livepos_driver: tokio::task::JoinHandle<()>, + pub pos_stream: tokio::sync::mpsc::UnboundedSender<livepos::Req>, +} + +#[derive(Debug)] +pub enum MetaMove { + Move(minesweeper::Move,SocketAddr), + Dump, + Reset, +} + +#[derive(Debug)] +pub struct Conn { + pub tx: tokio::sync::mpsc::UnboundedSender<Message>, + pub addr: SocketAddr, +} + +#[derive(Debug)] +pub struct Player { + pub conn: Conn, + pub uid: usize, + pub name: String, + pub clr: String, +} + +impl Display for Player { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "\"{}\"@{}", self.name, self.conn.addr) + } +} + +#[derive(Eq, PartialEq, Hash, Debug, Clone, serde::Serialize)] +pub struct RoomId(pub String); +impl Display for RoomId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} +impl std::borrow::Borrow<str> for RoomId { + fn borrow(&self) -> &str { + self.0.borrow() + } +} + +impl RoomId { + pub fn new_in<T>(map: &HashMap<RoomId, T>) -> Self { + use rand::{ thread_rng, Rng, distributions::Alphanumeric }; + let id = RoomId(thread_rng() + .sample_iter(&Alphanumeric) + .take(16) + .map(char::from) + .collect::<String>()); + if map.contains_key(&id) { RoomId::new_in(map) } + else { id } + } +} + +pub type CmdTx = tokio::sync::mpsc::UnboundedSender<MetaMove>; +pub type RoomMap = Arc<RwLock<HashMap<RoomId, Arc<RwLock<Room>>>>>; +pub type PlayerMapData = Arc<RwLock<HashMap<SocketAddr, Player>>>; +#[derive(Debug)] +pub struct PlayerMap { + inner: PlayerMapData, + uid_counter: AtomicUsize, +} + +impl Deref for PlayerMap { + type Target = Arc<RwLock<HashMap<SocketAddr, Player>>>; + fn deref(&self) -> &Self::Target { + &self.inner + } +} +impl DerefMut for PlayerMap { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} +impl Default for PlayerMap { + fn default() -> Self { + Self { inner: Arc::new(RwLock::new(HashMap::new())), uid_counter: 0.into() } + } +} + +impl PlayerMap { + pub async fn insert_conn(&mut self, conn: Conn, name: String, clr: String) -> usize { + let mut map = self.write().await; + let uid = self.uid_counter.fetch_add(1, Ordering::Relaxed); + map.insert( + conn.addr, + Player { conn, uid, name, clr }, + ); + uid + } +} + |