diff --git a/backend/config/config.exs b/backend/config/config.exs index a36a868..10c43f8 100644 --- a/backend/config/config.exs +++ b/backend/config/config.exs @@ -21,15 +21,6 @@ config :backend, BackendWeb.Endpoint, pubsub_server: Backend.PubSub, live_view: [signing_salt: "E+VIFYOy"] -# Configure the mailer -# -# By default it uses the "Local" adapter which stores the emails -# locally. You can see the emails in your browser, at "/dev/mailbox". -# -# For production it's recommended to configure a different adapter -# at the `config/runtime.exs`. -config :backend, Backend.Mailer, adapter: Swoosh.Adapters.Local - # Configure Elixir's Logger config :logger, :default_formatter, format: "$time $metadata[$level] $message\n", diff --git a/backend/config/dev.exs b/backend/config/dev.exs index bf1e529..5dc6901 100644 --- a/backend/config/dev.exs +++ b/backend/config/dev.exs @@ -63,6 +63,3 @@ config :phoenix, :stacktrace_depth, 20 # Initialize plugs at runtime for faster development compilation config :phoenix, :plug_init_mode, :runtime - -# Disable swoosh api client as it is only required for production adapters. -config :swoosh, :api_client, false diff --git a/backend/config/prod.exs b/backend/config/prod.exs index 9db893a..5ef8af4 100644 --- a/backend/config/prod.exs +++ b/backend/config/prod.exs @@ -10,12 +10,6 @@ config :backend, BackendWeb.Endpoint, hosts: ["localhost", "127.0.0.1"] ] -# Configure Swoosh API Client -config :swoosh, api_client: Swoosh.ApiClient.Req - -# Disable Swoosh Local Memory Storage -config :swoosh, local: false - # Do not print debug messages in production config :logger, level: :info diff --git a/backend/config/test.exs b/backend/config/test.exs index 9309e0d..b639ed1 100644 --- a/backend/config/test.exs +++ b/backend/config/test.exs @@ -10,9 +10,6 @@ config :backend, BackendWeb.Endpoint, # In test we don't send emails config :backend, Backend.Mailer, adapter: Swoosh.Adapters.Test -# Disable swoosh api client as it is only required for production adapters -config :swoosh, :api_client, false - # Print only warnings and errors during test config :logger, level: :warning diff --git a/backend/lib/backend/application.ex b/backend/lib/backend/application.ex index 0a250e9..8b2d6b1 100644 --- a/backend/lib/backend/application.ex +++ b/backend/lib/backend/application.ex @@ -8,7 +8,7 @@ defmodule Backend.Application do BackendWeb.Telemetry, {Phoenix.PubSub, name: Backend.PubSub}, Backend.Cluster, - {Backend.GlobalSingleton, Backend.GameState}, + {Backend.GlobalSingleton, Backend.GameRunner}, BackendWeb.Endpoint ] diff --git a/backend/lib/backend/game_state.ex b/backend/lib/backend/game_runner.ex similarity index 71% rename from backend/lib/backend/game_state.ex rename to backend/lib/backend/game_runner.ex index 9f49fda..575040e 100644 --- a/backend/lib/backend/game_state.ex +++ b/backend/lib/backend/game_runner.ex @@ -1,4 +1,4 @@ -defmodule Backend.GameState do +defmodule Backend.GameRunner do @moduledoc """ GenServer to track all players and their positions in the game. Uses :global registry for distributed singleton pattern - only one instance @@ -27,7 +27,7 @@ defmodule Backend.GameState do def add_player(player_id) do Logger.info("Player #{player_id} connected") - GenServer.call(@name, {:add_player, player_id}) + GenServer.cast(@name, {:add_player, player_id}) end def remove_player(player_id) do @@ -56,24 +56,27 @@ defmodule Backend.GameState do sleep_delay = round(1000 / @fps) :timer.send_interval(sleep_delay, :tick) - {:ok, %{}} + {:ok, %{players: %{}, tick_number: 0}} end @impl true - def handle_call({:add_player, player_id}, _from, state) do + def handle_cast({:add_player, player_id}, _from, state) do # Start players at random position x = :rand.uniform(800) y = :rand.uniform(600) - new_state = Map.put(state, player_id, %{x: x, y: y, keys_pressed: []}) - broadcast_state(new_state) - {:reply, new_state, new_state} + new_state = %{ + state + | players: state.players |> Map.put(player_id, %{x: x, y: y, keys_pressed: []}) + } + + {:noreply, broadcast_state(new_state)} end @impl true def handle_cast({:update_player_keys, player_id, keys_pressed}, state) do player = - case Map.get(state, player_id) do + case Map.get(state.players, player_id) do nil -> Logger.warning( "Key update for non-existent player: #{player_id}, creating at random position" @@ -85,18 +88,23 @@ defmodule Backend.GameState do Map.put(existing_player, :keys_pressed, keys_pressed) end - new_state = Map.put(state, player_id, player) - broadcast_state(new_state) - {:noreply, new_state} + new_state = %{state | players: Map.put(state.players, player_id, player)} + + {:noreply, broadcast_state(new_state)} end @impl true def handle_info(:tick, state) do - # On each tick, move players based on their keys_pressed - new_state = - Enum.reduce(state, state, fn {player_id, player}, acc -> + if rem(state.tick_number, 100) == 0 do + Logger.info("Game tick #{state.tick_number} on #{node()}, updating player positions") + end + + step = 10 + + new_players = + state.players + |> Enum.map(fn {player_id, player} -> directions = player.keys_pressed || [] - step = 10 new_player = Enum.reduce(directions, player, fn direction, acc -> @@ -109,11 +117,13 @@ defmodule Backend.GameState do end end) - Map.put(acc, player_id, new_player) + {player_id, new_player} end) + |> Enum.into(%{}) - broadcast_state(new_state) - {:noreply, new_state} + new_state = %{state | players: new_players, tick_number: state.tick_number + 1} + + {:noreply, broadcast_state(new_state)} end @impl true @@ -123,14 +133,14 @@ defmodule Backend.GameState do @impl true def handle_cast({:remove_player, player_id}, state) do - new_state = Map.delete(state, player_id) - broadcast_state(new_state) - {:noreply, new_state} - end + players = Map.delete(state.players, player_id) + new_state = %{state | players: players} - # Private Functions + {:noreply, broadcast_state(new_state)} + end defp broadcast_state(state) do Phoenix.PubSub.broadcast(@pubsub, @topic, {:game_state_updated, state}) + state end end diff --git a/backend/lib/backend/global_singleton.ex b/backend/lib/backend/global_singleton.ex index 5b82ce8..4099fbf 100644 --- a/backend/lib/backend/global_singleton.ex +++ b/backend/lib/backend/global_singleton.ex @@ -26,24 +26,35 @@ defmodule Backend.GlobalSingleton do defp monitor_loop(module) do case :global.whereis_name(module) do :undefined -> - Logger.info("#{module} not running, attempting to start on #{node()}") + # Double-check before attempting to start + Process.sleep(50) - case module.start_link([]) do - {:ok, _pid} -> - Logger.info("#{module} started on #{node()}") + case :global.whereis_name(module) do + :undefined -> + Logger.info("#{module} not running, attempting to start on #{node()}") - {:error, {:already_started, _pid}} -> - Logger.debug("#{module} already started by another node") + case module.start_link([]) do + {:ok, _pid} -> + Logger.info("#{module} started on #{node()}") - _ -> - :ok + {:error, {:already_started, _pid}} -> + Logger.debug("#{module} already started by another node") + + _ -> + :ok + end + + Process.sleep(100) + monitor_loop(module) + + _pid -> + # Another node won the race + monitor_loop(module) end - Process.sleep(100) - monitor_loop(module) - pid when is_pid(pid) -> ref = Process.monitor(pid) + receive do {:DOWN, ^ref, :process, ^pid, _reason} -> Logger.warning("#{module} went down, attempting takeover") diff --git a/backend/lib/backend/mailer.ex b/backend/lib/backend/mailer.ex deleted file mode 100644 index c67ac2c..0000000 --- a/backend/lib/backend/mailer.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule Backend.Mailer do - use Swoosh.Mailer, otp_app: :backend -end diff --git a/backend/lib/backend_web/channels/cluster_status_channel.ex b/backend/lib/backend_web/channels/cluster_status_channel.ex new file mode 100644 index 0000000..381546e --- /dev/null +++ b/backend/lib/backend_web/channels/cluster_status_channel.ex @@ -0,0 +1,18 @@ +defmodule BackendWeb.ClusterStatusChannel do + @moduledoc """ + Channel for cluster status information + """ + use BackendWeb, :channel + require Logger + + @impl true + def join("clusterstatus", _params, socket) do + Logger.info("Client joined clusterstatus channel") + {:ok, %{status: "connected"}, socket} + end + + @impl true + def handle_in(_event, _payload, socket) do + {:noreply, socket} + end +end diff --git a/backend/lib/backend_web/channels/connected_user_channel.ex b/backend/lib/backend_web/channels/connected_user_channel.ex index 6a0a023..0208af1 100644 --- a/backend/lib/backend_web/channels/connected_user_channel.ex +++ b/backend/lib/backend_web/channels/connected_user_channel.ex @@ -20,7 +20,7 @@ defmodule BackendWeb.ConnectedUserChannel do :new_browser_connection ) - current_state = Backend.GameState.get_state() + current_state = Backend.GameRunner.get_state() {:ok, %{game_state: current_state}, socket} end @@ -55,7 +55,7 @@ defmodule BackendWeb.ConnectedUserChannel do |> assign(:player_name, name) |> assign(:keys_pressed, MapSet.new()) - Backend.GameState.add_player(name) + Backend.GameRunner.add_player(name) {:reply, :ok, socket} end @@ -75,7 +75,7 @@ defmodule BackendWeb.ConnectedUserChannel do "Player '#{player_name}' key down: #{key}, keys: #{inspect(MapSet.to_list(keys_pressed))}" ) - Backend.GameState.update_player_keys(player_name, MapSet.to_list(keys_pressed)) + Backend.GameRunner.update_player_keys(player_name, MapSet.to_list(keys_pressed)) {:noreply, socket} end @@ -96,7 +96,7 @@ defmodule BackendWeb.ConnectedUserChannel do "Player '#{player_name}' key up: #{key}, keys: #{inspect(MapSet.to_list(keys_pressed))}" ) - Backend.GameState.update_player_keys(player_name, MapSet.to_list(keys_pressed)) + Backend.GameRunner.update_player_keys(player_name, MapSet.to_list(keys_pressed)) {:noreply, socket} end @@ -110,7 +110,7 @@ defmodule BackendWeb.ConnectedUserChannel do player_name -> Logger.info("Player '#{player_name}' disconnected from #{node()}") - Backend.GameState.remove_player(player_name) + Backend.GameRunner.remove_player(player_name) end :ok diff --git a/backend/lib/backend_web/channels/user_socket.ex b/backend/lib/backend_web/channels/user_socket.ex index 830d4ba..d8dcc9a 100644 --- a/backend/lib/backend_web/channels/user_socket.ex +++ b/backend/lib/backend_web/channels/user_socket.ex @@ -5,6 +5,7 @@ defmodule BackendWeb.UserSocket do ## Channels channel("user:*", BackendWeb.ConnectedUserChannel) + channel("clusterstatus", BackendWeb.ClusterStatusChannel) @impl true def connect(%{"user_name" => user_name}, socket, _connect_info) do diff --git a/backend/lib/backend_web/router.ex b/backend/lib/backend_web/router.ex index 2b5c196..a4024f3 100644 --- a/backend/lib/backend_web/router.ex +++ b/backend/lib/backend_web/router.ex @@ -24,7 +24,6 @@ defmodule BackendWeb.Router do pipe_through([:fetch_session, :protect_from_forgery]) live_dashboard("/dashboard", metrics: BackendWeb.Telemetry) - forward("/mailbox", Plug.Swoosh.MailboxPreview) end end end diff --git a/backend/mix.exs b/backend/mix.exs index 411619a..5896977 100644 --- a/backend/mix.exs +++ b/backend/mix.exs @@ -41,7 +41,6 @@ defmodule Backend.MixProject do [ {:phoenix, "~> 1.8.4"}, {:phoenix_live_dashboard, "~> 0.8.3"}, - {:swoosh, "~> 1.16"}, {:req, "~> 0.5"}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, diff --git a/client/src/App.tsx b/client/src/App.tsx index e56759a..dcdb274 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -4,6 +4,7 @@ import { BoardDisplay } from "./game/BoardDisplay"; import { ConnectionStatus } from "./game/ConnectionStatus"; import { SessionOverride } from "./game/SessionOverride"; import { useGameChannelContext } from "./contexts/useGameChannelContext"; +import { ClusterStatus } from "./clusterStatus/ClusterStatus"; const App: FC<{ playerName: string }> = ({ playerName }) => { const { isOverridden } = useGameChannelContext(); @@ -15,8 +16,11 @@ const App: FC<{ playerName: string }> = ({ playerName }) => { return ( <> -
- +
+
+ + +
diff --git a/client/src/clusterStatus/ClusterStatus.tsx b/client/src/clusterStatus/ClusterStatus.tsx new file mode 100644 index 0000000..99c42df --- /dev/null +++ b/client/src/clusterStatus/ClusterStatus.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import { useWebSocketContext } from "../contexts/useWebSocketContext"; + +export const ClusterStatus = () => { + const { socket, isConnected } = useWebSocketContext(); + const [channelStatus, setChannelStatus] = useState("waiting"); + + useEffect(() => { + if (!socket || !isConnected) { + return; + } + + const channelName = "clusterstatus"; + console.log(`Joining channel: ${channelName}`); + const newChannel = socket.channel(channelName, {}); + + newChannel + .join() + .receive("ok", () => { + setChannelStatus("connected"); + }) + .receive("error", (resp: unknown) => { + console.log(`Failed to join channel ${channelName}:`, resp); + setChannelStatus("join failed"); + }) + .receive("timeout", () => { + setChannelStatus("timeout"); + }); + + return () => { + console.log(`Leaving channel: ${channelName}`); + newChannel.leave(); + setChannelStatus("waiting"); + }; + }, [socket, isConnected]); + + return ( +
+
ClusterStatus
+
Channel: {channelStatus}
+
+ ); +} diff --git a/client/src/game/BoardDisplay.tsx b/client/src/game/BoardDisplay.tsx index 579b940..cc56dbe 100644 --- a/client/src/game/BoardDisplay.tsx +++ b/client/src/game/BoardDisplay.tsx @@ -8,13 +8,16 @@ const PlayerSchema = z.object({ y: z.number(), }); -const GameStateSchema = z.record(z.string(), PlayerSchema); +const GameStateSchema = z.object({ + players: z.record(z.string(), PlayerSchema), + tick_number: z.number(), +}); -type GameState = z.infer; +type Player = z.infer; export const BoardDisplay = ({ playerName }: { playerName: string }) => { const { channel, isJoined, joinGame } = useGameChannelContext(); - const [players, setPlayers] = useState({}); + const [players, setPlayers] = useState>({}); useEffect(() => { if (channel && !isJoined) { @@ -25,18 +28,15 @@ export const BoardDisplay = ({ playerName }: { playerName: string }) => { useEffect(() => { if (!channel) return; - const ref = channel.on( - "game_state", - (payload: { game_state: unknown }) => { - const result = GameStateSchema.safeParse(payload.game_state); - if (result.success) { - setPlayers(result.data); - } else { - console.error("Invalid game state received:", result.error); - setPlayers({}); - } - }, - ); + const ref = channel.on("game_state", (payload: { game_state: unknown }) => { + const result = GameStateSchema.safeParse(payload.game_state); + if (result.success) { + setPlayers(result.data.players); + } else { + console.error("Invalid game state received:", result.error); + setPlayers({}); + } + }); return () => { channel.off("game_state", ref); diff --git a/client/src/game/SessionOverride.tsx b/client/src/game/SessionOverride.tsx index 6c42694..3328b22 100644 --- a/client/src/game/SessionOverride.tsx +++ b/client/src/game/SessionOverride.tsx @@ -4,7 +4,6 @@ export const SessionOverride: FC = () => { return (
-
⚠️

Session Overridden