diff --git a/backend/manifest.toml b/backend/manifest.toml index fa20cb1..61b3d50 100644 --- a/backend/manifest.toml +++ b/backend/manifest.toml @@ -24,7 +24,7 @@ packages = [ { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "mist", version = "4.0.7", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "F7D15A1E3232E124C7CE31900253633434E59B34ED0E99F273DEE61CDB573CDD" }, { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, - { name = "shared", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../shared" }, + { name = "shared", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_json", "gleam_stdlib"], source = "local", path = "../shared" }, { name = "simplifile", version = "2.2.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "C88E0EE2D509F6D86EB55161D631657675AA7684DAB83822F7E59EB93D9A60E3" }, { name = "snag", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "7E9F06390040EB5FAB392CE642771484136F2EC103A92AE11BA898C8167E6E17" }, { name = "storail", version = "3.0.0", build_tools = ["gleam"], requirements = ["directories", "filepath", "gleam_crypto", "gleam_json", "gleam_stdlib", "simplifile"], otp_app = "storail", source = "hex", outer_checksum = "D032EE5C89AA4B6306FF81929BF9B5DD2583E9F743F0047788AE5F1F52AFE3B4" }, diff --git a/backend/src/app/router.gleam b/backend/src/app/router.gleam index 0a64d0c..e0b5da4 100644 --- a/backend/src/app/router.gleam +++ b/backend/src/app/router.gleam @@ -3,7 +3,7 @@ import gleam/bit_array import gleam/bytes_tree import gleam/crypto import gleam/dynamic/decode -import gleam/erlang/process +import gleam/erlang/process.{type Subject} import gleam/float import gleam/http/request.{type Request} import gleam/http/response.{type Response} @@ -11,7 +11,7 @@ import gleam/int import gleam/io import gleam/json import gleam/list -import gleam/option.{None, Some} +import gleam/option.{type Option, None, Some} import gleam/otp/actor import gleam/result import gleam/string @@ -19,6 +19,7 @@ import mist.{type Connection, type ResponseData} import player import player_session import session +import socket_manager import storail pub fn handle_request( @@ -55,14 +56,39 @@ fn serve_wasm( }) } +pub type SocketState { + SocketState( + ctx: web.Context, + id: Int, + subject: Subject(web.GameResponse), + user_id: Option(Int), + game_id: Option(String), + ) +} + fn serve_websocket( ctx: web.Context, req: Request(Connection), ) -> Response(ResponseData) { mist.websocket( request: req, - on_init: fn(_conn) { #(ctx, Some(ctx.selector)) }, - on_close: fn(_state) { io.println("goodbye!") }, + on_init: fn(_conn) { + let self = process.new_subject() + + let selector = + process.new_selector() + |> process.selecting(self, fn(x) { x }) + + let id = socket_manager.add_socket_subj(ctx.socket_manager, self) + + let state = + SocketState(ctx:, id:, subject: self, user_id: None, game_id: None) + #(state, Some(selector)) + }, + on_close: fn(state) { + io.println("Shutting down socket" <> int.to_string(state.id)) + socket_manager.remove_socket_subj(state.ctx.socket_manager, state.subject) + }, handler: handle_ws_message, ) } @@ -80,7 +106,11 @@ fn decode_errors_to_string(decode_errs: List(decode.DecodeError)) -> String { string.join(list.map(decode_errs, decode_error_to_string), "\n") } -fn handle_ws_message(state, conn, message) { +fn handle_ws_message( + state: SocketState, + conn, + message: mist.WebsocketMessage(web.GameResponse), +) { io.println("Got WS message") case message { mist.Text("ping") -> { @@ -90,7 +120,7 @@ fn handle_ws_message(state, conn, message) { } mist.Text(msg) -> { echo msg - case json.parse(msg, web.client_request_decoder()) { + let state = case json.parse(msg, web.client_request_decoder()) { Error(err) -> { let err_msg = case err { json.UnableToDecode(decode_err) -> @@ -102,16 +132,17 @@ fn handle_ws_message(state, conn, message) { "Unexpected Sequence: " <> seq_msg } io.println("Could not parse client message: " <> err_msg) + state } Ok(req) -> { - let resp = handle_client_msg(state, req) + let #(state, resp) = handle_client_msg(state, req) echo resp let resp_str = web.encode_game_response(resp) |> json.to_string let assert Ok(_) = mist.send_text_frame(conn, resp_str) - Nil + state } } @@ -120,7 +151,12 @@ fn handle_ws_message(state, conn, message) { mist.Binary(_) -> { actor.continue(state) } - mist.Custom(_) -> { + mist.Custom(resp) -> { + echo resp + let resp_str = + web.encode_game_response(resp) + |> json.to_string + let assert Ok(_) = mist.send_text_frame(conn, resp_str) actor.continue(state) } mist.Closed | mist.Shutdown -> actor.Stop(process.Normal) @@ -128,7 +164,7 @@ fn handle_ws_message(state, conn, message) { } fn register_user( - ctx: web.Context, + state: SocketState, username: String, gamecode: String, ) -> #(String, Int) { @@ -139,40 +175,40 @@ fn register_user( |> crypto.hash_chunk(bit_array.from_string(username)) |> crypto.hash_chunk(bit_array.from_string(gamecode)) |> crypto.digest - - let token_hash = - crypto.new_hasher(crypto.Sha512) - |> crypto.hash_chunk(token) - |> crypto.digest |> bit_array.base64_encode(True) + let token_hash = player_session.hash_token(token) + let assert <> = crypto.strong_random_bytes(8) - let key = storail.key(ctx.player_sessions, int.to_base16(user_id)) + let key = storail.key(state.ctx.player_sessions, int.to_base16(user_id)) let assert Ok(Nil) = storail.write( key, - player_session.PlayerSession(id: user_id, token_hash:, username:), + player_session.PlayerSession( + id: user_id, + token_hash:, + username:, + socket_id: state.id, + ), ) - let token = bit_array.base64_encode(token, True) - #(token, user_id) } fn handle_client_msg( - ctx: web.Context, + state: SocketState, req: web.ClientRequest, -) -> web.GameResponse { +) -> #(SocketState, web.GameResponse) { case req.msg { web.BuzzIn(time) -> { io.println("Got buzz in @ " <> float.to_string(time)) - web.AckBuzzer + #(state, web.AckBuzzer) } web.Register(gamecode, username) -> { - let #(token, user_id) = register_user(ctx, username, gamecode) - let key = storail.key(ctx.sessions, gamecode) + let #(token, user_id) = register_user(state, username, gamecode) + let key = storail.key(state.ctx.sessions, gamecode) let assert Ok(session) = storail.read(key) let player = player.Player(name: username, id: user_id, score: 0) @@ -184,16 +220,54 @@ fn handle_client_msg( let assert Ok(Nil) = storail.write(key, session) - web.JoinResponse(username, token) + #( + SocketState(..state, user_id: Some(user_id), game_id: Some(gamecode)), + web.JoinResponse(username, token), + ) } web.CreateRoom(gamecode, username) -> { - let #(token, user_id) = register_user(ctx, username, gamecode) + let #(token, user_id) = register_user(state, username, gamecode) - let key = storail.key(ctx.sessions, gamecode) + let key = storail.key(state.ctx.sessions, gamecode) let session = session.Session(gamecode, user_id, []) let assert Ok(Nil) = storail.write(key, session) - web.HostResponse(username, token) + #( + SocketState(..state, user_id: Some(user_id), game_id: Some(gamecode)), + web.HostResponse(username, token), + ) + } + web.ResetBuzzers -> { + let _token_hash = player_session.hash_token(req.token) + let assert Some(user_id) = state.user_id + let assert Some(session_id) = state.game_id + + let player_key = + storail.key(state.ctx.player_sessions, int.to_base16(user_id)) + + let assert Ok(_user) = storail.read(player_key) + + let session_key = storail.key(state.ctx.sessions, session_id) + let assert Ok(session) = storail.read(session_key) + + list.map(session.players, fn(player) { player.id }) + |> list.map(fn(player_id) { + let player_key = + storail.key(state.ctx.player_sessions, int.to_base16(player_id)) + let assert Ok(player) = storail.read(player_key) + player + }) + |> list.map(fn(player) { + let assert Ok(subject) = + socket_manager.get_socket_subj_by_id( + state.ctx.socket_manager, + player.socket_id, + ) + subject + }) + |> list.each(fn(subject) { actor.send(subject, web.ResetBuzzer) }) + + #(state, web.AckBuzzer) } } } diff --git a/backend/src/app/web.gleam b/backend/src/app/web.gleam index 6968531..f8a20dd 100644 --- a/backend/src/app/web.gleam +++ b/backend/src/app/web.gleam @@ -1,39 +1,18 @@ import config import gleam/dynamic/decode -import gleam/erlang/process import gleam/json import player_session import session +import socket_manager import storail pub type ClientMessage { BuzzIn(time: Float) + ResetBuzzers Register(game_code: String, username: String) CreateRoom(game_code: String, username: String) } -pub fn encode_client_message(client_message: ClientMessage) -> json.Json { - case client_message { - BuzzIn(..) -> - json.object([ - #("type", json.string("buzz_in")), - #("time", json.float(client_message.time)), - ]) - Register(..) -> - json.object([ - #("type", json.string("register")), - #("game_code", json.string(client_message.game_code)), - #("username", json.string(client_message.username)), - ]) - CreateRoom(..) -> - json.object([ - #("type", json.string("create_room")), - #("game_code", json.string(client_message.game_code)), - #("username", json.string(client_message.username)), - ]) - } -} - pub fn client_message_decoder() -> decode.Decoder(ClientMessage) { use variant <- decode.field("type", decode.string) case variant { @@ -51,6 +30,9 @@ pub fn client_message_decoder() -> decode.Decoder(ClientMessage) { use username <- decode.field("username", decode.string) decode.success(CreateRoom(game_code:, username:)) } + "ResetBuzzers" -> { + decode.success(ResetBuzzers) + } _ -> decode.failure(BuzzIn(0.0), "ClientMessage") } } @@ -102,6 +84,6 @@ pub type Context { config: config.Config, sessions: storail.Collection(session.Session), player_sessions: storail.Collection(player_session.PlayerSession), - selector: process.Selector(ClientRequest), + socket_manager: socket_manager.SocketManager(GameResponse), ) } diff --git a/backend/src/backend.gleam b/backend/src/backend.gleam index a616a38..1efadcd 100644 --- a/backend/src/backend.gleam +++ b/backend/src/backend.gleam @@ -8,6 +8,7 @@ import glint import mist import player_session import session +import socket_manager import storail pub type Error { @@ -65,7 +66,7 @@ pub fn run_server() -> glint.Command(Result(Nil, Error)) { config: cfg, sessions: setup_session_collection(storail_cfg), player_sessions: setup_player_session_collection(storail_cfg), - selector: process.new_selector(), + socket_manager: socket_manager.new(), ) let handler = router.handle_request(_, ctx) diff --git a/backend/src/socket_manager.gleam b/backend/src/socket_manager.gleam new file mode 100644 index 0000000..7027ea8 --- /dev/null +++ b/backend/src/socket_manager.gleam @@ -0,0 +1,89 @@ +import gleam/dict.{type Dict} +import gleam/erlang/process.{type Subject} +import gleam/list +import gleam/otp/actor + +pub type SocketManager(a) { + SocketManager(subject: Subject(SocketManagerMsg(a))) +} + +type State(a) { + State(subjects: Dict(Int, Subject(a)), next_id: Int) +} + +pub type SocketManagerMsg(a) { + AddSocketSubject(subject: Subject(a), client: Subject(Int)) + RemoveSocketSubject(subject: Subject(a)) + GetSubjectById(id: Int, client: Subject(Result(Subject(a), Nil))) + Shutdown +} + +fn handle( + msg: SocketManagerMsg(a), + state: State(a), +) -> actor.Next(SocketManagerMsg(a), State(a)) { + case msg { + AddSocketSubject(subject, client) -> { + let subject_id = state.next_id + actor.send(client, subject_id) + actor.continue(State( + dict.insert(state.subjects, state.next_id, subject), + state.next_id + 1, + )) + } + RemoveSocketSubject(subject) -> { + let remove_id = + list.first( + dict.keys( + dict.filter(state.subjects, fn(_key, value) { value == subject }), + ), + ) + + case remove_id { + Ok(id) -> { + actor.continue( + State(..state, subjects: dict.delete(state.subjects, id)), + ) + } + Error(_) -> { + actor.continue(state) + } + } + } + GetSubjectById(id, client) -> { + actor.send(client, dict.get(state.subjects, id)) + actor.continue(state) + } + Shutdown -> { + actor.Stop(process.Normal) + } + } +} + +pub fn new() -> SocketManager(a) { + let assert Ok(subject) = actor.start(State(dict.new(), 0), handle) + + SocketManager(subject) +} + +pub fn add_socket_subj( + socket_manager: SocketManager(a), + subject: Subject(a), +) -> Int { + actor.call(socket_manager.subject, AddSocketSubject(subject, _), 1000) +} + +pub fn get_socket_subj_by_id( + socket_manager: SocketManager(a), + id: Int, +) -> Result(Subject(a), Nil) { + actor.call(socket_manager.subject, GetSubjectById(id, _), 1000) +} + +pub fn remove_socket_subj(socket_manager: SocketManager(a), subject: Subject(a)) { + actor.send(socket_manager.subject, RemoveSocketSubject(subject)) +} + +pub fn shutdown(socket_manager: SocketManager(a)) { + actor.send(socket_manager.subject, Shutdown) +} diff --git a/frontend/src/host_screen.rs b/frontend/src/host_screen.rs index 2a5e1ad..cb17bbc 100644 --- a/frontend/src/host_screen.rs +++ b/frontend/src/host_screen.rs @@ -1,16 +1,12 @@ -use macroquad::{ - color::{BLACK, WHITE}, - text::draw_text, - window::{clear_background, screen_height, screen_width}, -}; +use macroquad::prelude::*; -use crate::screen::Screen; +use crate::{model::ClientMessage, screen::Screen}; #[derive(Default)] pub struct HostScreen {} impl Screen for HostScreen { - fn handle_frame(&mut self, _ctx: &mut crate::context::Context) { + fn handle_frame(&mut self, ctx: &mut crate::context::Context) { clear_background(WHITE); draw_text( @@ -20,6 +16,25 @@ impl Screen for HostScreen { 100.0, BLACK, ); + + let button_size = screen_width() * 0.01; + let button_location = Vec2::new(screen_width() * 0.50, screen_height() * 0.70); + + draw_circle( + button_location.x, + button_location.y, + button_size * 1.1, + DARKBROWN, + ); + draw_circle(button_location.x, button_location.y, button_size, RED); + if is_mouse_button_pressed(MouseButton::Left) { + let loc = Vec2::from(mouse_position()); + let normalize = loc - button_location; + + if normalize.length() < button_size { + ctx.send_msg(&ClientMessage::ResetBuzzers); + } + } } fn handle_messages( diff --git a/frontend/src/model/mod.rs b/frontend/src/model/mod.rs index 85f5cfd..dbed45f 100644 --- a/frontend/src/model/mod.rs +++ b/frontend/src/model/mod.rs @@ -11,6 +11,7 @@ pub enum ClientMessage { BuzzIn(BuzzIn), Register(Register), CreateRoom(Register), + ResetBuzzers, } #[derive(Debug, Serialize)] diff --git a/shared/gleam.toml b/shared/gleam.toml index aebaef9..fb1ed0b 100644 --- a/shared/gleam.toml +++ b/shared/gleam.toml @@ -15,6 +15,7 @@ version = "1.0.0" [dependencies] gleam_stdlib = ">= 0.44.0 and < 2.0.0" gleam_json = ">= 2.3.0 and < 3.0.0" +gleam_crypto = ">= 1.5.0 and < 2.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/shared/manifest.toml b/shared/manifest.toml index 9e7a76b..e416c2c 100644 --- a/shared/manifest.toml +++ b/shared/manifest.toml @@ -2,12 +2,14 @@ # You typically do not need to edit this file packages = [ + { name = "gleam_crypto", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "917BC8B87DBD584830E3B389CBCAB140FFE7CB27866D27C6D0FB87A9ECF35602" }, { name = "gleam_json", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "C55C5C2B318533A8072D221C5E06E5A75711C129E420DD1CE463342106012E5D" }, { name = "gleam_stdlib", version = "0.59.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "F8FEE9B35797301994B81AF75508CF87C328FE1585558B0FFD188DC2B32EAA95" }, { name = "gleeunit", version = "1.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "A7DD6C07B7DA49A6E28796058AA89E651D233B357D5607006D70619CD89DAAAB" }, ] [requirements] +gleam_crypto = { version = ">= 1.5.0 and < 2.0.0" } gleam_json = { version = ">= 2.3.0 and < 3.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/shared/src/player_session.gleam b/shared/src/player_session.gleam index 92b6b40..fc1a632 100644 --- a/shared/src/player_session.gleam +++ b/shared/src/player_session.gleam @@ -1,8 +1,10 @@ +import gleam/bit_array +import gleam/crypto import gleam/dynamic/decode import gleam/json pub type PlayerSession { - PlayerSession(id: Int, token_hash: String, username: String) + PlayerSession(id: Int, token_hash: String, username: String, socket_id: Int) } pub fn encode_player_session(player_session: PlayerSession) -> json.Json { @@ -10,6 +12,7 @@ pub fn encode_player_session(player_session: PlayerSession) -> json.Json { #("id", json.int(player_session.id)), #("token_hash", json.string(player_session.token_hash)), #("username", json.string(player_session.username)), + #("socket_id", json.int(player_session.socket_id)), ]) } @@ -17,5 +20,14 @@ pub fn player_session_decoder() -> decode.Decoder(PlayerSession) { use id <- decode.field("id", decode.int) use token_hash <- decode.field("token_hash", decode.string) use username <- decode.field("username", decode.string) - decode.success(PlayerSession(id:, token_hash:, username:)) + use socket_id <- decode.field("socket_id", decode.int) + decode.success(PlayerSession(id:, token_hash:, username:, socket_id:)) +} + +pub fn hash_token(token: String) -> String { + let token = + crypto.new_hasher(crypto.Sha512) + |> crypto.hash_chunk(bit_array.from_string(token)) + |> crypto.digest + |> bit_array.base64_encode(True) }