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",
"elixirLS.projectDir": "backend",
"cSpell.words": [
"nodedown",
"nodeup",
"pids",
"whereis"
]

View File

@@ -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

View File

@@ -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

View File

@@ -7,7 +7,7 @@ export const ClusterStatus = () => {
const [channelStatus, setChannelStatus] = useState<string>("waiting");
const [channel, setChannel] = useState<Channel | undefined>();
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 (
<div>
<div>ClusterStatus</div>
<div>Channel: {channelStatus}</div>
<div className="bg-navy-800 border border-navy-600 rounded-lg p-4 min-w-64">
<div className=" text-navy-300 mb-3">
Channel:{" "}
<span
className={
channelStatus === "connected" ? "text-navy-200" : "text-navy-500"
}
>
{channelStatus}
</span>
</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 ? (
<span className="text-navy-300">No nodes available</span>
<span className="text-navy-500 text-sm italic">
No nodes available
</span>
) : (
<>
{allNodes.map((node) => {
const isConnected = node === clusterStatus?.connectedNode;
return (
<div
key={node}
className={
isConnected
? "text-navy-100 font-semibold"
: "text-navy-400"
}
>
allNodes.map((node) => {
const isConnected = node === clusterStatus?.connectedNode;
return (
<div
key={node}
className={`flex items-center justify-between gap-2 rounded px-2 py-1 text-sm ${
isConnected ? "bg-navy-700 text-navy-100" : "text-navy-400"
}`}
>
<span className="truncate">
{node}
{isConnected && (
<span className="ml-2 text-xs text-navy-300">(you)</span>
<span className="ml-2 text-xs text-navy-400">(you)</span>
)}
<button
onClick={() => {
channel?.push("kill_node", { node });
}}
>
kill
</button>
</div>
);
})}
</>
</span>
<button
onClick={() => channel?.push("kill_node", { node })}
className={`
text-xs px-2 py-1 rounded border-2
bg-accent-900 border-accent-400 text-accent-200
hover:bg-accent-900 hover:border-accent-500 hover:text-accent-100
transition-all duration-200
`}
>
kill
</button>
</div>
);
})
)}
</div>
</div>

View File

@@ -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<string>("waiting");
const [isJoined, setIsJoined] = useState(false);
const [isOverridden, setIsOverridden] = useState(false);
const [channel, setChannel] = useState<Channel | null>(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,
}}

View File

@@ -16,15 +16,9 @@ const GameStateSchema = z.object({
type Player = z.infer<typeof PlayerSchema>;
export const BoardDisplay = ({ playerName }: { playerName: string }) => {
const { channel, isJoined, joinGame } = useGameChannelContext();
const { channel, joinGame } = useGameChannelContext();
const [players, setPlayers] = useState<Record<string, Player>>({});
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 (
<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]) => (
<BoardPlayer
key={name}