summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorstale <redkugelblitzin@gmail.com>2022-07-01 15:15:05 -0300
committerstale <redkugelblitzin@gmail.com>2022-07-01 15:15:05 -0300
commitc68dc16bef476203a4424c4b857f16eeb8f0e119 (patch)
tree2f3fe4580f5b6d0f798aad6a9d680f44556e7f55 /src
initial commit, but with scarequotes
Diffstat (limited to 'src')
-rw-r--r--src/conn.rs191
-rw-r--r--src/livepos.rs78
-rw-r--r--src/main.rs328
-rw-r--r--src/minesweeper.rs286
-rw-r--r--src/types.rs135
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(' ', "&nbsp;"), name.replace(' ', "&nbsp;"), 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(' ', "&nbsp"), 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(' ', "&nbsp");
+ 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
+ }
+}
+