diff --git a/.vscode/settings.json b/.vscode/settings.json index 8e5a45c..055c482 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,8 @@ { - "css.lint.unknownAtRules": "ignore" + "css.lint.unknownAtRules": "ignore", + "elixirLS.projectDir": "backend", + "cSpell.words": [ + "pids", + "whereis" + ] } \ No newline at end of file diff --git a/README.md b/README.md index 42430b5..961343e 100644 --- a/README.md +++ b/README.md @@ -9,4 +9,8 @@ resources: Channels: -- 1 channel per browser instance \ No newline at end of file +- 1 channel per browser instance + + +testing: +- \ No newline at end of file diff --git a/backend/config/test.exs b/backend/config/test.exs index b639ed1..19a1028 100644 --- a/backend/config/test.exs +++ b/backend/config/test.exs @@ -7,8 +7,10 @@ config :backend, BackendWeb.Endpoint, secret_key_base: "K4i8bHJci8dPJ6LuZHJo147F1h8Wq+SaK4trNGLxJ1cRGkZPZvvm1zk5wWEYY+o2", server: false -# In test we don't send emails -config :backend, Backend.Mailer, adapter: Swoosh.Adapters.Test +# Disable OpenTelemetry exporter in tests +config :opentelemetry, + traces_exporter: :none, + processors: [] # Print only warnings and errors during test config :logger, level: :warning diff --git a/backend/lib/backend/game_runner.ex b/backend/lib/backend/game_runner.ex index 8ed4078..815291e 100644 --- a/backend/lib/backend/game_runner.ex +++ b/backend/lib/backend/game_runner.ex @@ -46,6 +46,10 @@ defmodule Backend.GameRunner do GenServer.call(@name, :get_state) end + def get_node_name do + GenServer.call(@name, :get_node_name) + end + # Server Callbacks @impl true @@ -137,6 +141,11 @@ defmodule Backend.GameRunner do {:reply, state, state} end + @impl true + def handle_call(:get_node_name, _from, state) do + {:reply, node(), state} + end + defp broadcast_state(state) do Phoenix.PubSub.broadcast(Backend.PubSub, "game_state", {:game_state_updated, state}) state diff --git a/backend/mix.exs b/backend/mix.exs index 5896977..ff419a8 100644 --- a/backend/mix.exs +++ b/backend/mix.exs @@ -52,7 +52,8 @@ defmodule Backend.MixProject do {:opentelemetry, "~> 1.5"}, {:opentelemetry_exporter, "~> 1.8"}, {:opentelemetry_phoenix, "~> 2.0"}, - {:opentelemetry_telemetry, "~> 1.1"} + {:opentelemetry_telemetry, "~> 1.1"}, + {:mix_test_interactive, "~> 2.1", only: :dev, runtime: false} ] end diff --git a/backend/mix.lock b/backend/mix.lock index c7f1e46..6bd383f 100644 --- a/backend/mix.lock +++ b/backend/mix.lock @@ -5,6 +5,7 @@ "ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, @@ -15,6 +16,7 @@ "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mix_test_interactive": {:hex, :mix_test_interactive, "2.1.0", "4a803209a81761131ed9a737125a0d021596675fb10e7085e2af256810cd3eb8", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "02b1bd5e29a01463a0acc1a9d8f2edc9ba7f46e84e80d5f8049c35df9bc14b55"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "opentelemetry": {:hex, :opentelemetry, "1.7.0", "20d0f12d3d1c398d3670fd44fd1a7c495dd748ab3e5b692a7906662e2fb1a38a", [:rebar3], [{:opentelemetry_api, "~> 1.5.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "a9173b058c4549bf824cbc2f1d2fa2adc5cdedc22aa3f0f826951187bbd53131"}, @@ -41,6 +43,7 @@ "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "tls_certificate_check": {:hex, :tls_certificate_check, "1.31.0", "9a910b54d8cb96cc810cabf4c0129f21360f82022b20180849f1442a25ccbb04", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "9d2b41b128d5507bd8ad93e1a998e06d0ab2f9a772af343f4c00bf76c6be1532"}, + "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, diff --git a/backend/test/backend/game_runner_test.exs b/backend/test/backend/game_runner_test.exs new file mode 100644 index 0000000..5acc217 --- /dev/null +++ b/backend/test/backend/game_runner_test.exs @@ -0,0 +1,108 @@ +defmodule Backend.GameRunnerTest do + use ExUnit.Case, async: true + alias Backend.GameRunner + + describe "update_player_keys" do + test "tracks keys_pressed when player sends keydown event" do + player_id = "player_1" + + initial_state = %{ + players: %{ + player_id => %{x: 100, y: 100, keys_pressed: []} + }, + tick_number: 0 + } + + keys_pressed = ["w"] + + {:noreply, new_state} = + GameRunner.handle_cast({:update_player_keys, player_id, keys_pressed}, initial_state) + + assert new_state.players[player_id].keys_pressed == keys_pressed + end + + test "tracks multiple keys_pressed when player sends multiple keydown events" do + player_id = "player_2" + + initial_state = %{ + players: %{ + player_id => %{x: 200, y: 200, keys_pressed: []} + }, + tick_number: 0 + } + + keys_pressed = ["w", "a"] + + {:noreply, new_state} = + GameRunner.handle_cast({:update_player_keys, player_id, keys_pressed}, initial_state) + + assert new_state.players[player_id].keys_pressed == ["w", "a"] + end + + test "updates keys_pressed to empty list when player sends keyup event" do + player_id = "player_3" + + initial_state = %{ + players: %{ + player_id => %{x: 300, y: 300, keys_pressed: ["w", "d"]} + }, + tick_number: 0 + } + + keys_pressed = [] + + {:noreply, new_state} = + GameRunner.handle_cast({:update_player_keys, player_id, keys_pressed}, initial_state) + + assert new_state.players[player_id].keys_pressed == [] + end + + test "updates keys_pressed when player releases some keys but not all" do + player_id = "player_4" + + initial_state = %{ + players: %{ + player_id => %{x: 400, y: 400, keys_pressed: ["w", "a"]} + }, + tick_number: 0 + } + + keys_pressed = ["w"] + + {:noreply, new_state} = + GameRunner.handle_cast({:update_player_keys, player_id, keys_pressed}, initial_state) + + assert new_state.players[player_id].keys_pressed == ["w"] + end + + test "preserves player position when updating keys_pressed" do + player_id = "player_6" + x = 500 + y = 250 + + initial_state = %{ + players: %{ + player_id => %{x: x, y: y, keys_pressed: []} + }, + tick_number: 0 + } + + keys_pressed = ["d"] + + {:noreply, new_state} = + GameRunner.handle_cast({:update_player_keys, player_id, keys_pressed}, initial_state) + + assert new_state.players[player_id].x == x + assert new_state.players[player_id].y == y + assert new_state.players[player_id].keys_pressed == ["d"] + end + end + + describe "get_node_name" do + test "returns the current node name" do + node_name = GameRunner.get_node_name() + assert node_name == node() + end + end + +end diff --git a/backend/test/backend/node_cluster_integration_test.exs b/backend/test/backend/node_cluster_integration_test.exs new file mode 100644 index 0000000..8ec4067 --- /dev/null +++ b/backend/test/backend/node_cluster_integration_test.exs @@ -0,0 +1,84 @@ +defmodule Backend.NodeClusterIntegrationTests do + use ExUnit.Case, async: false + alias Backend.GameRunner + + describe "GameRunner singleton across a 4-node cluster" do + test "GameRunner runs on exactly one of the four nodes" do + peers = start_cluster(4) + peer_nodes = Enum.map(peers, fn {_p, n} -> n end) + + game_runner_pids = + Enum.map(peers, fn {peer_pid, peer_node_name} -> + runner_pid = :peer.call(peer_pid, :global, :whereis_name, [Backend.GameRunner]) + + assert is_pid(runner_pid), + "#{peer_node_name} could not find GameRunner in :global registry" + + runner_pid + end) + + assert length(Enum.uniq(game_runner_pids)) == 1, + "Peers disagree on GameRunner PID: #{inspect(Enum.uniq(game_runner_pids))}" + + runner_pid = hd(game_runner_pids) + + owner_node = node(runner_pid) + + assert owner_node in peer_nodes, + "Owner node #{owner_node} is not one of the cluster peers #{inspect(peer_nodes)}" + + assert length(peer_nodes -- [owner_node]) == 3, + "Expected 3 non-owner peer nodes, got #{inspect(peer_nodes -- [owner_node])}" + end + end + + defp start_cluster(count) do + peers = + for index <- 1..count do + peer_name = :"cluster_peer_#{System.unique_integer([:positive])}_#{index}" + + {:ok, peer, peer_node} = + :peer.start_link(%{ + name: peer_name, + connection: :standard_io, + wait_boot: 30_000 + }) + + _ = :peer.call(peer, :application, :ensure_all_started, [:elixir]) + + Enum.each(:code.get_path(), fn path -> + _ = :peer.call(peer, :code, :add_patha, [path]) + end) + + # needed for authentication + :peer.call(peer, :erlang, :set_cookie, [Node.get_cookie()]) + + :peer.call(peer, :application, :set_env, [:opentelemetry, :traces_exporter, :none]) + :peer.call(peer, :application, :set_env, [:opentelemetry, :processors, []]) + + on_exit(fn -> + try do + :peer.stop(peer) + catch + :exit, _ -> :ok + end + end) + + {peer, peer_node} + end + + peer_nodes = Enum.map(peers, fn {_p, n} -> n end) + + for {peer, _} <- peers do + Enum.each(peer_nodes, fn target -> + :peer.call(peer, :net_kernel, :connect_node, [target]) + end) + end + + for {peer, _} <- peers do + {:ok, _} = :peer.call(peer, :application, :ensure_all_started, [:backend]) + end + + peers + end +end diff --git a/backend/test/backend/user_channel_test.exs b/backend/test/backend/user_channel_test.exs new file mode 100644 index 0000000..4507c4a --- /dev/null +++ b/backend/test/backend/user_channel_test.exs @@ -0,0 +1,64 @@ +defmodule BackendWeb.ConnectedUserChannelTest do + use ExUnit.Case, async: false + import Phoenix.ChannelTest + + @endpoint BackendWeb.Endpoint + + setup do + user_name = "test_user_#{System.unique_integer([:positive])}" + {:ok, socket} = connect(BackendWeb.UserSocket, %{"user_name" => user_name}) + {:ok, reply, socket} = subscribe_and_join(socket, "user:#{user_name}", %{}) + %{socket: socket, reply: reply, user_name: user_name} + end + + describe "ConnectedUserChannel" do + test "join reply includes current game state", %{reply: reply} do + assert Map.has_key?(reply, :game_state) + end + + test "game_state_updated pubsub broadcast pushes game_state to socket", %{socket: _socket} do + state = %{players: %{"alice" => %{x: 0, y: 0}}} + Phoenix.PubSub.broadcast(Backend.PubSub, "game_state", {:game_state_updated, state}) + assert_push "game_state", %{game_state: ^state} + end + + test "new_browser_connection pubsub broadcast pushes to socket", %{ + socket: _socket, + user_name: user_name + } do + Phoenix.PubSub.broadcast( + Backend.PubSub, + "user_sessions:#{user_name}", + :new_browser_connection + ) + + assert_push "new_browser_connection", %{} + end + + test "join_game message registers player and replies :ok", %{socket: socket} do + ref = push(socket, "join_game", %{"name" => "player1"}) + assert_reply ref, :ok + end + + test "key_down before join_game is a no-op", %{socket: socket} do + ref = push(socket, "key_down", %{"key" => "ArrowUp"}) + refute_reply ref, _ + end + + test "key_down after join_game updates keys and replies nothing", %{socket: socket} do + push(socket, "join_game", %{"name" => "player1"}) + ref = push(socket, "key_down", %{"key" => "ArrowUp"}) + refute_reply ref, _ + end + + test "terminate removes player from game", %{socket: socket} do + push(socket, "join_game", %{"name" => "player_leaving"}) + Process.unlink(socket.channel_pid) + close(socket) + # allow terminate callback to run + :timer.sleep(50) + state = Backend.GameRunner.get_state() + refute Map.has_key?(state, "player_leaving") + end + end +end