From d129911b8e166440bb2c30e72bf956e93ed21433 Mon Sep 17 00:00:00 2001 From: Joey Hines Date: Thu, 12 Jun 2025 18:33:27 -0600 Subject: [PATCH] refactored to move away from storail, everything gets a manager now --- backend/gleam.toml | 1 - backend/manifest.toml | 5 - backend/src/app/router.gleam | 55 +++--- backend/src/app/web.gleam | 279 +++++------------------------- backend/src/backend.gleam | 37 +--- backend/src/manager.gleam | 31 ++-- backend/src/player_manager.gleam | 247 ++++++++++++++++++++++++-- backend/src/session_manager.gleam | 150 ++++++++++++---- backend/test/backend_test.gleam | 7 - frontend/src/main.rs | 2 +- 10 files changed, 441 insertions(+), 373 deletions(-) diff --git a/backend/gleam.toml b/backend/gleam.toml index 1c75897..741a1b6 100644 --- a/backend/gleam.toml +++ b/backend/gleam.toml @@ -14,7 +14,6 @@ version = "1.0.0" [dependencies] gleam_stdlib = ">= 0.44.0 and < 2.0.0" -storail = ">= 3.0.0 and < 4.0.0" tom = ">= 1.1.1 and < 2.0.0" simplifile = ">= 2.2.1 and < 3.0.0" glint = ">= 1.2.1 and < 2.0.0" diff --git a/backend/manifest.toml b/backend/manifest.toml index e371d8f..81849ed 100644 --- a/backend/manifest.toml +++ b/backend/manifest.toml @@ -3,8 +3,6 @@ packages = [ { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, - { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, - { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" }, { name = "gleam_community_colour", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "FDD6AC62C6EC8506C005949A4FCEF032038191D5EAAEC3C9A203CD53AE956ACA" }, @@ -24,11 +22,9 @@ packages = [ { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { 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_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" }, { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, { name = "tom", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0910EE688A713994515ACAF1F486A4F05752E585B9E3209D8F35A85B234C2719" }, ] @@ -47,5 +43,4 @@ glint = { version = ">= 1.2.1 and < 2.0.0" } mist = { version = ">= 4.0.7 and < 5.0.0" } shared = { path = "../shared" } simplifile = { version = ">= 2.2.1 and < 3.0.0" } -storail = { version = ">= 3.0.0 and < 4.0.0" } tom = { version = ">= 1.1.1 and < 2.0.0" } diff --git a/backend/src/app/router.gleam b/backend/src/app/router.gleam index 522361b..4410c6c 100644 --- a/backend/src/app/router.gleam +++ b/backend/src/app/router.gleam @@ -1,9 +1,7 @@ -import app/clients/host_client import app/clients/new_user_client import app/web import gleam/bit_array import gleam/bytes_tree -import gleam/dict import gleam/erlang/process import gleam/http/cookie import gleam/http/request.{type Request} @@ -18,9 +16,9 @@ import gleam/string import login import mist.{type Connection, type ResponseData} import player -import session +import player_manager +import session_manager import socket_manager -import storail type JoinAs { JoinAsHost @@ -54,41 +52,36 @@ fn server_login( let assert Ok(body) = bit_array.to_string(req.body) let assert Ok(login_req) = json.parse(body, login.login_request_decoder()) - let #(token, user_id) = - web.register_user(ctx, login_req.username, login_req.game_code) + let assert Ok(#(user_id, token)) = + player_manager.add_player( + ctx.players, + login_req.username, + login_req.game_code, + ) let _ = case join_type { JoinAsHost -> { - let key = storail.key(ctx.sessions, login_req.game_code) - let session = session.Session(login_req.game_code, user_id, dict.new()) - let assert Ok(Nil) = storail.write(key, session) + let assert Ok(_) = + session_manager.create_new_game( + ctx.sessions, + login_req.game_code, + user_id, + ) Nil } JoinAsPlayer -> { - let key = storail.key(ctx.sessions, login_req.game_code) - let assert Ok(session) = storail.read(key) - - let player = - player.Player( - name: login_req.username, - id: user_id, - score: 0, - buzz_in_time: None, + let assert Ok(_) = + session_manager.add_player_to_game( + ctx.sessions, + login_req.game_code, + player.Player( + name: login_req.username, + id: user_id, + score: 0, + buzz_in_time: None, + ), ) - let session = - session.Session( - ..session, - players: dict.insert(session.players, player.id, player), - ) - - let assert Ok(Nil) = storail.write(key, session) - web.send_message_to_host( - ctx, - session.host_user_id, - host_client.UpdatePlayerStates(session.players), - ) - Nil } } diff --git a/backend/src/app/web.gleam b/backend/src/app/web.gleam index bc2bf1e..6571cfa 100644 --- a/backend/src/app/web.gleam +++ b/backend/src/app/web.gleam @@ -2,22 +2,14 @@ import app/clients/host_client import app/clients/new_user_client import app/clients/player_client import config -import gleam/bit_array -import gleam/crypto -import gleam/dict import gleam/erlang/process import gleam/float import gleam/int import gleam/io import gleam/json -import gleam/list -import gleam/option.{type Option, None, Some} -import gleam/otp/actor -import player -import player_session -import session +import player_manager +import session_manager import socket_manager -import storail pub type Error { InvalidToken @@ -26,8 +18,8 @@ pub type Error { pub type Context { Context( config: config.Config, - sessions: storail.Collection(session.Session), - player_sessions: storail.Collection(player_session.PlayerSession), + sessions: session_manager.SessionManager, + players: player_manager.PlayerManager, socket_manager: socket_manager.SocketManager(String), ) } @@ -58,32 +50,6 @@ pub fn handle_client_msg( } } -fn set_buzz_in( - socket_state: SocketState, - player_state: player_client.PlayerState, - time: Float, -) { - let key = storail.key(socket_state.ctx.sessions, player_state.game_id) - let assert Ok(session) = storail.read(key) - - let players = - dict.upsert(session.players, player_state.user_id, fn(player) { - let assert Some(player) = player - - player.Player(..player, buzz_in_time: Some(time)) - }) - - let session = session.Session(..session, players:) - - let assert Ok(_) = storail.write(key, session) - - send_message_to_host( - socket_state.ctx, - session.host_user_id, - host_client.UpdatePlayerStates(session.players), - ) -} - fn handle_player( socket_state: SocketState, player_state: player_client.PlayerState, @@ -101,7 +67,13 @@ fn handle_player( <> float.to_string(time), ) - set_buzz_in(socket_state, player_state, time) + let assert Ok(_) = + session_manager.buzz_in_player( + socket_state.ctx.sessions, + player_state.game_id, + player_state.user_id, + time, + ) #( socket_state, @@ -113,67 +85,6 @@ fn handle_player( } } -pub fn register_user( - ctx: Context, - username: String, - game_code: 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(game_code)) - |> 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 assert Ok(Nil) = - storail.write( - key, - player_session.PlayerSession( - id: user_id, - token_hash:, - username:, - socket_id: None, - ), - ) - - #(token, user_id) -} - -pub fn attach_socket_to_user( - state: SocketState, - user_id: Int, - token: String, -) -> Result(Nil, Error) { - let key = storail.key(state.ctx.player_sessions, int.to_base16(user_id)) - let assert Ok(player_state) = storail.read(key) - - let hashed_token = player_session.hash_token(token) - - case hashed_token == player_state.token_hash { - False -> Error(InvalidToken) - True -> { - let assert Ok(Nil) = - storail.write( - key, - player_session.PlayerSession( - ..player_state, - socket_id: Some(state.id), - ), - ) - - Ok(Nil) - } - } -} - fn handle_new_user( socket_state: SocketState, _new_user_state: new_user_client.NewUserState, @@ -184,7 +95,13 @@ fn handle_new_user( let #(socket_state, new_user_msg) = case msg { new_user_client.JoinAsHost(token, user_id, game_id) -> { - let assert Ok(_) = attach_socket_to_user(socket_state, user_id, token) + let assert Ok(_) = + player_manager.attack_socket_to_player( + socket_state.ctx.players, + user_id, + socket_state.id, + token, + ) #( SocketState( @@ -195,7 +112,13 @@ fn handle_new_user( ) } new_user_client.JoinAsPlayer(token, user_id, game_id) -> { - let assert Ok(_) = attach_socket_to_user(socket_state, user_id, token) + let assert Ok(_) = + player_manager.attack_socket_to_player( + socket_state.ctx.players, + user_id, + socket_state.id, + token, + ) #( SocketState( @@ -213,106 +136,6 @@ fn handle_new_user( ) } -fn get_player_session_from_id( - ctx: Context, - id: Int, -) -> player_session.PlayerSession { - let player_key = storail.key(ctx.player_sessions, int.to_base16(id)) - let assert Ok(player) = storail.read(player_key) - player -} - -fn get_player_session_subject( - ctx: Context, - player: player_session.PlayerSession, -) -> Option(process.Subject(String)) { - case player.socket_id { - None -> None - Some(id) -> { - let assert Ok(subject) = - socket_manager.get_socket_subj_by_id(ctx.socket_manager, id) - - Some(subject) - } - } -} - -pub fn broadcast_message_to_players( - ctx: Context, - session: session.Session, - msg: player_client.PlayerServerMessages, -) { - dict.keys(session.players) - |> list.map(get_player_session_from_id(ctx, _)) - |> list.map(get_player_session_subject(ctx, _)) - |> list.filter_map(fn(subject) { option.to_result(subject, "Missing") }) - |> list.each(fn(subject) { - let client_msg = player_client.encode_player_server_messages(msg) - actor.send(subject, json.to_string(client_msg)) - }) -} - -pub fn send_message_to_player( - ctx: Context, - id: Int, - msg: player_client.PlayerServerMessages, -) { - let client_msg = player_client.encode_player_server_messages(msg) - - let subject = - get_player_session_from_id(ctx, id) - |> get_player_session_subject(ctx, _) - - case subject { - None -> Nil - Some(subject) -> { - actor.send(subject, json.to_string(client_msg)) - } - } -} - -pub fn send_message_to_host( - ctx: Context, - id: Int, - msg: host_client.HostServerMessages, -) { - let client_msg = host_client.encode_host_server_messages(msg) - - let subject = - get_player_session_from_id(ctx, id) - |> get_player_session_subject(ctx, _) - - case subject { - None -> Nil - Some(subject) -> { - actor.send(subject, json.to_string(client_msg)) - } - } -} - -fn reset_player_buzzer( - socket_state: SocketState, - session_key: storail.Key(session.Session), - id: Int, -) { - let assert Ok(session) = storail.read(session_key) - - let assert Ok(player) = dict.get(session.players, id) - let player = player.Player(..player, buzz_in_time: None) - - let players = dict.insert(session.players, id, player) - - let session = session.Session(..session, players: players) - - let assert Ok(_) = storail.write(session_key, session) - - send_message_to_host( - socket_state.ctx, - session.host_user_id, - host_client.UpdatePlayerStates(session.players), - ) -} - fn handle_host( socket_state: SocketState, host_state: host_client.HostState, @@ -321,49 +144,33 @@ fn handle_host( let assert Ok(msg) = json.parse(msg, host_client.host_client_messages_decoder()) - let session_key = storail.key(socket_state.ctx.sessions, host_state.game_id) - let assert Ok(session) = storail.read(session_key) - let #(socket_state, resp) = case msg { host_client.ResetAllBuzers -> { - dict.to_list(session.players) - |> list.each(fn(player_entry) { - reset_player_buzzer(socket_state, session_key, player_entry.0) - }) - - broadcast_message_to_players( - socket_state.ctx, - session, - player_client.ResetBuzzer, - ) + let assert Ok(_) = + session_manager.reset_all_buzzers( + socket_state.ctx.sessions, + host_state.game_id, + ) #(socket_state, host_client.Ack) } host_client.ResetPlayerBuzzer(id) -> { - reset_player_buzzer(socket_state, session_key, id) - send_message_to_player(socket_state.ctx, id, player_client.ResetBuzzer) + let assert Ok(_) = + session_manager.reset_player_buzzer( + socket_state.ctx.sessions, + host_state.game_id, + id, + ) + #(socket_state, host_client.Ack) } host_client.UpdateScore(id, score) -> { - let assert Ok(player) = dict.get(session.players, id) - let player = player.Player(..player, score: player.score + score) - let session = - session.Session( - ..session, - players: dict.insert(session.players, player.id, player), + let assert Ok(_) = + session_manager.updater_player_score( + socket_state.ctx.sessions, + host_state.game_id, + id, + score, ) - let assert Ok(_) = storail.write(session_key, session) - - send_message_to_player( - socket_state.ctx, - id, - player_client.UpdatePointTotal(player.score), - ) - - send_message_to_host( - socket_state.ctx, - session.host_user_id, - host_client.UpdatePlayerStates(session.players), - ) #(socket_state, host_client.Ack) } diff --git a/backend/src/backend.gleam b/backend/src/backend.gleam index 1efadcd..3663afd 100644 --- a/backend/src/backend.gleam +++ b/backend/src/backend.gleam @@ -6,10 +6,9 @@ import gleam/erlang/process import gleam/result import glint import mist -import player_session -import session +import player_manager +import session_manager import socket_manager -import storail pub type Error { ConfigError(config.ConfigError) @@ -21,28 +20,6 @@ pub fn error_string(err: Error) -> String { } } -pub fn setup_session_collection( - config: storail.Config, -) -> storail.Collection(session.Session) { - storail.Collection( - name: "Sessions", - to_json: session.serialize, - decoder: session.decoder(), - config:, - ) -} - -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() @@ -59,14 +36,16 @@ pub fn run_server() -> glint.Command(Result(Nil, Error)) { }), ) - let storail_cfg = storail.Config(cfg.storage_path) + let player_manager = player_manager.new() + let socket_manager = socket_manager.new() + let session_manager = session_manager.new(player_manager, socket_manager) let ctx = web.Context( config: cfg, - sessions: setup_session_collection(storail_cfg), - player_sessions: setup_player_session_collection(storail_cfg), - socket_manager: socket_manager.new(), + players: player_manager, + socket_manager:, + sessions: session_manager, ) let handler = router.handle_request(_, ctx) diff --git a/backend/src/manager.gleam b/backend/src/manager.gleam index a182e7a..35b384c 100644 --- a/backend/src/manager.gleam +++ b/backend/src/manager.gleam @@ -1,41 +1,30 @@ -import app/web import gleam/erlang/process import gleam/otp/actor pub const call_timeout = 1000 -pub type Manager(resp_type, resp_error, msg_type) { - Manager(subject: process.Subject(Request(resp_type, resp_error, msg_type))) +pub type Manager(resp_type, msg_type) { + Manager(subject: process.Subject(Request(resp_type, msg_type))) } pub type State(state_type) { - State(ctx: web.Context, state: state_type) + State(internal: state_type) } -pub type Response(resp_type, resp_error) = - Result(resp_type, resp_error) - -pub type Request(resp_type, resp_error, msg_type) { - Request( - reply_to: process.Subject(Response(resp_type, resp_error)), - msg: msg_type, - ) +pub type Request(resp_type, msg_type) { + Request(reply_to: process.Subject(resp_type), msg: msg_type) } pub fn new( - ctx: web.Context, init_state: fn() -> state_type, - handle: fn(Request(resp_type, resp_error, msg_type), State(state_type)) -> - actor.Next(Request(resp_type, resp_error, msg_type), State(state_type)), -) -> Manager(resp_type, resp_error, msg_type) { - let assert Ok(subject) = actor.start(State(ctx, init_state()), handle) + handle: fn(Request(resp_type, msg_type), State(state_type)) -> + actor.Next(Request(resp_type, msg_type), State(state_type)), +) -> Manager(resp_type, msg_type) { + let assert Ok(subject) = actor.start(State(init_state()), handle) Manager(subject) } -pub fn call( - manager: Manager(resp_type, resp_error, msg_type), - msg: msg_type, -) -> Result(resp_type, resp_error) { +pub fn call(manager: Manager(resp_type, msg_type), msg: msg_type) -> resp_type { actor.call(manager.subject, Request(_, msg), call_timeout) } diff --git a/backend/src/player_manager.gleam b/backend/src/player_manager.gleam index 3c363e6..a3876d3 100644 --- a/backend/src/player_manager.gleam +++ b/backend/src/player_manager.gleam @@ -1,38 +1,263 @@ -import app/web +import gleam/bit_array +import gleam/crypto import gleam/dict import gleam/erlang/process +import gleam/int +import gleam/io +import gleam/option.{type Option, None, Some} +import gleam/otp/actor +import gleam/result +import manager import player_session -pub type PlayerManager { - PlayerManager(subject: process.Subject(Request)) -} +pub type PlayerManager = + manager.Manager(Response, Message) pub type Message { AddPlayer(username: String, game_code: String) - AttachSocketToPlayer(user_id: Int, socket_id: Int) + AttachSocketToPlayer(user_id: Int, socket_id: Int, token_hash: String) RemoveSocket(user_id: Int) RemovePlayer(user_id: Int) - BroadcastMessageToPlayer(user_id: Int, msg: String) - BroadcastMessageToPlayers(user_id: List(Int), msg: String) + GetPlayerSocket(user_id: Int) } pub type PlayerError { PlayerNotFound(Int) UsernameAlreadyExists(String) + TokenHashMistmatch +} + +pub fn error_to_string(error: PlayerError) -> String { + case error { + PlayerNotFound(id) -> "Player of id=" <> int.to_string(id) <> " not found" + UsernameAlreadyExists(username) -> + "Username '" <> username <> "' already exists!" + TokenHashMistmatch -> "Error authenticating user" + } } pub type Return { Ack AddPlayerResp(user_id: Int, token: String) + GetPlayerSocketResp(socket_id: Option(Int)) } pub type Response = Result(Return, PlayerError) -pub type Request { - Request(reply_wth: process.Subject(Response), message: Message) +pub type Request = + manager.Request(Response, Message) + +pub type InternalState { + InternalState( + next_id: Int, + players: dict.Dict(Int, player_session.PlayerSession), + ) } -type State { - State(ctx: web.Context, players: dict.Dict(Int, player_session.PlayerSession)) +pub type State = + manager.State(InternalState) + +fn call( + player_manager: PlayerManager, + msg: Message, +) -> Result(Return, PlayerError) { + manager.call(player_manager, msg) +} + +pub fn new() -> PlayerManager { + manager.new(fn() { InternalState(0, dict.new()) }, handle) +} + +pub fn add_player( + player_manager: PlayerManager, + username: String, + gamecode: String, +) -> Result(#(Int, String), PlayerError) { + use ret <- result.try(call(player_manager, AddPlayer(username, gamecode))) + + let assert AddPlayerResp(user_id, token) = ret + Ok(#(user_id, token)) +} + +pub fn attack_socket_to_player( + player_manager: PlayerManager, + user_id: Int, + socket_id: Int, + token_hash: String, +) -> Result(Nil, PlayerError) { + use _ret <- result.try(call( + player_manager, + AttachSocketToPlayer(user_id, socket_id, token_hash), + )) + + Ok(Nil) +} + +pub fn remove_socket( + player_manager: PlayerManager, + user_id: Int, +) -> Result(Nil, PlayerError) { + use _ret <- result.try(call(player_manager, RemoveSocket(user_id))) + + Ok(Nil) +} + +pub fn remove_player( + player_manager: PlayerManager, + user_id: Int, +) -> Result(Nil, PlayerError) { + use _ret <- result.try(call(player_manager, RemovePlayer(user_id))) + + Ok(Nil) +} + +pub fn get_player_socket( + player_manager: PlayerManager, + user_id: Int, +) -> Result(Option(Int), PlayerError) { + use ret <- result.try(call(player_manager, GetPlayerSocket(user_id))) + + let assert GetPlayerSocketResp(socket_id) = ret + + Ok(socket_id) +} + +fn handle(request: Request, state: State) -> actor.Next(Request, State) { + let ret = case request.msg { + AddPlayer(username, gamecode) -> + add_player_internal(state, username, gamecode) + AttachSocketToPlayer(user_id, socket_id, token_hash) -> + attach_socket_to_player_internal(state, user_id, socket_id, token_hash) + RemovePlayer(user_id) -> remove_player_internal(state, user_id) + RemoveSocket(user_id) -> remove_socket_internal(state, user_id) + GetPlayerSocket(user_id) -> get_player_socket_internal(state, user_id) + } + + case ret { + Error(err) -> { + io.println_error("Player Manager error: " <> error_to_string(err)) + process.send(request.reply_to, Error(err)) + + actor.continue(state) + } + Ok(#(resp, actor_state)) -> { + process.send(request.reply_to, Ok(resp)) + + actor_state + } + } +} + +fn get_player( + state: State, + player_id: Int, +) -> Result(player_session.PlayerSession, PlayerError) { + result.map_error(dict.get(state.internal.players, player_id), fn(_) { + PlayerNotFound(player_id) + }) +} + +fn add_player_to_state( + state: State, + player: player_session.PlayerSession, +) -> State { + let players = dict.insert(state.internal.players, player.id, player) + + manager.State(internal: InternalState(..state.internal, players:)) +} + +fn add_player_internal( + state: State, + username: String, + game_code: String, +) -> Result(#(Return, actor.Next(Request, State)), PlayerError) { + 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(game_code)) + |> crypto.digest + |> bit_array.base64_encode(True) + + let token_hash = player_session.hash_token(token) + + let user_id = state.internal.next_id + + let sessions = + player_session.PlayerSession(user_id, token_hash, username, None) + |> dict.insert(state.internal.players, user_id, _) + + let continue = + manager.State(internal: InternalState(state.internal.next_id + 1, sessions)) + |> actor.continue + + io.print("Adding" <> username <> "id=" <> int.to_string(user_id) <> "to game") + #(AddPlayerResp(user_id, token), continue) + |> Ok +} + +fn attach_socket_to_player_internal( + state: State, + user_id: Int, + socket_id: Int, + token_hash: String, +) -> Result(#(Return, actor.Next(Request, State)), PlayerError) { + use player <- result.try(get_player(state, user_id)) + use _ <- result.try( + case player.token_hash == player_session.hash_token(token_hash) { + False -> Error(TokenHashMistmatch) + True -> Ok(Nil) + }, + ) + + let state = + player_session.PlayerSession(..player, socket_id: Some(socket_id)) + |> add_player_to_state(state, _) + |> actor.continue + + #(Ack, state) + |> Ok +} + +fn remove_socket_internal( + state: State, + user_id: Int, +) -> Result(#(Return, actor.Next(Request, State)), PlayerError) { + use player <- result.try(get_player(state, user_id)) + + let state = + player_session.PlayerSession(..player, socket_id: None) + |> add_player_to_state(state, _) + |> actor.continue + + #(Ack, state) + |> Ok +} + +fn remove_player_internal( + state: State, + user_id: Int, +) -> Result(#(Return, actor.Next(Request, State)), PlayerError) { + use player <- result.try(get_player(state, user_id)) + + let players = dict.delete(state.internal.players, player.id) + + let state = + manager.State(internal: InternalState(..state.internal, players:)) + |> actor.continue + + #(Ack, state) + |> Ok +} + +fn get_player_socket_internal( + state: State, + user_id: Int, +) -> Result(#(Return, actor.Next(Request, State)), PlayerError) { + use player <- result.try(get_player(state, user_id)) + + #(GetPlayerSocketResp(player.socket_id), actor.continue(state)) + |> Ok } diff --git a/backend/src/session_manager.gleam b/backend/src/session_manager.gleam index 57aa284..0837cd6 100644 --- a/backend/src/session_manager.gleam +++ b/backend/src/session_manager.gleam @@ -1,22 +1,42 @@ -import app/web +import app/clients/host_client +import app/clients/player_client import gleam/dict import gleam/erlang/process import gleam/int import gleam/io -import gleam/option.{None, Some} +import gleam/json +import gleam/list +import gleam/option.{type Option, None, Some} import gleam/otp/actor import gleam/result +import manager import player +import player_manager import session +import socket_manager -pub type SessionManager { - SessionManager(subject: process.Subject(Request)) +pub type SessionManager = + manager.Manager(Response, Message) + +pub type Request = + manager.Request(Response, Message) + +pub type State = + manager.State(InternalState) + +pub type InternalState { + InternalState( + games: dict.Dict(String, session.Session), + player_manager: player_manager.PlayerManager, + socket_manager: socket_manager.SocketManager(String), + ) } -pub fn new(ctx: web.Context) -> SessionManager { - let assert Ok(subject) = actor.start(State(ctx, dict.new()), handle) - - SessionManager(subject) +pub fn new(player_manager, socket_manager) -> SessionManager { + manager.new( + fn() { InternalState(dict.new(), player_manager, socket_manager) }, + handle, + ) } pub type Message { @@ -32,10 +52,6 @@ pub type Message { Shutdown } -pub type Request { - Request(reply_with: process.Subject(Response), msg: Message) -} - pub type Response = Result(Nil, SessionError) @@ -45,7 +61,7 @@ pub type SessionError { PlayerNotFound(player_id: Int) } -pub fn error_to_sring(error: SessionError) -> String { +pub fn error_to_string(error: SessionError) -> String { case error { GameAlreadyExists(game_id) -> "Game with id=" <> game_id <> " already exists." @@ -55,15 +71,11 @@ pub fn error_to_sring(error: SessionError) -> String { } } -type State { - State(ctx: web.Context, games: dict.Dict(String, session.Session)) -} - fn call( session_manager: SessionManager, msg: Message, ) -> Result(Nil, SessionError) { - actor.call(session_manager.subject, Request(_, msg), 1000) + manager.call(session_manager, msg) } pub fn create_new_game( @@ -113,7 +125,7 @@ pub fn reset_all_buzzers( call(session_manager, ResetAllBuzers(game_id)) } -pub fn reset_player_buzzers( +pub fn reset_player_buzzer( session_manager: SessionManager, game_id: String, user_id: Int, @@ -131,7 +143,7 @@ pub fn updater_player_score( } fn get_game(state: State, game_id) -> Result(session.Session, SessionError) { - result.map_error(dict.get(state.games, game_id), fn(_) { + result.map_error(dict.get(state.internal.games, game_id), fn(_) { GameNotFound(game_id) }) } @@ -150,9 +162,15 @@ fn add_game_to_manager( game_id: String, game: session.Session, ) -> State { - let games = dict.insert(state.games, game_id, game) + send_message_to_host( + state, + game.host_user_id, + host_client.UpdatePlayerStates(game.players), + ) - State(..state, games: games) + let games = dict.insert(state.internal.games, game_id, game) + + manager.State(internal: InternalState(..state.internal, games:)) } fn add_player_to_game_internal( @@ -164,6 +182,64 @@ fn add_player_to_game_internal( // Actor Logic +fn get_player_session_subject( + state: State, + player_id: Int, +) -> Option(process.Subject(String)) { + let assert Ok(socket_id) = + player_manager.get_player_socket(state.internal.player_manager, player_id) + case socket_id { + None -> None + Some(id) -> { + let assert Ok(subject) = + socket_manager.get_socket_subj_by_id(state.internal.socket_manager, id) + + Some(subject) + } + } +} + +fn send_message_to_socket(state: State, player_id: Int, msg: String) { + let subject = get_player_session_subject(state, player_id) + + case subject { + None -> Nil + Some(subject) -> { + actor.send(subject, msg) + } + } +} + +fn send_message_to_player( + state: State, + player_id: Int, + msg: player_client.PlayerServerMessages, +) { + player_client.encode_player_server_messages(msg) + |> json.to_string + |> send_message_to_socket(state, player_id, _) +} + +fn broadcast_msg_to_players( + state: State, + game: session.Session, + msg: player_client.PlayerServerMessages, +) { + game.players + |> dict.keys + |> list.each(fn(player_id) { send_message_to_player(state, player_id, msg) }) +} + +fn send_message_to_host( + state: State, + player_id: Int, + msg: host_client.HostServerMessages, +) { + host_client.encode_host_server_messages(msg) + |> json.to_string + |> send_message_to_socket(state, player_id, _) +} + fn add_player_internal( state: State, game_id: String, @@ -198,7 +274,7 @@ fn create_new_game_internal( game_id: String, host_id: Int, ) -> Result(actor.Next(Request, State), SessionError) { - case dict.has_key(state.games, game_id) { + case dict.has_key(state.internal.games, game_id) { True -> Error(GameAlreadyExists(game_id)) False -> { session.Session(game_id, host_id, dict.new()) @@ -215,9 +291,9 @@ fn end_game_internal( ) -> Result(actor.Next(Request, State), SessionError) { use _ <- result.try(get_game(state, game_id)) - let games = dict.delete(state.games, game_id) + let games = dict.delete(state.internal.games, game_id) - State(..state, games: games) + manager.State(internal: InternalState(..state.internal, games:)) |> actor.continue |> Ok } @@ -249,13 +325,15 @@ fn reset_all_buzzers_internal( player.Player(..player, buzz_in_time: None) }) + broadcast_msg_to_players(state, game, player_client.ResetBuzzer) + session.Session(..game, players: players) |> add_game_to_manager(state, game_id, _) |> actor.continue |> Ok } -fn reset_player_buzzer( +fn reset_player_buzzer_internal( state: State, game_id: String, player_id: Int, @@ -263,6 +341,8 @@ fn reset_player_buzzer( use game <- result.try(get_game(state, game_id)) use player <- result.try(get_player_in_game(game, player_id)) + send_message_to_player(state, player.id, player_client.ResetBuzzer) + player.Player(..player, buzz_in_time: None) |> add_player_internal(state, game_id, _) } @@ -276,7 +356,15 @@ fn update_player_score_internal( use game <- result.try(get_game(state, game_id)) use player <- result.try(get_player_in_game(game, player_id)) - player.Player(..player, score: player.score + score_diff) + let player = player.Player(..player, score: player.score + score_diff) + + send_message_to_player( + state, + player_id, + player_client.UpdatePointTotal(player.score), + ) + + player |> add_player_internal(state, game_id, _) } @@ -301,7 +389,7 @@ fn handle(request: Request, state: State) -> actor.Next(Request, State) { reset_all_buzzers_internal(state, game_id) } ResetPlayerBuzzer(game_id, player_id) -> { - reset_player_buzzer(state, game_id, player_id) + reset_player_buzzer_internal(state, game_id, player_id) } UpdatePlayerScore(game_id, player_id, score_diff) -> { update_player_score_internal(state, game_id, player_id, score_diff) @@ -313,13 +401,13 @@ fn handle(request: Request, state: State) -> actor.Next(Request, State) { case ret { Error(err) -> { - io.println_error("Session Manager error: " <> error_to_sring(err)) - process.send(request.reply_with, Error(err)) + io.println_error("Session Manager error: " <> error_to_string(err)) + process.send(request.reply_to, Error(err)) actor.continue(state) } Ok(actor_state) -> { - process.send(request.reply_with, Ok(Nil)) + process.send(request.reply_to, Ok(Nil)) actor_state } diff --git a/backend/test/backend_test.gleam b/backend/test/backend_test.gleam index 3831e7a..ecd12ad 100644 --- a/backend/test/backend_test.gleam +++ b/backend/test/backend_test.gleam @@ -1,12 +1,5 @@ import gleeunit -import gleeunit/should pub fn main() { gleeunit.main() } - -// gleeunit test functions end in `_test` -pub fn hello_world_test() { - 1 - |> should.equal(1) -} diff --git a/frontend/src/main.rs b/frontend/src/main.rs index 098fb4e..03ccd28 100644 --- a/frontend/src/main.rs +++ b/frontend/src/main.rs @@ -112,7 +112,7 @@ fn get_ws_url() -> String { "ws" }; - format!("wss://{}/ws", location.host().unwrap()) + format!("{}://{}/ws", protocol, location.host().unwrap()) } mod modname {}