not reseting state on browser disconnect or reconnect

This commit is contained in:
2026-03-03 14:53:59 -07:00
parent 60714f9afd
commit 7eb95af0b8
6 changed files with 119 additions and 96 deletions

View File

@@ -2,6 +2,8 @@
"css.lint.unknownAtRules": "ignore", "css.lint.unknownAtRules": "ignore",
"elixirLS.projectDir": "backend", "elixirLS.projectDir": "backend",
"cSpell.words": [ "cSpell.words": [
"nodedown",
"nodeup",
"pids", "pids",
"whereis" "whereis"
] ]

View File

@@ -8,13 +8,13 @@ defmodule BackendWeb.ClusterStatusChannel do
@impl true @impl true
def join("cluster_status", _params, socket) do def join("cluster_status", _params, socket) do
Logger.info("Client joined clusterstatus channel") Logger.info("Client joined clusterstatus channel")
:net_kernel.monitor_nodes(true)
{:ok, %{status: "connected"}, socket} {:ok, %{status: "connected"}, socket}
end end
@impl true @impl true
def handle_in("get_nodes", _payload, socket) do def handle_in("get_nodes", _payload, socket) do
Logger.info("Client requested node list #{inspect(Node.list())}") send_node_list(socket)
push(socket, "node_list", %{other_nodes: Node.list(), connected_node: node()})
{:noreply, socket} {:noreply, socket}
end end
@@ -29,4 +29,28 @@ defmodule BackendWeb.ClusterStatusChannel do
def handle_in(_event, _payload, socket) do def handle_in(_event, _payload, socket) do
{:noreply, socket} {:noreply, socket}
end 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 end

View File

@@ -20,6 +20,11 @@ defmodule BackendWeb.ConnectedUserChannel do
:new_browser_connection :new_browser_connection
) )
socket =
socket
|> assign(:player_name, socket_user)
|> assign(:keys_pressed, MapSet.new())
current_state = Backend.GameRunner.get_state() current_state = Backend.GameRunner.get_state()
{:ok, %{game_state: current_state}, socket} {:ok, %{game_state: current_state}, socket}
end end
@@ -47,74 +52,42 @@ defmodule BackendWeb.ConnectedUserChannel do
end end
@impl true @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 def handle_in("join_game", %{"name" => name}, socket) do
Logger.info("Player '#{name}' joining game on #{node()}") Logger.info("Player '#{name}' joining game on #{node()}")
socket =
socket
|> assign(:player_name, name)
|> assign(:keys_pressed, MapSet.new())
Backend.GameRunner.add_player(name) Backend.GameRunner.add_player(name)
{:reply, :ok, socket} {:reply, :ok, socket}
end end
@impl true @impl true
def handle_in("key_down", %{"key" => key}, socket) do def handle_in("key_down", %{"key" => key}, %{assigns: %{player_name: player_name}} = socket) do
case socket.assigns[:player_name] do keys_pressed = MapSet.put(socket.assigns[:keys_pressed] || MapSet.new(), key)
nil -> socket = assign(socket, :keys_pressed, keys_pressed)
Logger.warning("Key down attempted without joining game")
{:noreply, socket}
player_name -> Logger.debug(
keys_pressed = MapSet.put(socket.assigns[:keys_pressed] || MapSet.new(), key) "Player '#{player_name}' key down: #{key}, keys: #{inspect(MapSet.to_list(keys_pressed))}"
socket = assign(socket, :keys_pressed, keys_pressed) )
Logger.debug( Backend.GameRunner.update_player_keys(
"Player '#{player_name}' key down: #{key}, keys: #{inspect(MapSet.to_list(keys_pressed))}" player_name,
) MapSet.to_list(keys_pressed)
)
Backend.GameRunner.update_player_keys(player_name, MapSet.to_list(keys_pressed)) {:noreply, socket}
{:noreply, socket}
end
end end
@impl true @impl true
def handle_in("key_up", %{"key" => key}, socket) do def handle_in("key_up", %{"key" => key}, %{assigns: %{player_name: player_name}} = socket) do
case socket.assigns[:player_name] do keys_pressed = MapSet.delete(socket.assigns[:keys_pressed] || MapSet.new(), key)
nil -> socket = assign(socket, :keys_pressed, keys_pressed)
Logger.warning("Key up attempted without joining game")
{:noreply, socket}
player_name -> Logger.debug(
keys_pressed = MapSet.delete(socket.assigns[:keys_pressed] || MapSet.new(), key) "Player '#{player_name}' key up: #{key}, keys: #{inspect(MapSet.to_list(keys_pressed))}"
socket = assign(socket, :keys_pressed, keys_pressed) )
Logger.debug( Backend.GameRunner.update_player_keys(player_name, MapSet.to_list(keys_pressed))
"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)) {:noreply, socket}
{: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
end end
end end

View File

@@ -7,7 +7,7 @@ export const ClusterStatus = () => {
const [channelStatus, setChannelStatus] = useState<string>("waiting"); const [channelStatus, setChannelStatus] = useState<string>("waiting");
const [channel, setChannel] = useState<Channel | undefined>(); const [channel, setChannel] = useState<Channel | undefined>();
const [clusterStatus, setClusterStatus] = useState< const [clusterStatus, setClusterStatus] = useState<
{ otherNodes: string[]; connectedNode: string } | undefined { otherNodes: string[]; connectedNode: string, gameNode: string } | undefined
>(); >();
useEffect(() => { useEffect(() => {
@@ -36,11 +36,12 @@ export const ClusterStatus = () => {
newChannel.on( newChannel.on(
"node_list", "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); console.log("Received node list:", payload.other_nodes);
setClusterStatus({ setClusterStatus({
otherNodes: payload.other_nodes, otherNodes: payload.other_nodes,
connectedNode: payload.connected_node, connectedNode: payload.connected_node,
gameNode: payload.game_node,
}); });
}, },
); );
@@ -58,40 +59,61 @@ export const ClusterStatus = () => {
: []; : [];
return ( return (
<div> <div className="bg-navy-800 border border-navy-600 rounded-lg p-4 min-w-64">
<div>ClusterStatus</div>
<div>Channel: {channelStatus}</div> <div className=" text-navy-300 mb-3">
Channel:{" "}
<span
className={
channelStatus === "connected" ? "text-navy-200" : "text-navy-500"
}
>
{channelStatus}
</span>
</div>
<div> <div>
<div className="text-navy-300 mb-2">
Game Node:{" "}
<span className="text-navy-200">
{clusterStatus ? clusterStatus.gameNode : "unknown"}
</span>
</div>
</div>
<div className="flex flex-col gap-1">
{allNodes.length === 0 ? ( {allNodes.length === 0 ? (
<span className="text-navy-300">No nodes available</span> <span className="text-navy-500 text-sm italic">
No nodes available
</span>
) : ( ) : (
<> allNodes.map((node) => {
{allNodes.map((node) => { const isConnected = node === clusterStatus?.connectedNode;
const isConnected = node === clusterStatus?.connectedNode; return (
return ( <div
<div key={node}
key={node} className={`flex items-center justify-between gap-2 rounded px-2 py-1 text-sm ${
className={ isConnected ? "bg-navy-700 text-navy-100" : "text-navy-400"
isConnected }`}
? "text-navy-100 font-semibold" >
: "text-navy-400" <span className="truncate">
}
>
{node} {node}
{isConnected && ( {isConnected && (
<span className="ml-2 text-xs text-navy-300">(you)</span> <span className="ml-2 text-xs text-navy-400">(you)</span>
)} )}
<button </span>
onClick={() => { <button
channel?.push("kill_node", { node }); onClick={() => channel?.push("kill_node", { node })}
}} className={`
> text-xs px-2 py-1 rounded border-2
kill bg-accent-900 border-accent-400 text-accent-200
</button> hover:bg-accent-900 hover:border-accent-500 hover:text-accent-100
</div> transition-all duration-200
); `}
})} >
</> kill
</button>
</div>
);
})
)} )}
</div> </div>
</div> </div>

View File

@@ -5,7 +5,6 @@ import { useWebSocketContext } from "./useWebSocketContext";
interface GameChannelContextValue { interface GameChannelContextValue {
channel: Channel | null; channel: Channel | null;
channelStatus: string; channelStatus: string;
isJoined: boolean;
isOverridden: boolean; isOverridden: boolean;
joinGame: (name: string) => void; joinGame: (name: string) => void;
} }
@@ -23,7 +22,6 @@ export function GameChannelProvider({
}) { }) {
const { socket, isConnected } = useWebSocketContext(); const { socket, isConnected } = useWebSocketContext();
const [channelStatus, setChannelStatus] = useState<string>("waiting"); const [channelStatus, setChannelStatus] = useState<string>("waiting");
const [isJoined, setIsJoined] = useState(false);
const [isOverridden, setIsOverridden] = useState(false); const [isOverridden, setIsOverridden] = useState(false);
const [channel, setChannel] = useState<Channel | null>(null); const [channel, setChannel] = useState<Channel | null>(null);
@@ -63,7 +61,6 @@ export function GameChannelProvider({
console.log(`Leaving channel: ${channelName}`); console.log(`Leaving channel: ${channelName}`);
newChannel.leave(); newChannel.leave();
setChannel(null); setChannel(null);
setIsJoined(false);
setChannelStatus("waiting"); setChannelStatus("waiting");
}; };
}, [socket, isConnected, channelName]); }, [socket, isConnected, channelName]);
@@ -76,12 +73,10 @@ export function GameChannelProvider({
.receive("ok", () => { .receive("ok", () => {
console.log(`✓ Joined game as: ${name}`); console.log(`✓ Joined game as: ${name}`);
setChannelStatus("joined"); setChannelStatus("joined");
setIsJoined(true);
}) })
.receive("error", (resp: unknown) => { .receive("error", (resp: unknown) => {
console.log(`✗ Failed to join game:`, resp); console.log(`✗ Failed to join game:`, resp);
setChannelStatus("join failed"); setChannelStatus("join failed");
setIsJoined(false);
}); });
}; };
@@ -90,7 +85,6 @@ export function GameChannelProvider({
value={{ value={{
channel, channel,
channelStatus, channelStatus,
isJoined,
isOverridden, isOverridden,
joinGame, joinGame,
}} }}

View File

@@ -16,15 +16,9 @@ const GameStateSchema = z.object({
type Player = z.infer<typeof PlayerSchema>; type Player = z.infer<typeof PlayerSchema>;
export const BoardDisplay = ({ playerName }: { playerName: string }) => { export const BoardDisplay = ({ playerName }: { playerName: string }) => {
const { channel, isJoined, joinGame } = useGameChannelContext(); const { channel, joinGame } = useGameChannelContext();
const [players, setPlayers] = useState<Record<string, Player>>({}); const [players, setPlayers] = useState<Record<string, Player>>({});
useEffect(() => {
if (channel && !isJoined) {
joinGame(playerName);
}
}, [channel, isJoined, playerName, joinGame]);
useEffect(() => { useEffect(() => {
if (!channel) return; if (!channel) return;
@@ -43,8 +37,22 @@ export const BoardDisplay = ({ playerName }: { playerName: string }) => {
}; };
}, [channel]); }, [channel]);
const playerNames = Object.keys(players);
const isJoined = playerNames.includes(playerName);
return ( return (
<div className="relative w-200 h-150 bg-navy-800 my-12.5 mx-auto border-2 border-navy-700 overflow-hidden"> <div className="relative w-200 h-150 bg-navy-800 my-12.5 mx-auto border-2 border-navy-700 overflow-hidden">
{!isJoined && (
<div className="absolute inset-0 flex items-center justify-center bg-navy-900/70 z-10">
<button
onClick={() => joinGame(playerName)}
disabled={!channel}
className="px-8 py-3 bg-accent-600 hover:bg-accent-500 disabled:bg-navy-600 disabled:cursor-not-allowed text-navy-100 font-semibold rounded-lg transition-colors"
>
Join Game
</button>
</div>
)}
{Object.entries(players).map(([name, player]) => ( {Object.entries(players).map(([name, player]) => (
<BoardPlayer <BoardPlayer
key={name} key={name}