diff --git a/.vscode/settings.json b/.vscode/settings.json index 055c482..66a1e9e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,8 @@ "css.lint.unknownAtRules": "ignore", "elixirLS.projectDir": "backend", "cSpell.words": [ + "nodedown", + "nodeup", "pids", "whereis" ] diff --git a/backend/lib/backend_web/channels/cluster_status_channel.ex b/backend/lib/backend_web/channels/cluster_status_channel.ex index a7ef7cf..3cf9113 100644 --- a/backend/lib/backend_web/channels/cluster_status_channel.ex +++ b/backend/lib/backend_web/channels/cluster_status_channel.ex @@ -8,13 +8,13 @@ defmodule BackendWeb.ClusterStatusChannel do @impl true def join("cluster_status", _params, socket) do Logger.info("Client joined clusterstatus channel") + :net_kernel.monitor_nodes(true) {:ok, %{status: "connected"}, socket} end @impl true def handle_in("get_nodes", _payload, socket) do - Logger.info("Client requested node list #{inspect(Node.list())}") - push(socket, "node_list", %{other_nodes: Node.list(), connected_node: node()}) + send_node_list(socket) {:noreply, socket} end @@ -29,4 +29,28 @@ defmodule BackendWeb.ClusterStatusChannel do def handle_in(_event, _payload, socket) do {:noreply, socket} end + + @impl true + def handle_info({:nodeup, node_name}, socket) do + Logger.info("Node up: #{node_name}, broadcasting updated node list") + send_node_list(socket) + {:noreply, socket} + end + + @impl true + def handle_info({:nodedown, node_name}, socket) do + Logger.info("Node down: #{node_name}, broadcasting updated node list") + send_node_list(socket) + {:noreply, socket} + end + + defp send_node_list(socket) do + game_pid = Backend.GameRunner.get_pid() + + push(socket, "node_list", %{ + other_nodes: Node.list(), + connected_node: node(), + game_node: node(game_pid) + }) + 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 0644f2d..05a0ff0 100644 --- a/backend/lib/backend_web/channels/connected_user_channel.ex +++ b/backend/lib/backend_web/channels/connected_user_channel.ex @@ -20,6 +20,11 @@ defmodule BackendWeb.ConnectedUserChannel do :new_browser_connection ) + socket = + socket + |> assign(:player_name, socket_user) + |> assign(:keys_pressed, MapSet.new()) + current_state = Backend.GameRunner.get_state() {:ok, %{game_state: current_state}, socket} end @@ -47,74 +52,42 @@ defmodule BackendWeb.ConnectedUserChannel do end @impl true - @spec handle_in(<<_::48, _::_*8>>, map(), any()) :: - {:noreply, any()} | {:reply, :ok, Phoenix.Socket.t()} def handle_in("join_game", %{"name" => name}, socket) do Logger.info("Player '#{name}' joining game on #{node()}") - socket = - socket - |> assign(:player_name, name) - |> assign(:keys_pressed, MapSet.new()) - Backend.GameRunner.add_player(name) {:reply, :ok, socket} end @impl true - def handle_in("key_down", %{"key" => key}, socket) do - case socket.assigns[:player_name] do - nil -> - Logger.warning("Key down attempted without joining game") - {:noreply, socket} + def handle_in("key_down", %{"key" => key}, %{assigns: %{player_name: player_name}} = socket) do + keys_pressed = MapSet.put(socket.assigns[:keys_pressed] || MapSet.new(), key) + socket = assign(socket, :keys_pressed, keys_pressed) - player_name -> - keys_pressed = MapSet.put(socket.assigns[:keys_pressed] || MapSet.new(), key) - socket = assign(socket, :keys_pressed, keys_pressed) + Logger.debug( + "Player '#{player_name}' key down: #{key}, keys: #{inspect(MapSet.to_list(keys_pressed))}" + ) - Logger.debug( - "Player '#{player_name}' key down: #{key}, keys: #{inspect(MapSet.to_list(keys_pressed))}" - ) + Backend.GameRunner.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 + {:noreply, socket} end @impl true - def handle_in("key_up", %{"key" => key}, socket) do - case socket.assigns[:player_name] do - nil -> - Logger.warning("Key up attempted without joining game") - {:noreply, socket} + def handle_in("key_up", %{"key" => key}, %{assigns: %{player_name: player_name}} = socket) do + keys_pressed = MapSet.delete(socket.assigns[:keys_pressed] || MapSet.new(), key) + socket = assign(socket, :keys_pressed, keys_pressed) - player_name -> - keys_pressed = MapSet.delete(socket.assigns[:keys_pressed] || MapSet.new(), key) - socket = assign(socket, :keys_pressed, keys_pressed) + Logger.debug( + "Player '#{player_name}' key up: #{key}, keys: #{inspect(MapSet.to_list(keys_pressed))}" + ) - Logger.debug( - "Player '#{player_name}' key up: #{key}, keys: #{inspect(MapSet.to_list(keys_pressed))}" - ) + Backend.GameRunner.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 - end - - @impl true - def terminate(_reason, socket) do - case socket.assigns[:player_name] do - nil -> - Logger.info("WebSocket disconnected from #{node()}") - - player_name -> - Logger.info("Player '#{player_name}' disconnected from #{node()}") - Backend.GameRunner.remove_player(player_name) - end - - :ok + {:noreply, socket} end end diff --git a/client/src/clusterStatus/ClusterStatus.tsx b/client/src/clusterStatus/ClusterStatus.tsx index af90cbb..bda981f 100644 --- a/client/src/clusterStatus/ClusterStatus.tsx +++ b/client/src/clusterStatus/ClusterStatus.tsx @@ -7,7 +7,7 @@ export const ClusterStatus = () => { const [channelStatus, setChannelStatus] = useState("waiting"); const [channel, setChannel] = useState(); const [clusterStatus, setClusterStatus] = useState< - { otherNodes: string[]; connectedNode: string } | undefined + { otherNodes: string[]; connectedNode: string, gameNode: string } | undefined >(); useEffect(() => { @@ -36,11 +36,12 @@ export const ClusterStatus = () => { newChannel.on( "node_list", - (payload: { other_nodes: string[]; connected_node: string }) => { + (payload: { other_nodes: string[]; connected_node: string; game_node: string }) => { console.log("Received node list:", payload.other_nodes); setClusterStatus({ otherNodes: payload.other_nodes, connectedNode: payload.connected_node, + gameNode: payload.game_node, }); }, ); @@ -58,40 +59,61 @@ export const ClusterStatus = () => { : []; return ( -
-
ClusterStatus
-
Channel: {channelStatus}
+
+ +
+ Channel:{" "} + + {channelStatus} + +
+
+ Game Node:{" "} + + {clusterStatus ? clusterStatus.gameNode : "unknown"} + +
+
+
{allNodes.length === 0 ? ( - No nodes available + + No nodes available + ) : ( - <> - {allNodes.map((node) => { - const isConnected = node === clusterStatus?.connectedNode; - return ( -
+ allNodes.map((node) => { + const isConnected = node === clusterStatus?.connectedNode; + return ( +
+ {node} {isConnected && ( - (you) + (you) )} - -
- ); - })} - + + +
+ ); + }) )}
diff --git a/client/src/contexts/GameChannelContext.tsx b/client/src/contexts/GameChannelContext.tsx index 8d820ed..d7ae60d 100644 --- a/client/src/contexts/GameChannelContext.tsx +++ b/client/src/contexts/GameChannelContext.tsx @@ -5,7 +5,6 @@ import { useWebSocketContext } from "./useWebSocketContext"; interface GameChannelContextValue { channel: Channel | null; channelStatus: string; - isJoined: boolean; isOverridden: boolean; joinGame: (name: string) => void; } @@ -23,7 +22,6 @@ export function GameChannelProvider({ }) { const { socket, isConnected } = useWebSocketContext(); const [channelStatus, setChannelStatus] = useState("waiting"); - const [isJoined, setIsJoined] = useState(false); const [isOverridden, setIsOverridden] = useState(false); const [channel, setChannel] = useState(null); @@ -63,7 +61,6 @@ export function GameChannelProvider({ console.log(`Leaving channel: ${channelName}`); newChannel.leave(); setChannel(null); - setIsJoined(false); setChannelStatus("waiting"); }; }, [socket, isConnected, channelName]); @@ -76,12 +73,10 @@ export function GameChannelProvider({ .receive("ok", () => { console.log(`✓ Joined game as: ${name}`); setChannelStatus("joined"); - setIsJoined(true); }) .receive("error", (resp: unknown) => { console.log(`✗ Failed to join game:`, resp); setChannelStatus("join failed"); - setIsJoined(false); }); }; @@ -90,7 +85,6 @@ export function GameChannelProvider({ value={{ channel, channelStatus, - isJoined, isOverridden, joinGame, }} diff --git a/client/src/game/BoardDisplay.tsx b/client/src/game/BoardDisplay.tsx index cc56dbe..78f5ef7 100644 --- a/client/src/game/BoardDisplay.tsx +++ b/client/src/game/BoardDisplay.tsx @@ -16,15 +16,9 @@ const GameStateSchema = z.object({ type Player = z.infer; export const BoardDisplay = ({ playerName }: { playerName: string }) => { - const { channel, isJoined, joinGame } = useGameChannelContext(); + const { channel, joinGame } = useGameChannelContext(); const [players, setPlayers] = useState>({}); - useEffect(() => { - if (channel && !isJoined) { - joinGame(playerName); - } - }, [channel, isJoined, playerName, joinGame]); - useEffect(() => { if (!channel) return; @@ -43,8 +37,22 @@ export const BoardDisplay = ({ playerName }: { playerName: string }) => { }; }, [channel]); + const playerNames = Object.keys(players); + const isJoined = playerNames.includes(playerName); + return (
+ {!isJoined && ( +
+ +
+ )} {Object.entries(players).map(([name, player]) => (