diff --git a/backend/.gitignore b/backend/.gitignore index 903d5e4..5ed656c 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -3,3 +3,4 @@ /build erl_crash.dump config.toml +/store diff --git a/backend/src/app/router.gleam b/backend/src/app/router.gleam index 22b6ca8..0a64d0c 100644 --- a/backend/src/app/router.gleam +++ b/backend/src/app/router.gleam @@ -7,6 +7,7 @@ import gleam/erlang/process import gleam/float import gleam/http/request.{type Request} import gleam/http/response.{type Response} +import gleam/int import gleam/io import gleam/json import gleam/list @@ -15,6 +16,10 @@ import gleam/otp/actor import gleam/result import gleam/string import mist.{type Connection, type ResponseData} +import player +import player_session +import session +import storail pub fn handle_request( req: Request(Connection), @@ -122,8 +127,42 @@ fn handle_ws_message(state, conn, message) { } } +fn register_user( + ctx: web.Context, + username: String, + gamecode: String, +) -> #(String, Int) { + let secret = crypto.strong_random_bytes(16) + let token = + crypto.new_hasher(crypto.Sha512) + |> crypto.hash_chunk(secret) + |> 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 assert <> = crypto.strong_random_bytes(8) + + let key = storail.key(ctx.player_sessions, int.to_base16(user_id)) + + let assert Ok(Nil) = + storail.write( + key, + player_session.PlayerSession(id: user_id, token_hash:, username:), + ) + + let token = bit_array.base64_encode(token, True) + + #(token, user_id) +} + fn handle_client_msg( - _ctx: web.Context, + ctx: web.Context, req: web.ClientRequest, ) -> web.GameResponse { case req.msg { @@ -131,25 +170,30 @@ fn handle_client_msg( io.println("Got buzz in @ " <> float.to_string(time)) web.AckBuzzer } - web.Register(username, gamecode) -> { - let secret = crypto.strong_random_bytes(16) - let token = - crypto.new_hasher(crypto.Sha512) - |> crypto.hash_chunk(secret) - |> crypto.hash_chunk(bit_array.from_string(username)) - |> crypto.hash_chunk(bit_array.from_string(gamecode)) - |> crypto.digest + web.Register(gamecode, username) -> { + let #(token, user_id) = register_user(ctx, username, gamecode) + let key = storail.key(ctx.sessions, gamecode) + let assert Ok(session) = storail.read(key) - let token_hash = - crypto.new_hasher(crypto.Sha512) - |> crypto.hash_chunk(token) - |> crypto.digest + let player = player.Player(name: username, id: user_id, score: 0) + let session = + session.Session( + ..session, + players: list.append(session.players, [player]), + ) - io.println("New token: " <> bit_array.base64_encode(token_hash, True)) - - let token = bit_array.base64_encode(token, True) + let assert Ok(Nil) = storail.write(key, session) web.JoinResponse(username, token) } + web.CreateRoom(gamecode, username) -> { + let #(token, user_id) = register_user(ctx, username, gamecode) + + let key = storail.key(ctx.sessions, gamecode) + let session = session.Session(gamecode, user_id, []) + let assert Ok(Nil) = storail.write(key, session) + + web.HostResponse(username, token) + } } } diff --git a/backend/src/app/web.gleam b/backend/src/app/web.gleam index 663d359..6968531 100644 --- a/backend/src/app/web.gleam +++ b/backend/src/app/web.gleam @@ -2,15 +2,39 @@ import config import gleam/dynamic/decode import gleam/erlang/process import gleam/json +import player_session import session import storail pub type ClientMessage { BuzzIn(time: Float) Register(game_code: String, username: String) + CreateRoom(game_code: String, username: String) } -fn client_message_decoder() -> decode.Decoder(ClientMessage) { +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 { "BuzzIn" -> { @@ -22,7 +46,12 @@ fn client_message_decoder() -> decode.Decoder(ClientMessage) { use username <- decode.field("username", decode.string) decode.success(Register(game_code:, username:)) } - _ -> decode.failure(BuzzIn(time: 0.0), "ClientMessage") + "CreateRoom" -> { + use game_code <- decode.field("game_code", decode.string) + use username <- decode.field("username", decode.string) + decode.success(CreateRoom(game_code:, username:)) + } + _ -> decode.failure(BuzzIn(0.0), "ClientMessage") } } @@ -38,6 +67,7 @@ pub fn client_request_decoder() -> decode.Decoder(ClientRequest) { pub type GameResponse { JoinResponse(username: String, token: String) + HostResponse(username: String, token: String) AckBuzzer ResetBuzzer UpdatePointTotal(score: Int) @@ -51,6 +81,12 @@ pub fn encode_game_response(game_response: GameResponse) -> json.Json { #("username", json.string(game_response.username)), #("token", json.string(game_response.token)), ]) + HostResponse(..) -> + json.object([ + #("type", json.string("HostResponse")), + #("username", json.string(game_response.username)), + #("token", json.string(game_response.token)), + ]) AckBuzzer -> json.object([#("type", json.string("AckBuzzer"))]) ResetBuzzer -> json.object([#("type", json.string("ResetBuzzer"))]) UpdatePointTotal(..) -> @@ -65,6 +101,7 @@ pub type Context { Context( config: config.Config, sessions: storail.Collection(session.Session), + player_sessions: storail.Collection(player_session.PlayerSession), selector: process.Selector(ClientRequest), ) } diff --git a/backend/src/backend.gleam b/backend/src/backend.gleam index c5e9048..a616a38 100644 --- a/backend/src/backend.gleam +++ b/backend/src/backend.gleam @@ -6,6 +6,7 @@ import gleam/erlang/process import gleam/result import glint import mist +import player_session import session import storail @@ -30,6 +31,17 @@ pub fn setup_session_collection( ) } +pub fn setup_player_session_collection( + config: storail.Config, +) -> storail.Collection(player_session.PlayerSession) { + storail.Collection( + name: "PlayerSessions", + to_json: player_session.encode_player_session, + decoder: player_session.player_session_decoder(), + config:, + ) +} + pub fn run_server() -> glint.Command(Result(Nil, Error)) { use <- glint.command_help("Runs the backend of Play of the Game") use _, args, _ <- glint.command() @@ -52,6 +64,7 @@ pub fn run_server() -> glint.Command(Result(Nil, Error)) { web.Context( config: cfg, sessions: setup_session_collection(storail_cfg), + player_sessions: setup_player_session_collection(storail_cfg), selector: process.new_selector(), ) diff --git a/frontend/src/host_screen.rs b/frontend/src/host_screen.rs new file mode 100644 index 0000000..2a5e1ad --- /dev/null +++ b/frontend/src/host_screen.rs @@ -0,0 +1,31 @@ +use macroquad::{ + color::{BLACK, WHITE}, + text::draw_text, + window::{clear_background, screen_height, screen_width}, +}; + +use crate::screen::Screen; + +#[derive(Default)] +pub struct HostScreen {} + +impl Screen for HostScreen { + fn handle_frame(&mut self, _ctx: &mut crate::context::Context) { + clear_background(WHITE); + + draw_text( + "I'm the host!", + screen_width() / 2.0, + screen_height() / 2.0, + 100.0, + BLACK, + ); + } + + fn handle_messages( + &mut self, + _ctx: &mut crate::context::Context, + ) -> Option { + None + } +} diff --git a/frontend/src/login_screen.rs b/frontend/src/login_screen.rs index 09669db..4f969ee 100644 --- a/frontend/src/login_screen.rs +++ b/frontend/src/login_screen.rs @@ -11,6 +11,7 @@ pub struct LoginScreen { new_username: String, new_room_code: String, sent_join: bool, + sent_host: bool, retry_count: usize, error_msg: bool, } @@ -23,6 +24,8 @@ impl Screen for LoginScreen { let window_size = Vec2::new(screen_width() * 0.25, screen_height() * 0.25); let mut pressed_join = false; + let mut pressed_host = false; + root_ui().window( hash!("Window"), Vec2::new( @@ -47,6 +50,16 @@ impl Screen for LoginScreen { pressed_join = true; } }); + ui.group(hash!("Host Group"), group_size, |ui| { + if ui.button(Vec2::new(group_size.x * 0.45, 0.0), "Host Game") { + info!( + "User pressed host with name={} room_code={}", + self.new_username, self.new_room_code + ); + + pressed_host = true; + } + }); if self.error_msg { ui.group(hash!("Error Group"), group_size, |ui| { @@ -64,16 +77,37 @@ impl Screen for LoginScreen { self.sent_join = true; } + + if pressed_host { + ctx.send_msg(&ClientMessage::CreateRoom(Register { + game_code: self.new_room_code.clone(), + username: self.new_username.clone(), + })); + + self.sent_host = true; + } } fn handle_messages(&mut self, ctx: &mut Context) -> Option { - if self.sent_join { + if self.sent_join || self.sent_host { let msg = ctx.recv_msg(); - if let Some(GameResponse::JoinResponse { username, token }) = msg { - ctx.username = username; - ctx.token = token; - return Some(StateTransition::JoinAsPlayer); + if let Some(msg) = msg { + match msg { + GameResponse::JoinResponse { username, token } => { + ctx.username = username; + ctx.token = token; + return Some(StateTransition::JoinAsPlayer); + } + GameResponse::HostResponse { username, token } => { + ctx.username = username; + ctx.token = token; + return Some(StateTransition::JoinAsHost); + } + _ => { + warn!("Got unexpected message: {:?}", msg) + } + } } self.retry_count += 1; diff --git a/frontend/src/main.rs b/frontend/src/main.rs index 7b26156..c674698 100644 --- a/frontend/src/main.rs +++ b/frontend/src/main.rs @@ -1,10 +1,12 @@ use context::Context; +use host_screen::HostScreen; use login_screen::LoginScreen; use macroquad::prelude::*; use player_screen::PlayerScreen; use screen::Screen; mod context; +mod host_screen; mod login_screen; mod model; mod player_screen; @@ -14,15 +16,18 @@ pub enum State { Init, Login(LoginScreen), PlayerScreen(PlayerScreen), + HostScreen(HostScreen), } impl State { pub fn transition_state(&self, transition: StateTransition) -> Self { + info!("Applying transition: {:?}", transition); match transition { StateTransition::Init | StateTransition::LeaveGame => { Self::Login(LoginScreen::default()) } StateTransition::JoinAsPlayer => Self::PlayerScreen(PlayerScreen::default()), + StateTransition::JoinAsHost => Self::HostScreen(HostScreen::default()), } } @@ -31,6 +36,7 @@ impl State { Self::Init => {} Self::Login(login_screen) => login_screen.handle_frame(ctx), Self::PlayerScreen(player_screen) => player_screen.handle_frame(ctx), + State::HostScreen(host_screen) => host_screen.handle_frame(ctx), } } @@ -39,13 +45,16 @@ impl State { Self::Init => Some(StateTransition::Init), State::Login(login_screen) => login_screen.handle_messages(ctx), State::PlayerScreen(player_screen) => player_screen.handle_messages(ctx), + State::HostScreen(host_screen) => host_screen.handle_messages(ctx), } } } +#[derive(Debug)] pub enum StateTransition { Init, JoinAsPlayer, + JoinAsHost, LeaveGame, } diff --git a/frontend/src/model/mod.rs b/frontend/src/model/mod.rs index c417ce4..85f5cfd 100644 --- a/frontend/src/model/mod.rs +++ b/frontend/src/model/mod.rs @@ -10,6 +10,7 @@ pub mod register; pub enum ClientMessage { BuzzIn(BuzzIn), Register(Register), + CreateRoom(Register), } #[derive(Debug, Serialize)] @@ -22,7 +23,8 @@ pub struct ClientRequest { #[serde(tag = "type")] pub enum GameResponse { JoinResponse { username: String, token: String }, + HostResponse { username: String, token: String }, AckBuzzer, ResetBuzzer, - UpdatePountTotal { score: i32 }, + UpdatePointTotal { score: i32 }, } diff --git a/frontend/src/player_screen.rs b/frontend/src/player_screen.rs index 1193fea..f92af82 100644 --- a/frontend/src/player_screen.rs +++ b/frontend/src/player_screen.rs @@ -28,10 +28,11 @@ impl Screen for PlayerScreen { info!("Button press reset"); self.button_pressed = false; } - crate::model::GameResponse::UpdatePountTotal { score } => { + crate::model::GameResponse::UpdatePointTotal { score } => { info!("Score updated to: {}", score); self.score = score; } + _ => {} } } diff --git a/shared/src/player_session.gleam b/shared/src/player_session.gleam new file mode 100644 index 0000000..92b6b40 --- /dev/null +++ b/shared/src/player_session.gleam @@ -0,0 +1,21 @@ +import gleam/dynamic/decode +import gleam/json + +pub type PlayerSession { + PlayerSession(id: Int, token_hash: String, username: String) +} + +pub fn encode_player_session(player_session: PlayerSession) -> json.Json { + json.object([ + #("id", json.int(player_session.id)), + #("token_hash", json.string(player_session.token_hash)), + #("username", json.string(player_session.username)), + ]) +} + +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:)) +} diff --git a/shared/src/session.gleam b/shared/src/session.gleam index 1e5e22d..0deb7fa 100644 --- a/shared/src/session.gleam +++ b/shared/src/session.gleam @@ -4,12 +4,12 @@ import gleam/list import player pub type Session { - Session(id: Int, host_user_id: Int, players: List(player.Player)) + Session(id: String, host_user_id: Int, players: List(player.Player)) } pub fn serialize(session: Session) -> json.Json { json.object([ - #("id", json.int(session.id)), + #("id", json.string(session.id)), #("host_user_id", json.int(session.host_user_id)), #( "players", @@ -22,7 +22,7 @@ pub fn serialize(session: Session) -> json.Json { } pub fn decoder() -> decode.Decoder(Session) { - use id <- decode.field("id", decode.int) + use id <- decode.field("id", decode.string) use host_user_id <- decode.field("host_user_id", decode.int) use players <- decode.field("players", decode.list(player.decoder()))