diff --git a/apps/game_server_web/lib/game_server_web/application.ex b/apps/game_server_web/lib/game_server_web/application.ex new file mode 100644 index 000000000..56abc3288 --- /dev/null +++ b/apps/game_server_web/lib/game_server_web/application.ex @@ -0,0 +1,12 @@ +defmodule GameServerWeb.Application do + use Application + + @impl true + def start(_type, _args) do + children = [ + GameServerWeb.SignalingBroker + ] + + Supervisor.start_link(children, strategy: :one_for_one, name: GameServerWeb.Supervisor) + end +end diff --git a/apps/game_server_web/lib/game_server_web/channels/signaling_broker.ex b/apps/game_server_web/lib/game_server_web/channels/signaling_broker.ex new file mode 100644 index 000000000..4816c5416 --- /dev/null +++ b/apps/game_server_web/lib/game_server_web/channels/signaling_broker.ex @@ -0,0 +1,577 @@ +defmodule GameServerWeb.SignalingBroker do + @moduledoc """ + Signaling relay for WebRTC peer-to-peer and client-server topologies. + + Rooms are created explicitly by a worker process (e.g. a lobby worker) and + are keyed by the lobby id. Each room stores its topology and, for :star, + the designated host user id. The broker validates membership and topology + rules on every relay. + + Does not create PeerConnections or handle media; only routes SDP offers, + answers, and ICE candidates between registered peers in a room. + + ## Topologies + + * `:mesh` — any member may send an offer/answer/ICE to any other member. + * `:star` — one host peer (the Godot headless server) and client peers. + Clients may only signal to the host; the host may signal to any client. + Non-host peers cannot exchange messages directly. + + Each peer is monitored via `Process.monitor/1`. When a peer crashes or + disconnects it enters a grace period so that reconnections keep the same + user_id. If the grace period expires, the remaining peers are notified. + """ + + use GenServer + require Logger + + defstruct rooms: %{}, refs: %{} + + # ── Public API ─────────────────────────────────────────────────────────── + + def start_link(opts) do + name = Keyword.get(opts, :name, __MODULE__) + GenServer.start_link(__MODULE__, opts, name: name) + end + + @doc """ + Creates a signaling room. `room_id` is typically the lobby id. + + For `:star` topology `host_user_id` is required and designates the user + that will act as the authoritative server peer. + + `allowed_users` is a map of `user_id => role` populated by the lobby hook. + `late_join` controls whether users not in the initial list may join later. + `reconnect_timeout` is the grace period in milliseconds before a + disconnected user is removed. + """ + def create_room(room_id, topology, opts \\ []) when topology in [:mesh, :star] do + host_user_id = if topology == :star, do: Keyword.fetch!(opts, :host_user_id), else: nil + GenServer.call(__MODULE__, {:create_room, room_id, topology, host_user_id, opts}) + end + + @doc """ + Closes a signaling room. Existing peers are notified with a `room_closed` + event so their channels can stop gracefully. + """ + def close_room(room_id) do + GenServer.call(__MODULE__, {:close_room, room_id}) + end + + def room_exists?(room_id) do + GenServer.call(__MODULE__, {:room_exists, room_id}) + end + + @doc """ + Registers a peer in a room using the authenticated `user_id`. + + Returns `{:ok, role}` where `role` is derived from the room topology and + the provided `user_id`. Returns `{:error, :room_not_found}` if the room + does not exist, `{:error, :not_allowed}` if the user is not in the + allowed list and late join is disabled, and `{:ok, role}` on + reconnection. + """ + def join(room_id, user_id, pid, metadata \\ %{}) + when is_binary(room_id) and is_binary(user_id) and is_pid(pid) do + GenServer.call(__MODULE__, {:join, room_id, user_id, pid, metadata}) + end + + def leave(room_id, user_id) when is_binary(room_id) and is_binary(user_id) do + GenServer.call(__MODULE__, {:leave, room_id, user_id}) + end + + @doc """ + Allows a user to join a room after it has been created (late join). + Called by the lobby hook when a new user joins the lobby. + """ + def allow_user(room_id, user_id, role \\ :peer) do + GenServer.call(__MODULE__, {:allow_user, room_id, user_id, role}) + end + + @doc """ + Removes a user from the allowed list and kicks them if connected. + Called by the lobby hook when a user leaves the lobby. + """ + def disallow_user(room_id, user_id) do + GenServer.call(__MODULE__, {:disallow_user, room_id, user_id}) + end + + @doc """ + Routes a signaling message from one peer to a specific target. + + Enforces topology rules: in `:star` mode a non-host peer may only relay + to the host. + """ + def relay(room_id, from_user_id, to_user_id, type, payload) do + GenServer.call(__MODULE__, {:relay, room_id, from_user_id, to_user_id, type, payload}) + end + + @doc """ + Broadcasts a signaling message to every other peer in the room. + + In `:star` mode only the host may broadcast. + """ + def broadcast(room_id, from_user_id, type, payload) do + GenServer.call(__MODULE__, {:broadcast, room_id, from_user_id, type, payload}) + end + + def list_peers(room_id) do + GenServer.call(__MODULE__, {:list_peers, room_id}) + end + + def is_host?(room_id, user_id) do + GenServer.call(__MODULE__, {:is_host, room_id, user_id}) + end + + def get_room(room_id) do + GenServer.call(__MODULE__, {:get_room, room_id}) + end + + def update_room_host(room_id, new_host_user_id) do + GenServer.call(__MODULE__, {:update_room_host, room_id, new_host_user_id}) + end + + # ── GenServer callbacks ────────────────────────────────────────────────── + + @impl true + def init(_opts) do + {:ok, %__MODULE__{}} + end + + @impl true + def handle_call({:create_room, room_id, topology, host_user_id, opts}, _from, state) do + if Map.has_key?(state.rooms, room_id) do + Logger.warning("SignalingBroker: room already exists room=#{room_id}") + {:reply, {:error, :already_exists}, state} + else + allowed_users = Keyword.get(opts, :allowed_users, %{}) + late_join = Keyword.get(opts, :late_join, true) + reconnect_timeout = Keyword.get(opts, :reconnect_timeout, 30_000) + + room = %{ + topology: topology, + host_user_id: host_user_id, + allowed_users: allowed_users, + peers: %{}, + late_join: late_join, + reconnect_timeout: reconnect_timeout + } + + Logger.info("SignalingBroker: room created room=#{room_id} topology=#{topology} host_user_id=#{host_user_id || "none"} allowed_users=#{map_size(allowed_users)} late_join=#{late_join}") + {:reply, :ok, %{state | rooms: Map.put(state.rooms, room_id, room)}} + end + end + + @impl true + def handle_call({:close_room, room_id}, _from, state) do + case Map.pop(state.rooms, room_id) do + {nil, _} -> + Logger.warning("SignalingBroker: close_room for non-existent room=#{room_id}") + {:reply, {:error, :room_not_found}, state} + + {room, rooms} -> + peer_count = map_size(room.peers) + Logger.info("SignalingBroker: closing room=#{room_id} topology=#{room.topology} evicting=#{peer_count}") + + for {user_id, %{pid: pid}} <- room.peers do + Logger.debug("SignalingBroker: sending room_closed to user=#{user_id} pid=#{inspect(pid)}") + send(pid, {:signaling_relay, :room_closed, nil, %{}}) + end + + refs = Enum.reject(state.refs, fn {_ref, {r, _u}} -> r == room_id end) |> Map.new() + + {:reply, :ok, %{state | rooms: rooms, refs: refs}} + end + end + + @impl true + def handle_call({:room_exists, room_id}, _from, state) do + {:reply, Map.has_key?(state.rooms, room_id), state} + end + + @impl true + def handle_call({:allow_user, room_id, user_id, role}, _from, state) do + case Map.get(state.rooms, room_id) do + nil -> + Logger.warning("SignalingBroker: allow_user failed room_not_found room=#{room_id} user=#{user_id}") + {:reply, {:error, :room_not_found}, state} + + room -> + allowed_users = Map.put(room.allowed_users, user_id, role) + room = %{room | allowed_users: allowed_users} + rooms = Map.put(state.rooms, room_id, room) + + Logger.info("SignalingBroker: allowed user room=#{room_id} user=#{user_id} role=#{role}") + {:reply, :ok, %{state | rooms: rooms}} + end + end + + @impl true + def handle_call({:disallow_user, room_id, user_id}, _from, state) do + case Map.get(state.rooms, room_id) do + nil -> + {:reply, {:error, :room_not_found}, state} + + room -> + allowed_users = Map.delete(room.allowed_users, user_id) + room = %{room | allowed_users: allowed_users} + rooms = Map.put(state.rooms, room_id, room) + + {room, rooms, refs} = + if peer = Map.get(room.peers, user_id) do + if peer.disconnect_timer, do: Process.cancel_timer(peer.disconnect_timer) + + {peer, peers} = Map.pop(room.peers, user_id) + send(peer.pid, {:signaling_relay, :room_closed, nil, %{reason: "removed_from_lobby"}}) + + for {other_id, %{pid: other_pid}} <- peers, other_id != user_id do + send(other_pid, {:signaling_relay, :peer_left, user_id, %{user_id: user_id}}) + end + + room = %{room | peers: peers} + rooms = Map.put(rooms, room_id, room) + + ref_entry = Enum.find(state.refs, fn {_ref, {r, u}} -> r == room_id and u == user_id end) + refs = if ref_entry, do: Map.delete(state.refs, elem(ref_entry, 0)), else: state.refs + + {room, rooms, refs} + else + {room, rooms, state.refs} + end + + Logger.info("SignalingBroker: disallowed user room=#{room_id} user=#{user_id}") + {:reply, :ok, %{state | rooms: rooms, refs: refs}} + end + end + + @impl true + def handle_call({:join, room_id, user_id, pid, metadata}, _from, state) do + case Map.get(state.rooms, room_id) do + nil -> + Logger.warning("SignalingBroker: join failed room_not_found room=#{room_id} user=#{user_id}") + {:reply, {:error, :room_not_found}, state} + + room -> + allowed_role = Map.get(room.allowed_users, user_id) + + cond do + is_nil(allowed_role) and not room.late_join -> + Logger.warning("SignalingBroker: join failed not_allowed room=#{room_id} user=#{user_id}") + {:reply, {:error, :not_allowed}, state} + + peer = Map.get(room.peers, user_id) -> + # Reconnection: same user_id reconnecting before the grace period expires. + if peer.disconnect_timer, do: Process.cancel_timer(peer.disconnect_timer) + + ref = Process.monitor(pid) + peer = %{peer | pid: pid, ref: ref, status: :connected, disconnect_timer: nil} + peers = Map.put(room.peers, user_id, peer) + room = %{room | peers: peers} + rooms = Map.put(state.rooms, room_id, room) + refs = Map.put(state.refs, ref, {room_id, user_id}) + + peer_count = map_size(peers) + Logger.info("SignalingBroker: user reconnected room=#{room_id} user=#{user_id} role=#{peer.role} total_peers=#{peer_count}") + + for {other_id, %{pid: other_pid}} <- peers, other_id != user_id do + Logger.debug("SignalingBroker: notifying user=#{other_id} of peer_rejoined user=#{user_id}") + send(other_pid, {:signaling_relay, :peer_rejoined, user_id, %{ + user_id: user_id, + role: peer.role + }}) + end + + {:reply, {:ok, peer.role}, %{state | rooms: rooms, refs: refs}} + + true -> + role = allowed_role || default_role(room, user_id) + ref = Process.monitor(pid) + + peer = %{ + pid: pid, + ref: ref, + user_id: user_id, + role: role, + metadata: metadata, + status: :connected, + joined_at: System.monotonic_time(:second), + disconnect_timer: nil + } + + peers = Map.put(room.peers, user_id, peer) + room = %{room | peers: peers} + rooms = Map.put(state.rooms, room_id, room) + refs = Map.put(state.refs, ref, {room_id, user_id}) + + peer_count = map_size(peers) + Logger.info("SignalingBroker: user joined room=#{room_id} user=#{user_id} role=#{role} total_peers=#{peer_count}") + + # Notify existing peers about the newcomer. + for {other_id, %{pid: other_pid}} <- peers, other_id != user_id do + Logger.debug("SignalingBroker: notifying user=#{other_id} of peer_joined user=#{user_id}") + send(other_pid, {:signaling_relay, :peer_joined, user_id, %{ + user_id: user_id, + role: role + }}) + end + + # Notify the newly joined peer about existing peers so it can initiate + # connections (e.g. star clients connecting to the host). + for {other_id, %{role: other_role}} <- peers, other_id != user_id do + Logger.debug("SignalingBroker: seeding existing peer to new user=#{user_id} other=#{other_id} role=#{other_role}") + send(pid, {:signaling_relay, :peer_joined, other_id, %{ + user_id: other_id, + role: other_role + }}) + end + + {:reply, {:ok, role}, %{state | rooms: rooms, refs: refs}} + end + end + end + + @impl true + def handle_call({:leave, room_id, user_id}, _from, state) do + case Map.get(state.rooms, room_id) do + nil -> + Logger.warning("SignalingBroker: leave failed room_not_found room=#{room_id} user=#{user_id}") + {:reply, {:error, :room_not_found}, state} + + room -> + case Map.pop(room.peers, user_id) do + {nil, _} -> + Logger.warning("SignalingBroker: leave failed peer_not_found room=#{room_id} user=#{user_id}") + {:reply, {:error, :peer_not_found}, state} + + {peer, peers} -> + if peer.disconnect_timer, do: Process.cancel_timer(peer.disconnect_timer) + + peer_count = map_size(peers) + Logger.info("SignalingBroker: user leaving room=#{room_id} user=#{user_id} role=#{peer.role} remaining_peers=#{peer_count}") + + for {other_id, %{pid: other_pid}} <- peers do + send(other_pid, {:signaling_relay, :peer_left, user_id, %{user_id: user_id}}) + end + + room = %{room | peers: peers} + + rooms = + if map_size(peers) == 0 do + Logger.info("SignalingBroker: room empty, removing room=#{room_id}") + Map.delete(state.rooms, room_id) + else + Map.put(state.rooms, room_id, room) + end + + ref_entry = Enum.find(state.refs, fn {_ref, {r, u}} -> r == room_id and u == user_id end) + + refs = + if ref_entry do + Map.delete(state.refs, elem(ref_entry, 0)) + else + state.refs + end + + {:reply, :ok, %{state | rooms: rooms, refs: refs}} + end + end + end + + @impl true + def handle_call({:relay, room_id, from, to, type, payload}, _from, state) do + case Map.get(state.rooms, room_id) do + nil -> + Logger.warning("SignalingBroker: relay failed room_not_found room=#{room_id} from=#{from} to=#{to} type=#{type}") + {:reply, {:error, :room_not_found}, state} + + room -> + from_peer = Map.get(room.peers, from) + to_peer = Map.get(room.peers, to) + + cond do + is_nil(from_peer) -> + Logger.warning("SignalingBroker: relay failed peer_not_found room=#{room_id} from=#{from} to=#{to} type=#{type}") + {:reply, {:error, :peer_not_found}, state} + + is_nil(to_peer) -> + Logger.warning("SignalingBroker: relay failed peer_not_found room=#{room_id} from=#{from} to=#{to} type=#{type}") + {:reply, {:error, :peer_not_found}, state} + + room.topology == :star and from_peer.role != :host and to_peer.role != :host -> + Logger.warning("SignalingBroker: relay failed not_allowed room=#{room_id} from=#{from} role=#{from_peer.role} to=#{to} role=#{to_peer.role}") + {:reply, {:error, :not_allowed}, state} + + true -> + Logger.debug("SignalingBroker: relaying room=#{room_id} type=#{type} from=#{from} to=#{to}") + send(to_peer.pid, {:signaling_relay, type, from, payload}) + {:reply, :ok, state} + end + end + end + + @impl true + def handle_call({:broadcast, room_id, from, type, payload}, _from, state) do + case Map.get(state.rooms, room_id) do + nil -> + Logger.warning("SignalingBroker: broadcast failed room_not_found room=#{room_id} from=#{from} type=#{type}") + {:reply, {:error, :room_not_found}, state} + + room -> + from_peer = Map.get(room.peers, from) + + if is_nil(from_peer) do + Logger.warning("SignalingBroker: broadcast failed peer_not_found room=#{room_id} from=#{from}") + {:reply, {:error, :peer_not_found}, state} + else + if room.topology == :star and from_peer.role != :host do + Logger.warning("SignalingBroker: broadcast failed not_allowed room=#{room_id} from=#{from} role=#{from_peer.role}") + {:reply, {:error, :not_allowed}, state} + else + targets = Enum.filter(room.peers, fn {user_id, _} -> user_id != from end) |> Enum.map(fn {id, _} -> id end) + Logger.debug("SignalingBroker: broadcasting room=#{room_id} type=#{type} from=#{from} targets=#{length(targets)}") + + for {user_id, %{pid: pid}} <- room.peers, user_id != from do + send(pid, {:signaling_relay, type, from, payload}) + end + + {:reply, :ok, state} + end + end + end + end + + @impl true + def handle_call({:list_peers, room_id}, _from, state) do + case Map.get(state.rooms, room_id) do + nil -> + Logger.warning("SignalingBroker: list_peers failed room_not_found room=#{room_id}") + {:reply, {:error, :room_not_found}, state} + + room -> + peers = + Map.new(room.peers, fn {user_id, peer} -> + {user_id, %{user_id: peer.user_id, role: peer.role, metadata: peer.metadata}} + end) + + {:reply, peers, state} + end + end + + def handle_call({:is_host, room_id, user_id}, _from, state) do + case Map.get(state.rooms, room_id) do + nil -> + {:reply, false, state} + + room -> + {:reply, room.host_user_id == user_id, state} + end + end + + def handle_call({:get_room, room_id}, _from, state) do + case Map.get(state.rooms, room_id) do + nil -> + {:reply, {:error, :room_not_found}, state} + + room -> + {:reply, {:ok, %{topology: room.topology, host_user_id: room.host_user_id}}, state} + end + end + + def handle_call({:update_room_host, room_id, new_host_user_id}, _from, state) do + case Map.get(state.rooms, room_id) do + nil -> + {:reply, {:error, :room_not_found}, state} + + room -> + if room.topology != :star do + {:reply, {:error, :not_star}, state} + else + room = %{room | host_user_id: new_host_user_id} + rooms = Map.put(state.rooms, room_id, room) + Logger.info("SignalingBroker: updated host room=#{room_id} host=#{new_host_user_id}") + {:reply, :ok, %{state | rooms: rooms}} + end + end + end + + @impl true + def handle_info({:DOWN, ref, :process, pid, reason}, state) do + case Map.pop(state.refs, ref) do + {nil, _} -> + Logger.debug("SignalingBroker: DOWN from unknown pid=#{inspect(pid)} reason=#{inspect(reason)}") + {:noreply, state} + + {{room_id, user_id}, refs} -> + case Map.get(state.rooms, room_id) do + nil -> + Logger.warning("SignalingBroker: DOWN for removed room room=#{room_id} user=#{user_id} pid=#{inspect(pid)} reason=#{inspect(reason)}") + {:noreply, %{state | refs: refs}} + + room -> + peer = Map.get(room.peers, user_id) + + if peer do + # Start grace period instead of removing immediately so the same + # user_id can reconnect and keep its role. + timer = Process.send_after(self(), {:reconnect_timeout, room_id, user_id}, room.reconnect_timeout) + peer = %{peer | status: :disconnected, disconnect_timer: timer} + peers = Map.put(room.peers, user_id, peer) + room = %{room | peers: peers} + rooms = Map.put(state.rooms, room_id, room) + + Logger.info("SignalingBroker: user disconnected room=#{room_id} user=#{user_id} grace=#{room.reconnect_timeout}ms pid=#{inspect(pid)} reason=#{inspect(reason)}") + {:noreply, %{state | rooms: rooms, refs: refs}} + else + {:noreply, %{state | refs: refs}} + end + end + end + end + + @impl true + def handle_info({:reconnect_timeout, room_id, user_id}, state) do + case Map.get(state.rooms, room_id) do + nil -> + {:noreply, state} + + room -> + peer = Map.get(room.peers, user_id) + + if peer && peer.status == :disconnected do + {_peer, peers} = Map.pop(room.peers, user_id) + remaining = map_size(peers) + Logger.info("SignalingBroker: reconnect timeout expired room=#{room_id} user=#{user_id} remaining_peers=#{remaining}") + + for {other_id, %{pid: other_pid}} <- peers, other_id != user_id do + send(other_pid, {:signaling_relay, :peer_left, user_id, %{user_id: user_id}}) + end + + rooms = + if map_size(peers) == 0 do + Logger.info("SignalingBroker: room empty after timeout, removing room=#{room_id}") + Map.delete(state.rooms, room_id) + else + Map.put(state.rooms, room_id, %{room | peers: peers}) + end + + {:noreply, %{state | rooms: rooms}} + else + {:noreply, state} + end + end + end + + @impl true + def handle_info(_msg, state) do + {:noreply, state} + end + + # ── Private helpers ───────────────────────────────────────────────────── + + defp default_role(room, user_id) do + case room.topology do + :mesh -> :peer + :star -> if user_id == room.host_user_id, do: :host, else: :client + end + end +end diff --git a/apps/game_server_web/lib/game_server_web/channels/signaling_channel.ex b/apps/game_server_web/lib/game_server_web/channels/signaling_channel.ex new file mode 100644 index 000000000..82d014a2b --- /dev/null +++ b/apps/game_server_web/lib/game_server_web/channels/signaling_channel.ex @@ -0,0 +1,307 @@ +defmodule GameServerWeb.SignalingChannel do + @moduledoc """ + Channel for WebRTC signaling relay. + + Topic: `signaling:` + + Rooms are created by the `WebRTCLobbyHook` through `SignalingBroker.create_room/3`. + The allowed-user list is populated by the hook, so this channel does not + need to query the lobby system. The topology and host are fixed at room + creation; clients cannot choose their role. + + ## Lifecycle + + On join the authenticated `user_id` is used directly as the peer identity. + The broker assigns the role (`:host` or `:client` for `:star`, `:peer` for + `:mesh`) based on the room's configuration. If the same user_id reconnects + within the configured grace period, the existing peer is preserved and a + `peer_rejoined` event is broadcast. + + ## Messages + + Inbound events (from client): + + push("offer", %{target: "user-uuid", sdp: "..."}) + push("answer", %{target: "user-uuid", sdp: "..."}) + push("ice", %{target: "user-uuid", candidate: "..."}) + push("broadcast_offer", %{sdp: "..."}) + + Outbound events (to client): + + "offer" — %{sdp: "...", from_user_id: "..."} + "answer" — %{sdp: "...", from_user_id: "..."} + "ice" — %{candidate: "...", from_user_id: "..."} + "peer_joined" — %{user_id: "...", role: :host | :client | :peer} + "peer_rejoined" — %{user_id: "...", role: :host | :client | :peer} + "peer_left" — %{user_id: "..."} + "room_closed" — %{} + """ + + use Phoenix.Channel + + import GameServerWeb.ChannelPush + require Logger + + alias GameServerWeb.SignalingBroker + + # WebSocket message rate limits (per user) — defaults, overridden by config + @default_ws_rate_limit 300 + @default_ws_rate_window :timer.seconds(10) + + # Separate ICE candidate budget — prevents ICE flooding from starving + # other channel events. A typical WebRTC session sends 5–30 candidates. + @default_ice_rate_limit 150 + @default_ice_rate_window :timer.seconds(30) + + @impl true + def join("signaling:" <> room_id, _payload, socket) do + user_id = socket.assigns.current_scope.user_id + + if is_nil(user_id) do + Logger.warning("SignalingChannel: unauthorized join attempt room=#{room_id} missing user_id") + {:error, %{reason: "unauthorized"}} + else + case SignalingBroker.join(room_id, user_id, self(), %{}) do + {:ok, role} -> + Logger.info("SignalingChannel: join ok room=#{room_id} user=#{user_id} role=#{role}") + {:ok, %{user_id: user_id, role: role}, + assign(socket, + signaling_room: room_id, + signaling_user_id: user_id, + signaling_role: role + )} + + {:error, :room_not_found} -> + Logger.warning("SignalingChannel: join failed room_not_found room=#{room_id} user=#{user_id}") + {:error, %{reason: "room_not_found"}} + + {:error, :not_allowed} -> + Logger.warning("SignalingChannel: join failed not_allowed room=#{room_id} user=#{user_id}") + {:error, %{reason: "not_allowed"}} + + {:error, reason} -> + Logger.warning("SignalingChannel: join failed reason=#{reason} room=#{room_id} user=#{user_id}") + {:error, %{reason: to_string(reason)}} + end + end + end + + # ── Signaling relay ────────────────────────────────────────────────────── + + @impl true + def handle_in("offer", %{"target" => target, "sdp" => sdp}, socket) do + with :ok <- check_ws_rate_limit(socket) do + room = socket.assigns.signaling_room + from = socket.assigns.signaling_user_id + + case SignalingBroker.relay(room, from, target, :offer, %{sdp: sdp}) do + :ok -> + {:reply, {:ok, %{}}, socket} + + {:error, :peer_not_found} -> + Logger.warning("SignalingChannel: offer failed peer_not_found room=#{room} from=#{from} target=#{target}") + {:reply, {:error, %{error: "peer_not_found"}}, socket} + + {:error, :not_allowed} -> + Logger.warning("SignalingChannel: offer failed not_allowed room=#{room} from=#{from} target=#{target}") + {:reply, {:error, %{error: "not_allowed"}}, socket} + + {:error, :room_not_found} -> + Logger.warning("SignalingChannel: offer failed room_not_found room=#{room} from=#{from}") + {:stop, :normal, {:error, %{error: "room_not_found"}}, socket} + end + end + end + + @impl true + def handle_in("answer", %{"target" => target, "sdp" => sdp}, socket) do + with :ok <- check_ws_rate_limit(socket) do + room = socket.assigns.signaling_room + from = socket.assigns.signaling_user_id + + case SignalingBroker.relay(room, from, target, :answer, %{sdp: sdp}) do + :ok -> + {:reply, {:ok, %{}}, socket} + + {:error, :peer_not_found} -> + Logger.warning("SignalingChannel: answer failed peer_not_found room=#{room} from=#{from} target=#{target}") + {:reply, {:error, %{error: "peer_not_found"}}, socket} + + {:error, :not_allowed} -> + Logger.warning("SignalingChannel: answer failed not_allowed room=#{room} from=#{from} target=#{target}") + {:reply, {:error, %{error: "not_allowed"}}, socket} + + {:error, :room_not_found} -> + Logger.warning("SignalingChannel: answer failed room_not_found room=#{room} from=#{from}") + {:stop, :normal, {:error, %{error: "room_not_found"}}, socket} + end + end + end + + @impl true + def handle_in("ice", %{"target" => target, "candidate" => candidate}, socket) do + with :ok <- check_ice_rate_limit(socket) do + room = socket.assigns.signaling_room + from = socket.assigns.signaling_user_id + + case SignalingBroker.relay(room, from, target, :ice, %{candidate: candidate}) do + :ok -> + {:reply, {:ok, %{}}, socket} + + {:error, :peer_not_found} -> + Logger.warning("SignalingChannel: ice failed peer_not_found room=#{room} from=#{from} target=#{target}") + {:reply, {:error, %{error: "peer_not_found"}}, socket} + + {:error, :not_allowed} -> + Logger.warning("SignalingChannel: ice failed not_allowed room=#{room} from=#{from} target=#{target}") + {:reply, {:error, %{error: "not_allowed"}}, socket} + + {:error, :room_not_found} -> + Logger.warning("SignalingChannel: ice failed room_not_found room=#{room} from=#{from}") + {:stop, :normal, {:error, %{error: "room_not_found"}}, socket} + end + end + end + + @impl true + def handle_in("broadcast_offer", %{"sdp" => sdp}, socket) do + with :ok <- check_ws_rate_limit(socket) do + room = socket.assigns.signaling_room + from = socket.assigns.signaling_user_id + + case SignalingBroker.broadcast(room, from, :offer, %{sdp: sdp, from_user_id: from}) do + :ok -> + {:reply, {:ok, %{}}, socket} + + {:error, :not_allowed} -> + Logger.warning("SignalingChannel: broadcast_offer failed not_allowed room=#{room} from=#{from}") + {:reply, {:error, %{error: "not_allowed"}}, socket} + + {:error, :room_not_found} -> + Logger.warning("SignalingChannel: broadcast_offer failed room_not_found room=#{room} from=#{from}") + {:stop, :normal, {:error, %{error: "room_not_found"}}, socket} + end + end + end + + @impl true + def handle_in("list_peers", _payload, socket) do + with :ok <- check_ws_rate_limit(socket) do + room = socket.assigns.signaling_room + + case SignalingBroker.list_peers(room) do + peers when is_map(peers) -> + {:reply, {:ok, %{peers: peers}}, socket} + + {:error, :room_not_found} -> + Logger.warning("SignalingChannel: list_peers failed room_not_found room=#{room}") + {:stop, :normal, {:error, %{error: "room_not_found"}}, socket} + end + end + end + + @impl true + def handle_in(event, _payload, socket) do + Logger.warning("SignalingChannel: unknown event=#{event} room=#{socket.assigns[:signaling_room] || "nil"} user=#{socket.assigns[:signaling_user_id] || "nil"}") + {:reply, {:error, %{error: "unknown_event"}}, socket} + end + + # ── Broker relay messages ──────────────────────────────────────────────── + + @impl true + def handle_info({:signaling_relay, :room_closed, nil, payload}, socket) do + Logger.info("SignalingChannel: room_closed received, stopping room=#{socket.assigns.signaling_room} user=#{socket.assigns.signaling_user_id}") + push_event(socket, "room_closed", payload) + {:stop, :normal, socket} + end + + @impl true + def handle_info({:signaling_relay, type, from_user_id, payload}, socket) do + event_name = relay_event_name(type) + payload = if is_nil(from_user_id), do: payload, else: Map.put(payload, :from_user_id, from_user_id) + push_event(socket, event_name, payload) + {:noreply, socket} + end + + @impl true + def handle_info({:channel_updates_flush, _}, socket) do + {:noreply, socket} + end + + @impl true + def handle_info(msg, socket) do + Logger.debug("SignalingChannel: unexpected msg=#{inspect(msg)} room=#{socket.assigns[:signaling_room] || "nil"} user=#{socket.assigns[:signaling_user_id] || "nil"}") + {:noreply, socket} + end + + @impl true + def terminate(reason, socket) do + room_id = socket.assigns[:signaling_room] + user_id = socket.assigns[:signaling_user_id] + + if room_id && user_id do + Logger.info("SignalingChannel: terminating reason=#{inspect(reason)} room=#{room_id} user=#{user_id}") + # Do NOT call SignalingBroker.leave here. The broker's DOWN handler + # starts a grace period so the same user_id can reconnect and keep + # its role. Explicit leave is only used for intentional removal. + else + Logger.debug("SignalingChannel: terminating without room/user reason=#{inspect(reason)}") + end + + :ok + end + + # ── Private helpers ─────────────────────────────────────────────────────── + + defp relay_event_name(:offer), do: "offer" + defp relay_event_name(:answer), do: "answer" + defp relay_event_name(:ice), do: "ice" + defp relay_event_name(:peer_joined), do: "peer_joined" + defp relay_event_name(:peer_rejoined), do: "peer_rejoined" + defp relay_event_name(:peer_left), do: "peer_left" + defp relay_event_name(:room_closed), do: "room_closed" + + # ── WebSocket rate limiting ───────────────────────────────────────────── + + defp check_ws_rate_limit(socket) do + config = Application.get_env(:game_server_web, GameServerWeb.Plugs.RateLimiter, []) + + if Keyword.get(config, :enabled, true) do + user_id = socket.assigns.current_scope.user_id + limit = Keyword.get(config, :signaling_ws_limit, @default_ws_rate_limit) + window = Keyword.get(config, :signaling_ws_window, @default_ws_rate_window) + + case GameServerWeb.RateLimit.hit("signaling_ws:#{user_id}", window, limit) do + {:allow, _count} -> + :ok + + {:deny, _retry_after} -> + Logger.warning("SignalingChannel: rate limit exceeded user=#{user_id} room=#{socket.assigns[:signaling_room] || "nil"}") + {:stop, :normal, {:error, %{error: "rate_limited"}}, socket} + end + else + :ok + end + end + + defp check_ice_rate_limit(socket) do + config = Application.get_env(:game_server_web, GameServerWeb.Plugs.RateLimiter, []) + + if Keyword.get(config, :enabled, true) do + user_id = socket.assigns.current_scope.user_id + limit = Keyword.get(config, :signaling_ice_limit, @default_ice_rate_limit) + window = Keyword.get(config, :signaling_ice_window, @default_ice_rate_window) + + case GameServerWeb.RateLimit.hit("signaling_ice:#{user_id}", window, limit) do + {:allow, _count} -> + :ok + + {:deny, _retry_after} -> + Logger.warning("SignalingChannel: ICE rate limit exceeded user=#{user_id} room=#{socket.assigns[:signaling_room] || "nil"}") + {:reply, {:error, %{error: "ice_rate_limited"}}, socket} + end + else + :ok + end + end +end diff --git a/apps/game_server_web/lib/game_server_web/channels/user_socket.ex b/apps/game_server_web/lib/game_server_web/channels/user_socket.ex index 506cb23b9..3b9617e62 100644 --- a/apps/game_server_web/lib/game_server_web/channels/user_socket.ex +++ b/apps/game_server_web/lib/game_server_web/channels/user_socket.ex @@ -23,7 +23,8 @@ defmodule GameServerWeb.UserSocket do "Global lobby list: created/updated/deleted, membership counts"}, {"group:*", GameServerWeb.GroupChannel, "One group: state, members, join requests, chat"}, {"groups", GameServerWeb.GroupsChannel, "Global group list: created/updated/deleted"}, - {"party:*", GameServerWeb.PartyChannel, "One party: state, members, chat, disband"} + {"party:*", GameServerWeb.PartyChannel, "One party: state, members, chat, disband"}, + {"signaling:*", GameServerWeb.SignalingChannel, "Signaling channel for WebRTC"} ] for {pattern, module, _description} <- @channels do diff --git a/apps/game_server_web/lib/game_server_web/signaling.ex b/apps/game_server_web/lib/game_server_web/signaling.ex new file mode 100644 index 000000000..5b053a6d6 --- /dev/null +++ b/apps/game_server_web/lib/game_server_web/signaling.ex @@ -0,0 +1,19 @@ +defmodule GameServer.Signaling do + @moduledoc """ + Public API for the WebRTC signaling broker. + + This module is exposed to plugins through the SDK. All operations are + delegated to the internal `GameServerWeb.SignalingBroker` process. + """ + + alias GameServerWeb.SignalingBroker + + defdelegate create_room(room_id, topology, opts \\ []), to: SignalingBroker + defdelegate close_room(room_id), to: SignalingBroker + defdelegate room_exists?(room_id), to: SignalingBroker + defdelegate allow_user(room_id, user_id, role \\ :peer), to: SignalingBroker + defdelegate disallow_user(room_id, user_id), to: SignalingBroker + defdelegate list_peers(room_id), to: SignalingBroker + defdelegate get_room(room_id), to: SignalingBroker + defdelegate update_room_host(room_id, new_host_user_id), to: SignalingBroker +end diff --git a/apps/game_server_web/mix.exs b/apps/game_server_web/mix.exs index f2f6c5c17..802dd8928 100644 --- a/apps/game_server_web/mix.exs +++ b/apps/game_server_web/mix.exs @@ -24,6 +24,7 @@ defmodule GameServerWeb.MixProject do def application do [ + mod: {GameServerWeb.Application, []}, extra_applications: [:logger] ] end diff --git a/modules/plugins/.gitignore b/modules/plugins/.gitignore new file mode 100644 index 000000000..e9d9cf718 --- /dev/null +++ b/modules/plugins/.gitignore @@ -0,0 +1,2 @@ +ebin/ +_build/ diff --git a/modules/plugins/webrtc_lobby_hook/.formatter.exs b/modules/plugins/webrtc_lobby_hook/.formatter.exs new file mode 100644 index 000000000..d304ff320 --- /dev/null +++ b/modules/plugins/webrtc_lobby_hook/.formatter.exs @@ -0,0 +1,3 @@ +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/modules/plugins/webrtc_lobby_hook/.gitignore b/modules/plugins/webrtc_lobby_hook/.gitignore new file mode 100644 index 000000000..914532603 --- /dev/null +++ b/modules/plugins/webrtc_lobby_hook/.gitignore @@ -0,0 +1,3 @@ +/_build/ +/deps/ +/ebin/ diff --git a/modules/plugins/webrtc_lobby_hook/README.md b/modules/plugins/webrtc_lobby_hook/README.md new file mode 100644 index 000000000..bfffb2dc2 --- /dev/null +++ b/modules/plugins/webrtc_lobby_hook/README.md @@ -0,0 +1,85 @@ +# WebRTC Lobby Hook + +Keeps a Phoenix WebRTC signaling room in sync with a game lobby. When a lobby has WebRTC enabled in its metadata, this hook automatically creates, updates and tears down a matching signaling room managed by the SignalingBroker. + +## What it does + +- Creates a signaling room automatically when a WebRTC-enabled lobby is created. +- Closes the signaling room when the lobby is deleted. +- Syncs the allowed-user list with lobby joins and leaves. +- Notifies the designated star-topology host so a headless server can join the signaling channel automatically. +- Supports late joins and role assignment (host, client, peer). + +## Supported topologies + +| Topology | Behaviour | +|---|---| +| star | One host and many clients. The host is notified via user: with webrtc:room_ready. Clients send offers to the host. | +| mesh | All participants are peers. No automatic host notification. | + +## Lobby metadata configuration + +Set metadata.webrtc on the lobby: + +```Elixir +%{ + "webrtc" => %{ + "enabled" => true, + "topology" => "star", + "host_user_id" => "star-topology-server-user-id", + "late_join" => true, + "reconnect_timeout" => 30000 + } +} +``` + +### Options + +- enabled — Set to true to create the signaling room. +- topology — star or mesh. +- host_user_id — Optional. In star mode this user is assigned the :host role. Falls back to lobby.host_id. +- late_join — Allow users who join the lobby later to enter the signaling room. Defaults to true. +- reconnect_timeout — Grace period for disconnected peers in milliseconds. Defaults to 30000. + +## Hook callbacks + +| Callback | Purpose | +|---|---| +| before_lobby_create/1 | Injects default WebRTC metadata into lobby attributes if missing. | +| after_lobby_create/1 | Creates the signaling room and seeds the allowed-user list from lobby members. | +| after_lobby_updated/1 | Creates or closes the room if WebRTC is toggled. | +| after_lobby_deleted/1 | Closes the signaling room. | +| after_lobby_join/2 | Allows the joining user into the signaling room with the correct role. | +| after_lobby_leave/2 | Removes the user from the signaling room. | +| after_lobby_host_change/2 | Updates the star host and notifies the new host. | + +## Star topology specifics + +1. The host is resolved in this order: metadata.webrtc.host_user_id -> lobby.host_id. +2. The host is always added to the allowed-user list even if it is not a lobby member (useful for headless servers). +3. When the room is created, the host receives a broadcast on user:: + +```Elixir +%{ + "lobby_id" => lobby_id, + "topology" => "star", + "host_user_id" => host_user_id, + "signaling_topic" => "signaling:#{lobby_id}" +} +``` + +## Role assignment + +Roles are assigned automatically: + +- star + matching host ID -> :host +- star + anyone else -> :client +- mesh -> :peer + +The SignalingBroker uses these roles to enforce who can send offers and answers. + +## Notes + +- The hook normalizes all metadata keys to strings so that Phoenix changesets and Ecto interop cleanly. +- The signaling room ID is the same as the lobby ID, so they are always 1-to-1. +- If the signaling room already exists when a lobby is updated, the hook leaves it alone. diff --git a/modules/plugins/webrtc_lobby_hook/config/config.exs b/modules/plugins/webrtc_lobby_hook/config/config.exs new file mode 100644 index 000000000..5b66c9106 --- /dev/null +++ b/modules/plugins/webrtc_lobby_hook/config/config.exs @@ -0,0 +1,3 @@ +import Config + +# No runtime configuration required for this example plugin. diff --git a/modules/plugins/webrtc_lobby_hook/lib/game_server/modules/webrtc_lobby_hook.ex b/modules/plugins/webrtc_lobby_hook/lib/game_server/modules/webrtc_lobby_hook.ex new file mode 100644 index 000000000..c2b779636 --- /dev/null +++ b/modules/plugins/webrtc_lobby_hook/lib/game_server/modules/webrtc_lobby_hook.ex @@ -0,0 +1,239 @@ +defmodule GameServer.Modules.WebRTCLobbyHook do + @moduledoc """ + Keeps a WebRTC signaling room in sync with a lobby. + + This is the only module that connects the lobby system to the WebRTC + signaling layer. When a lobby has `metadata.webrtc.enabled = true`, a + signaling room with the same id as the lobby is created automatically. + The room is closed when the lobby is deleted, and the allowed-user list + is kept in sync with lobby joins and leaves. + + When a star-topology room is created, the designated host is notified on + its user channel (`user:`) with a `webrtc:room_ready` event + so a headless server can connect automatically. + + Configuration is read from `lobby.metadata.webrtc`: + + %{ + "enabled" => true, + "topology" => "star" | "mesh", + "late_join" => true, + "reconnect_timeout" => 30000, + "host_user_id" => "optional-server-user-id" + } + + In `:star` mode the host is resolved in this order: + 1. `metadata.webrtc.host_user_id` + 2. `lobby.host_id` + + The allowed-user list is seeded from the lobby members at creation time. + Late joiners are added via `after_lobby_join/2`. + """ + + use GameServer.Hooks + + require Logger + + alias GameServer.Signaling + alias GameServer.Lobbies + alias GameServerWeb.Endpoint + + # Force WebRTC Star for all lobbies. + @impl true + def before_lobby_create(attrs) do + metadata = Map.get(attrs, :metadata) || Map.get(attrs, "metadata") || %{} + + metadata = + Map.new(metadata, fn {k, v} -> + {to_string(k), v} + end) + + webrtc_meta = %{ + "webrtc" => %{ + "enabled" => true, + "topology" => "star", + "late_join" => true, + "reconnect_timeout" => 30000, + "host_user_id" => "example_host_id" + } + } + + new_metadata = Map.merge(metadata, webrtc_meta) + + metadata_key = + cond do + Map.has_key?(attrs, "metadata") -> "metadata" + Map.has_key?(attrs, :metadata) -> :metadata + true -> "metadata" + end + + new_attrs = Map.put(attrs, metadata_key, new_metadata) + + {:ok, new_attrs} + end + + @impl true + def after_lobby_create(lobby) do + ensure_room(lobby) + end + + @impl true + def after_lobby_updated(lobby) do + # If WebRTC is enabled later, create the room. If disabled, close it. + ensure_room(lobby) + end + + @impl true + def after_lobby_deleted(lobby) do + Logger.info("WebRTC: closing signaling room for deleted lobby=#{lobby.id}") + Signaling.close_room(lobby.id) + end + + @impl true + def after_lobby_join(user, lobby) do + # Late join: allow the user into the signaling room. + role = role_for(user.id, lobby) + Logger.info("WebRTC: late join allowed lobby=#{lobby.id} user=#{user.id} role=#{role}") + Signaling.allow_user(lobby.id, user.id, role) + end + + @impl true + def after_lobby_leave(user, lobby) do + Logger.info("WebRTC: user left lobby, removing from signaling room lobby=#{lobby.id} user=#{user.id}") + Signaling.disallow_user(lobby.id, user.id) + end + + @impl true + def after_lobby_host_change(lobby, new_host_id) do + # In star topology, update the host user id when the lobby host changes. + with {:ok, %{topology: :star}} <- Signaling.get_room(lobby.id) do + Logger.info("WebRTC: updating star host lobby=#{lobby.id} host=#{new_host_id}") + Signaling.allow_user(lobby.id, new_host_id, :host) + Signaling.update_room_host(lobby.id, new_host_id) + notify_host_ready(lobby.id, :star, new_host_id) + else + _ -> :ok + end + end + + # ── Private helpers ───────────────────────────────────────────────────── + + defp ensure_room(lobby) do + with %{"webrtc" => %{"enabled" => true, "topology" => topology}} <- lobby.metadata, + topology_atom <- parse_topology(topology), + {:ok, host_user_id} <- resolve_host(lobby, topology_atom) do + if Signaling.room_exists?(lobby.id) do + :ok + else + allowed_users = build_allowed_users(lobby, host_user_id, topology_atom) + late_join = get_in(lobby.metadata, ["webrtc", "late_join"]) || true + reconnect_timeout = get_in(lobby.metadata, ["webrtc", "reconnect_timeout"]) || 30_000 + + Logger.info("WebRTC: creating signaling room lobby=#{lobby.id} topology=#{topology} host=#{host_user_id} allowed_users=#{map_size(allowed_users)}") + + :ok = Signaling.create_room(lobby.id, topology_atom, + host_user_id: host_user_id, + allowed_users: allowed_users, + late_join: late_join, + reconnect_timeout: reconnect_timeout + ) + + # Notify the host so a headless server can join automatically. + if topology_atom == :star do + notify_host_ready(lobby.id, topology_atom, host_user_id) + end + + :ok + end + else + _ -> + # WebRTC not enabled or invalid config; close room if it exists. + if Signaling.room_exists?(lobby.id) do + Signaling.close_room(lobby.id) + end + + :ok + end + end + + # Broadcasts a notification to the host's user channel so the headless + # server can connect to the signaling room automatically. + defp notify_host_ready(lobby_id, topology, host_user_id) do + Logger.info("WebRTC: notifying host user=#{host_user_id} of ready room=#{lobby_id}") + + Endpoint.broadcast("user:#{host_user_id}", "webrtc:room_ready", %{ + "lobby_id" => lobby_id, + "topology" => to_string(topology), + "host_user_id" => host_user_id, + "signaling_topic" => "signaling:#{lobby_id}" + }) + + Logger.info("WebRTC: notifying host=#{host_user_id} about signaling room lobby=#{lobby_id}") + end + + defp build_allowed_users(lobby, host_user_id, topology) do + # Returns lobby members to build the list of allowed users. + members = Lobbies.get_lobby_members(lobby) + + # Convert everything to strings so Ecto UUIDs, binaries and atoms all + # interop cleanly. The host_user_id is always forced to :host. + host_id = to_string(host_user_id) + + users = + Map.new(members, fn member -> + id = to_string(member.id) + + role = + cond do + topology == :star and id == host_id -> :host + topology == :star -> :client + true -> :peer + end + + {id, role} + end) + + # Ensure the dedicated host is allowed even if it is not a lobby member. + # This is critical for headless servers that never join the lobby itself. + users = + if topology == :star and is_binary(host_user_id) and host_user_id != "" do + Map.put(users, host_id, :host) + else + users + end + + Logger.info("WebRTC: build_allowed_users host=#{host_id} users=#{inspect(users)}") + users + end + + defp role_for(user_id, lobby) do + webrtc = lobby.metadata["webrtc"] || %{} + topology = parse_topology(webrtc["topology"] || "mesh") + host_user_id = webrtc["host_user_id"] || lobby.host_id + + cond do + topology == :star and to_string(user_id) == to_string(host_user_id) -> :host + topology == :star -> :client + true -> :peer + end + end + + defp resolve_host(%{metadata: %{"webrtc" => %{"host_user_id" => host_id}}}, :star) + when is_binary(host_id) and host_id != "", + do: {:ok, host_id} + + defp resolve_host(%{host_id: host_id}, :star) + when is_binary(host_id) and host_id != "", + do: {:ok, host_id} + + defp resolve_host(_, :star), do: {:error, :no_host_for_star} + defp resolve_host(_, :mesh), do: {:ok, nil} + + defp parse_topology("star"), do: :star + defp parse_topology("mesh"), do: :mesh + + defp parse_topology(other) do + Logger.warning("WebRTC: unknown topology=#{other}, defaulting to mesh") + :mesh + end +end diff --git a/modules/plugins/webrtc_lobby_hook/mix.exs b/modules/plugins/webrtc_lobby_hook/mix.exs new file mode 100644 index 000000000..d149b6937 --- /dev/null +++ b/modules/plugins/webrtc_lobby_hook/mix.exs @@ -0,0 +1,33 @@ +defmodule HandleWebRTC.WebRTCLobbyHook do + use Mix.Project + + def project do + [ + app: :webrtc_lobby_hook, + version: "0.1.1", + elixir: "~> 1.20", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger], + env: [hooks_module: GameServer.Modules.WebRTCLobbyHook] + ] + end + + # NOTE: This example lives inside the main server repo, so we depend on the + # in-repo SDK via a path dependency. + defp deps do + [ + {:game_server_sdk, path: "../../../sdk", runtime: false, optional: true}, + {:game_server_plugin_tools, path: "../../../sdk_tools", runtime: false}, + {:bunt, "~> 1.0"}, + {:phoenix, "~> 1.8.3"}, + # Typed hook payloads (see proto/example_hook.proto). + #{:protobuf, "~> 0.17"} + ] + end +end diff --git a/modules/plugins/webrtc_lobby_hook/mix.lock b/modules/plugins/webrtc_lobby_hook/mix.lock new file mode 100644 index 000000000..20a9d4a30 --- /dev/null +++ b/modules/plugins/webrtc_lobby_hook/mix.lock @@ -0,0 +1,13 @@ +%{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "phoenix": {:hex, :phoenix, "1.8.9", "a63ed0962ed5b903b146dab0ae8eb8387fe478f8171a5e26d56a165f35996fe1", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "3477e2dd5a4f61820341169031bdfe21275f659923bea9c5c0ea2aa1c3fcc046"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "protobuf": {:hex, :protobuf, "0.17.0", "39e24e43c9648e148feba16ed51100b5b2028ea900b55460377b0476f6e10613", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "ca6c91f6f63e2c147b47f03eefd10b80538aa6fc55ff4b12b795efb786b0152f"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [: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", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, +} diff --git a/sdk/lib/game_server/signaling.ex b/sdk/lib/game_server/signaling.ex new file mode 100644 index 000000000..a39858b16 --- /dev/null +++ b/sdk/lib/game_server/signaling.ex @@ -0,0 +1,12 @@ +defmodule GameServer.Signaling do + @moduledoc "SDK stub for GameServer.Signaling." + + def create_room(_room_id, _topology, _opts \\ []), do: :ok + def close_room(_room_id), do: :ok + def room_exists?(_room_id), do: false + def allow_user(_room_id, _user_id, _role \\ :peer), do: :ok + def disallow_user(_room_id, _user_id), do: :ok + def list_peers(_room_id), do: %{} + def get_room(_room_id), do: {:error, :room_not_found} + def update_room_host(_room_id, _new_host_user_id), do: :ok +end