using System; using System.Collections.Generic; using FishNet; using FishNet.Managing; using Steamworks; using UnityEngine; using Ashwild.Player; namespace Ashwild.Network { /// /// The one place that bridges Steam friend invites into the game. It does three things: /// • SEND — while in a session, keeps a private "carrier" Steam lobby (one per local player, all /// advertising the same host connect code) so the pause-menu "Invite" button can open Steam's /// lobby invite overlay. Any session member — host or client — can invite their own friends, /// and every invite still points at the single host's room (see NetworkSessionManager.MaxPlayers). /// • PRESENCE — publishes the session's connect code as Steam Rich Presence while online, so /// friends see "Join Game" in their friends list and that path carries the code JoinSession reads. /// • RECEIVE — routes Steam's join paths to match the player's intent: /// – LobbyInvite_t (invitation just ARRIVED) → raises PlayerEvents.InviteReceived so the menu /// pops the invitation card; the player joins with one in-game click, no Alt-Tab to Steam. /// – GameLobbyJoinRequested_t / GameRichPresenceJoinRequested_t (player explicitly clicked /// "Join" inside Steam) → joins the session directly, skipping the card. /// Also resolves friend avatars (async via Steam) for the invitation card. /// Place this on the persistent FishNet NetworkManager GameObject, beside NetworkSessionManager. /// [DisallowMultipleComponent] public class SteamInviteService : MonoBehaviour { #region Constants /// /// Steam Rich Presence / lobby-data key that makes the "Join Game" entry appear for friends /// and carries the connect code an incoming friend uses to reach our session. /// private const string ConnectKey = "connect"; /// /// Fallback member cap for the carrier lobby when the session manager is unavailable. /// private const int FallbackMemberLimit = 6; #endregion #region State /// /// Global access point used by the invite button and the invite popup. /// public static SteamInviteService Instance { get; private set; } /// /// Steam callback fired when a friend clicks "Join Game" via our Rich Presence connect string. /// private Callback joinRequestedCallback; /// /// Steam callback fired when the local player clicks "Join" on a lobby invite inside Steam. /// private Callback lobbyJoinRequestedCallback; /// /// Steam callback fired the moment a friend's lobby invitation ARRIVES (before any accept). /// private Callback lobbyInviteCallback; /// /// Steam callback fired when an avatar image finishes downloading from Steam's servers. /// private Callback avatarLoadedCallback; /// /// Async result of CreateLobby — writes the connect code into the new lobby once it exists. /// private CallResult lobbyCreatedResult; /// /// The local player's carrier lobby while in a session (Nil when none). Exists only so the /// Steam overlay has a lobby to invite friends to; nobody actually stays in it. /// private CSteamID currentLobby; /// /// Connect code to stamp onto the carrier lobby once CreateLobby completes. /// private string pendingLobbyCode; /// /// Avatar requests still waiting on Steam to finish downloading the image, by SteamID64. /// private readonly Dictionary> pendingAvatars = new Dictionary>(); /// /// True once the Steam callbacks have been registered (guards the deferred init). /// private bool callbacksReady; #endregion #region Unity Lifecycle /// /// Establishes the singleton; the GameObject persists via the NetworkManager itself. /// private void Awake() { if (Instance != null && Instance != this) { Destroy(this); return; } Instance = this; } /// /// Subscribes to the session bus and registers the Steam callbacks (once Steam is up). /// private void OnEnable() { PlayerEvents.SessionStarted += HandleSessionOnline; PlayerEvents.SessionJoined += HandleSessionJoined; PlayerEvents.SessionStopped += HandleSessionStopped; EnsureCallbacks(); } /// /// Unsubscribes from the bus, tears down the carrier lobby, and disposes the Steam callbacks — /// mirrors OnEnable. /// private void OnDisable() { PlayerEvents.SessionStarted -= HandleSessionOnline; PlayerEvents.SessionJoined -= HandleSessionJoined; PlayerEvents.SessionStopped -= HandleSessionStopped; LeaveCarrierLobby(); joinRequestedCallback?.Dispose(); lobbyJoinRequestedCallback?.Dispose(); lobbyInviteCallback?.Dispose(); avatarLoadedCallback?.Dispose(); lobbyCreatedResult?.Dispose(); joinRequestedCallback = null; lobbyJoinRequestedCallback = null; lobbyInviteCallback = null; avatarLoadedCallback = null; lobbyCreatedResult = null; callbacksReady = false; pendingAvatars.Clear(); } /// /// Retries callback registration until Steam is initialized, then stops checking. /// private void Update() { if (!callbacksReady) EnsureCallbacks(); } /// /// Clears the singleton reference and leaves any carrier lobby still open. /// private void OnDestroy() { LeaveCarrierLobby(); if (Instance == this) Instance = null; } #endregion #region Public API /// /// Opens the Steam overlay so the player can invite a friend to the current session. Prefers /// the lobby invite dialog (so the friend gets an in-game invitation card via LobbyInvite_t); /// falls back to the plain connect-string dialog if the carrier lobby isn't ready. Requires /// being in a session (host or client) so there is a code to share. /// public void OpenInviteOverlay() { if (!SteamManager.Initialized) { Debug.LogError("[SteamInvite] Steam is not initialized — cannot open the invite overlay.", this); return; } string code = PlayerEvents.SessionCode; if (string.IsNullOrEmpty(code)) { Debug.LogWarning("[SteamInvite] No active session — nothing to invite a friend to yet.", this); return; } if (currentLobby.IsValid()) SteamFriends.ActivateGameOverlayInviteDialog(currentLobby); else SteamFriends.ActivateGameOverlayInviteDialogConnectString(code); } /// /// Resolves a friend's avatar as a Sprite. Returns it immediately via the callback when /// Steam already has it cached, otherwise waits for Steam to download it (may never fire /// if the friend has no avatar — callers should keep their fallback in that case). /// public void RequestAvatar(ulong steamId, Action onReady) { if (onReady == null) return; if (!SteamManager.Initialized) { onReady(null); return; } CSteamID id = new CSteamID(steamId); int handle = SteamFriends.GetLargeFriendAvatar(id); if (handle > 0) { onReady(SteamAvatarUtil.BuildSprite(handle)); return; } // -1 means Steam is fetching it from its servers → wait for AvatarImageLoaded_t. // 0 means the friend simply has no avatar set. if (handle == -1) pendingAvatars[steamId] = onReady; else onReady(null); } #endregion #region Steam Callbacks /// /// A friend's lobby invitation just ARRIVED — surface it to the menu popup so the player can /// join with one in-game click. The connect code is read from the inviter's Rich Presence, /// which every session member publishes; an empty read means we can't build a joinable card. /// private void OnLobbyInvite(LobbyInvite_t cb) { CSteamID inviter = new CSteamID(cb.m_ulSteamIDUser); string connect = ResolveConnectFromFriend(inviter); if (string.IsNullOrEmpty(connect)) { Debug.LogWarning($"[SteamInvite] Lobby invite from {cb.m_ulSteamIDUser} had no readable connect code — ignoring.", this); return; } string inviterName = inviter.IsValid() ? SteamFriends.GetFriendPersonaName(inviter) : "Un ami"; Debug.Log($"[SteamInvite] Invitation reçue de {inviterName} (connect={connect}).", this); PlayerEvents.RaiseInviteReceived(cb.m_ulSteamIDUser, inviterName, connect); } /// /// The player clicked "Join" on a lobby invite inside Steam — an explicit intent, so we join /// the session directly and skip the invitation card. /// private void OnLobbyJoinRequested(GameLobbyJoinRequested_t cb) { string connect = ResolveConnectFromFriend(cb.m_steamIDFriend); if (string.IsNullOrEmpty(connect)) { Debug.LogWarning("[SteamInvite] Lobby join request had no readable connect code — ignoring.", this); return; } JoinDirect(connect); } /// /// A friend clicked "Join Game" via our Rich Presence connect string — an explicit intent, so /// we join directly rather than popping the invitation card. /// private void OnJoinRequested(GameRichPresenceJoinRequested_t cb) { string connect = cb.m_rgchConnect; if (string.IsNullOrEmpty(connect)) { Debug.LogWarning("[SteamInvite] Join request had an empty connect string — ignoring.", this); return; } JoinDirect(connect); } /// /// The carrier lobby finished creating — record it and stamp the connect code onto it so a /// joining friend could also recover the code from lobby data if needed. /// private void OnLobbyCreated(LobbyCreated_t cb, bool ioFailure) { if (ioFailure || cb.m_eResult != EResult.k_EResultOK) { Debug.LogWarning($"[SteamInvite] Failed to create the invite lobby (result={cb.m_eResult}, ioFailure={ioFailure}). Invites fall back to the connect string.", this); return; } currentLobby = new CSteamID(cb.m_ulSteamIDLobby); if (!string.IsNullOrEmpty(pendingLobbyCode)) SteamMatchmaking.SetLobbyData(currentLobby, ConnectKey, pendingLobbyCode); } /// /// A requested avatar finished downloading — complete the matching pending request. /// private void OnAvatarLoaded(AvatarImageLoaded_t cb) { ulong id = cb.m_steamID.m_SteamID; if (!pendingAvatars.TryGetValue(id, out Action onReady)) return; pendingAvatars.Remove(id); onReady(SteamAvatarUtil.BuildSprite(cb.m_iImage)); } #endregion #region Event Handlers /// /// Host came online — advertise the session code and open a carrier lobby friends can be invited to. /// private void HandleSessionOnline(string code) { PublishConnect(code); CreateCarrierLobby(code); } /// /// Joined a host — advertise the same code and open a carrier lobby so this client can also /// invite its own friends into the host's room. /// private void HandleSessionJoined() { string code = PlayerEvents.SessionCode; PublishConnect(code); CreateCarrierLobby(code); } /// /// Session ended — stop advertising as joinable and tear down the carrier lobby. /// private void HandleSessionStopped() { if (SteamManager.Initialized) SteamFriends.SetRichPresence(ConnectKey, null); LeaveCarrierLobby(); } #endregion #region Internal Helpers /// /// Registers the Steam callbacks once Steam is initialized; safe to call repeatedly. /// private void EnsureCallbacks() { if (callbacksReady || !SteamManager.Initialized) return; joinRequestedCallback = Callback.Create(OnJoinRequested); lobbyJoinRequestedCallback = Callback.Create(OnLobbyJoinRequested); lobbyInviteCallback = Callback.Create(OnLobbyInvite); avatarLoadedCallback = Callback.Create(OnAvatarLoaded); lobbyCreatedResult = CallResult.Create(OnLobbyCreated); callbacksReady = true; } /// /// Writes the connect code into Steam Rich Presence (no-op when Steam/code is missing). /// private void PublishConnect(string code) { if (!SteamManager.Initialized || string.IsNullOrEmpty(code)) return; SteamFriends.SetRichPresence(ConnectKey, code); } /// /// Opens (or refreshes) the private carrier lobby that the invite overlay hangs its friend /// invites off. Friends-only and capped at the room's player limit; the connect code is stamped /// on once LobbyCreated returns. No-op without Steam or a code; refreshes data if one already exists. /// private void CreateCarrierLobby(string code) { if (!SteamManager.Initialized || string.IsNullOrEmpty(code)) return; pendingLobbyCode = code; if (currentLobby.IsValid()) { SteamMatchmaking.SetLobbyData(currentLobby, ConnectKey, code); return; } int memberLimit = NetworkSessionManager.Instance != null ? NetworkSessionManager.Instance.MaxPlayers : FallbackMemberLimit; SteamAPICall_t call = SteamMatchmaking.CreateLobby(ELobbyType.k_ELobbyTypeFriendsOnly, memberLimit); lobbyCreatedResult?.Set(call); } /// /// Leaves and forgets the carrier lobby, if one is open. /// private void LeaveCarrierLobby() { pendingLobbyCode = null; if (!currentLobby.IsValid()) return; if (SteamManager.Initialized) SteamMatchmaking.LeaveLobby(currentLobby); currentLobby = CSteamID.Nil; } /// /// Reads the connect code a session member publishes via Rich Presence. Returns empty when the /// friend is invalid, offline, or hasn't published one (they may not be in a session). /// private string ResolveConnectFromFriend(CSteamID friend) { if (!SteamManager.Initialized || !friend.IsValid()) return string.Empty; return SteamFriends.GetFriendRichPresence(friend, ConnectKey); } /// /// Connects straight to a session by its code — used when the player expressed an explicit /// "Join" intent inside Steam. Ignored (with a log) when already in a session so a stray Steam /// click can't tear down a running game. /// private void JoinDirect(string code) { if (IsAlreadyInSession()) { Debug.LogWarning("[SteamInvite] Ignoring a Steam join request — already in a session.", this); return; } if (NetworkSessionManager.Instance == null) { Debug.LogError("[SteamInvite] Cannot join — NetworkSessionManager missing.", this); return; } GameSession.Mode = GameSession.LaunchMode.Join; GameSession.JoinCode = code; NetworkSessionManager.Instance.JoinSession(code); } /// /// True when a client or server connection is already up, so we shouldn't start a fresh join. /// private bool IsAlreadyInSession() { NetworkManager nm = InstanceFinder.NetworkManager; if (nm == null) return false; return nm.ClientManager.Started || nm.ServerManager.Started; } #endregion } }