using System.Net.WebSockets; using LiteDB; using Newtonsoft.Json; using Campus_live_rewrite.api.server; using Campus_live_rewrite.api.server.Classes.db_class; using Campus_live_rewrite.Controllers; using static Campus_live_rewrite.api.WebsocketEvents; using static Campus_live_rewrite.Controllers.AccountsController; namespace Campus_live_rewrite.api.dbs { public class FriendsDB { public static LiteDatabase PlayerFriendDBFile = new LiteDatabase(Environment.CurrentDirectory + "/Data/DBs/PlayersFriends.db"); // ========================== // GET ALL RELATIONSHIPS // ========================== public static List GetRelationships(ulong playerId) { var col = PlayerFriendDBFile.GetCollection("PlayersFriends"); List list = new(); // Read the caller's OWN side of each pair (PlayerID == caller). The client's // RelationshipResponse is projected from the caller's point of view: PlayerID is the // OTHER player, RelationshipType/Favorited/Ignored/Muted are the CALLER's own values // (RecFlare's toResponse does exactly this). // // This used to read the OTHER player's row instead (filter OtherPlayerID == caller), // which returned that player's flags and, worse, the OTHER side's request direction - // so a friend request you SENT showed up as one you RECEIVED (and the flags a favorite // will set below would have landed on the wrong person's list). foreach (var data in col.FindAll()) { if (data.Relationship == null || data.Relationship.PlayerID != playerId) continue; list.Add(new RelationshipDetail { Id = data.Relationship.Id, PlayerID = data.Relationship.OtherPlayerID, // the OTHER player (client's shape) OtherPlayerID = playerId, // the caller RelationshipType = data.Relationship.RelationshipType, Muted = data.Relationship.Muted, Ignored = data.Relationship.Ignored, Favorited = data.Relationship.Favorited, VoiceVolume = data.Relationship.VoiceVolume }); } return list; } // ========================== // BLOCK ("ignore" on the wire). Unlike favorite / mute this lands on BOTH sides of the // pair: the blocker's row gets Local, the blocked player's row gets Remote (Mutual when // both have blocked each other), so each of them reads Ignored = true and their clients // hide and mute the other. Only the blocker's own part is theirs to lift: the blocked // player's "unblock" cannot clear a Remote, so a block stays until the player who set it // takes it off. Blocking also ends a friendship / pending request between the two. // Both players get a live RelationshipChanged frame (id 1) - which is what the client // repaints its card / friends list from - plus a website refresh. // ========================== public static RelationshipDetail SetBlock(ulong caller, ulong target, bool value) { var col = PlayerFriendDBFile.GetCollection("PlayersFriends"); var mine = GetOrCreateRow(col, caller, target); var theirs = GetOrCreateRow(col, target, caller); static ReciprocalStatus With(ReciprocalStatus s, ReciprocalStatus part, bool on) { bool local = s == ReciprocalStatus.Local || s == ReciprocalStatus.Mutual; bool remote = s == ReciprocalStatus.Remote || s == ReciprocalStatus.Mutual; if (part == ReciprocalStatus.Local) local = on; else remote = on; return local && remote ? ReciprocalStatus.Mutual : local ? ReciprocalStatus.Local : remote ? ReciprocalStatus.Remote : ReciprocalStatus.None; } bool wasBlocking = mine.Relationship.Ignored == ReciprocalStatus.Local || mine.Relationship.Ignored == ReciprocalStatus.Mutual; mine.Relationship.Ignored = With(mine.Relationship.Ignored, ReciprocalStatus.Local, value); theirs.Relationship.Ignored = With(theirs.Relationship.Ignored, ReciprocalStatus.Remote, value); bool unfriended = false; if (value && (mine.Relationship.RelationshipType != RelationshipType.None || theirs.Relationship.RelationshipType != RelationshipType.None)) { mine.Relationship.RelationshipType = RelationshipType.None; theirs.Relationship.RelationshipType = RelationshipType.None; unfriended = true; } col.Update(mine); col.Update(theirs); if (unfriended) { try { RemoveFriendInviteMessage(caller, target); RemoveFriendInviteMessage(target, caller); } catch { } try { SitePrefsDB.Forget(caller, target); SitePrefsDB.Forget(target, caller); } catch { } // nickname / home pin go with the friendship } string what = value ? (wasBlocking ? "re-blocked" : "blocked") : (wasBlocking ? "unblocked" : "asked to unblock (not theirs to lift)"); Console.WriteLine($"[block] {caller} {what} {target}: mine={mine.Relationship.Ignored} theirs={theirs.Relationship.Ignored}{(unfriended ? " (friendship / request ended)" : "")}"); // Live to both sides: the frame the client's RelationshipChanged handler repaints // from, in each player's own point of view. Notifications.FireAndForget(Notifications.SendToPlayer(caller, JsonConvert.SerializeObject(new Reponse { Id = "1", Msg = RelationshipView(mine.Relationship) })), "block changed (self)"); Notifications.FireAndForget(Notifications.SendToPlayer(target, JsonConvert.SerializeObject(new Reponse { Id = "1", Msg = RelationshipView(theirs.Relationship) })), "block changed (other)"); Notifications.FireAndForget(Notifications.SendHubInvocation(caller, "RelationshipsInvalid"), "block invalidate (self)"); Notifications.FireAndForget(Notifications.SendHubInvocation(target, "RelationshipsInvalid"), "block invalidate (other)"); SiteNotifications.Notify(caller); SiteNotifications.Notify(target); // Each now sees the other as offline (PresenceIsBlocked); refresh what they see. Notifications.FireAndForget(Notifications.PushPresenceTo(caller, target), "block presence refresh (self)"); Notifications.FireAndForget(Notifications.PushPresenceTo(target, caller), "block presence refresh (other)"); return new RelationshipDetail { Id = mine.Relationship.Id, PlayerID = target, OtherPlayerID = caller, RelationshipType = mine.Relationship.RelationshipType, Muted = mine.Relationship.Muted, Ignored = mine.Relationship.Ignored, Favorited = mine.Relationship.Favorited, VoiceVolume = mine.Relationship.VoiceVolume }; } /// The caller's voice volume for one player (their own side of the row). public static void SetVoiceVolume(ulong caller, ulong target, int volume) { var col = PlayerFriendDBFile.GetCollection("PlayersFriends"); var row = GetOrCreateRow(col, caller, target); row.Relationship.VoiceVolume = volume; col.Update(row); } /// True when either of the two has blocked the other. public static bool IsBlockedEitherWay(ulong a, ulong b) { if (a == 0 || b == 0 || a == b) return false; var col = PlayerFriendDBFile.GetCollection("PlayersFriends"); var row = col.FindOne(x => x.PlayerID == a && x.Relationship.OtherPlayerID == b); if (row?.Relationship != null && row.Relationship.Ignored != ReciprocalStatus.None) return true; var other = col.FindOne(x => x.PlayerID == b && x.Relationship.OtherPlayerID == a); return other?.Relationship != null && other.Relationship.Ignored != ReciprocalStatus.None; } /// True when has blocked (their own Local part). public static bool HasBlocked(ulong blocker, ulong other) { var col = PlayerFriendDBFile.GetCollection("PlayersFriends"); var row = col.FindOne(x => x.PlayerID == blocker && x.Relationship.OtherPlayerID == other); return row?.Relationship != null && (row.Relationship.Ignored == ReciprocalStatus.Local || row.Relationship.Ignored == ReciprocalStatus.Mutual); } private static YourFriendData GetOrCreateRow(ILiteCollection col, ulong owner, ulong other) { var row = col.FindOne(x => x.PlayerID == owner && x.Relationship.OtherPlayerID == other); if (row != null) return row; row = new YourFriendData { Id = ObjectId.NewObjectId(), PlayerID = owner, Relationship = new RelationshipDetail { Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), PlayerID = owner, OtherPlayerID = other, RelationshipType = RelationshipType.None, Favorited = ReciprocalStatus.None, Ignored = ReciprocalStatus.None, Muted = ReciprocalStatus.None, VoiceVolume = 100 } }; col.Insert(row); return row; } // ========================== // SET A PER-SIDE FLAG (favorite / ignore / mute) on the CALLER's own side. // Mirrors RecFlare's setRelationshipFlag: the write lands on the caller's side of the // pair, and flagging someone you have no relationship with creates a bare (None) row to // hold the flag - so you can favorite/ignore/mute a player you were never friends with. // Value maps to ReciprocalStatus.Local (set, serializes as 1) or None (clear, 0), which // is the 0/1 the client reads for each flag. Returns the caller-POV relationship. // ========================== public static RelationshipDetail SetRelationshipFlag(ulong caller, ulong target, FriendInfo flag, bool value) { var col = PlayerFriendDBFile.GetCollection("PlayersFriends"); var row = col.FindOne(x => x.PlayerID == caller && x.Relationship.OtherPlayerID == target); if (row == null) { row = new YourFriendData { Id = ObjectId.NewObjectId(), PlayerID = caller, Relationship = new RelationshipDetail { Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), PlayerID = caller, OtherPlayerID = target, RelationshipType = RelationshipType.None, Favorited = ReciprocalStatus.None, Ignored = ReciprocalStatus.None, Muted = ReciprocalStatus.None, VoiceVolume = 100 } }; col.Insert(row); } var status = value ? ReciprocalStatus.Local : ReciprocalStatus.None; switch (flag) { case FriendInfo.Favorited: row.Relationship.Favorited = status; break; case FriendInfo.Muted: row.Relationship.Muted = status; break; case FriendInfo.Ignored: row.Relationship.Ignored = status; break; } col.Update(row); // Realtime: the caller's game re-reads its relationships (favorite star, mute/ignore // state) and their website tabs refresh - the same channels a friend request uses. // A favorite also changes what the caller may SEE of the target ("Appear Online To: // Favorites"), so re-send that one presence to them with the new relationship applied. Notifications.FireAndForget(Notifications.SendHubInvocation(caller, "RelationshipsInvalid"), "relationship-flag invalidate"); Notifications.FireAndForget(Notifications.SendToPlayer(caller, JsonConvert.SerializeObject(new Reponse { Id = "1", Msg = RelationshipView(row.Relationship) })), "relationship-flag changed"); SiteNotifications.Notify(caller); if (flag == FriendInfo.Favorited) Notifications.FireAndForget(Notifications.PushPresenceTo(caller, target), "favorite presence refresh"); // Return in the caller's POV shape (PlayerID = the other player), the same projection // GetRelationships uses, so the caller can apply it directly. return new RelationshipDetail { Id = row.Relationship.Id, PlayerID = target, OtherPlayerID = caller, RelationshipType = row.Relationship.RelationshipType, Muted = row.Relationship.Muted, Ignored = row.Relationship.Ignored, Favorited = row.Relationship.Favorited, VoiceVolume = row.Relationship.VoiceVolume }; } // ========================== // COUNT ONLINE FRIENDS - the caller's accepted friends that currently hold a live // notification websocket. Replaces the hardcoded int.MaxValue/0 stub. // ========================== public static int CountOnlineFriends(ulong playerId) { return GetRelationships(playerId) .Where(r => r.RelationshipType == RelationshipType.Friend) .Select(r => r.PlayerID) .Distinct() .Count(fid => CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(fid)); } // ========================== // SEND FRIEND REQUEST // ========================== public static RelationshipDetail SendFriendRequest(ulong fromPlayer, ulong toPlayer) { var col = PlayerFriendDBFile.GetCollection("PlayersFriends"); // Blocked either way: nothing is sent and nobody is told; the caller just gets their // current (None) relationship back. if (IsBlockedEitherWay(fromPlayer, toPlayer)) { Console.WriteLine($"[friends] {fromPlayer} -> {toPlayer}: friend request refused, one has blocked the other"); var blockedRow = GetOrCreateRow(col, fromPlayer, toPlayer).Relationship; return new RelationshipDetail { Id = blockedRow.Id, PlayerID = toPlayer, OtherPlayerID = fromPlayer, RelationshipType = blockedRow.RelationshipType, Muted = blockedRow.Muted, Ignored = blockedRow.Ignored, Favorited = blockedRow.Favorited, VoiceVolume = blockedRow.VoiceVolume }; } // ---- A -> B ---- var relA = col.FindOne(x => x.PlayerID == fromPlayer && x.Relationship.OtherPlayerID == toPlayer ); if (relA == null) { relA = new YourFriendData { Id = ObjectId.NewObjectId(), PlayerID = fromPlayer, Relationship = new RelationshipDetail { Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), PlayerID = fromPlayer, OtherPlayerID = toPlayer, RelationshipType = RelationshipType.None, Favorited = ReciprocalStatus.None, Ignored = ReciprocalStatus.None, Muted = ReciprocalStatus.None, VoiceVolume = 100 } }; col.Insert(relA); } // ---- B -> A ---- var relB = col.FindOne(x => x.PlayerID == toPlayer && x.Relationship.OtherPlayerID == fromPlayer ); if (relB == null) { relB = new YourFriendData { Id = ObjectId.NewObjectId(), PlayerID = toPlayer, Relationship = new RelationshipDetail { Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), PlayerID = toPlayer, OtherPlayerID = fromPlayer, RelationshipType = RelationshipType.None, Favorited = ReciprocalStatus.None, Ignored = ReciprocalStatus.None, Muted = ReciprocalStatus.None, VoiceVolume = 100 } }; col.Insert(relB); } // Update types relA.Relationship.RelationshipType = RelationshipType.FriendRequestSent; relB.Relationship.RelationshipType = RelationshipType.FriendRequestReceived; col.Update(relA); col.Update(relB); //todo: send ws event to anounce the player that it got a new friend request // Websocket pushes. fromPlayer is always the SENDER, toPlayer the RECIPIENT (both // callers pass that order). // - sender: their side is now "Sent" (relationship changed -> shows Pending). // - recipient: the incoming request, as BOTH a relationship change (so the friends // UI updates live) AND a message notification " sent you a friend request". // The message used to be sent to the SENDER, so the recipient's game never got the // heads-up and the request only showed on a later fetch. Notifications.SendToPlayer( fromPlayer, JsonConvert.SerializeObject(CreateResponseFriendRequestSent(toPlayer)) ); Notifications.SendToPlayer( toPlayer, JsonConvert.SerializeObject(CreateResponseFriendRequestReceived(fromPlayer, toPlayer)) ); // A stored FriendInvite message for the recipient: the in-game notifications list is // /api/messages/v2/get, so a request that only ever went out as a live frame vanished // from it on the next open - "friend requests don't show unless you search for the // player". One per sender: re-sending replaces rather than stacks. Removed again when // the request is accepted, declined or cancelled (RemoveFriendInviteMessage). RemoveFriendInviteMessage(toPlayer, fromPlayer); var inviteMsg = PlayerDB.AddMessage(toPlayer, fromPlayer, WebsocketEvents.MessageType.FriendInvite, ""); if (inviteMsg != null) Notifications.FireAndForget(Notifications.SendToPlayer(toPlayer, JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(inviteMsg))), "friend-request message"); // (The extra "FriendInvite" message frame that used to go out here as well showed up // as a SECOND notification for the same request - one alert per request now.) // Fire the client's OWN hub handlers (proven by the 2023 server) so the request shows // in-game live: FriendRequestReceived raises the alert on the recipient, and // RelationshipsInvalid makes BOTH sides re-fetch /api/relationships so the incoming // request and the sender's "pending" state appear without a relog. Notifications.FireAndForget(Notifications.SendHubInvocation(toPlayer, "FriendRequestReceived", (long)fromPlayer), "friend-request alert"); Notifications.FireAndForget(Notifications.SendHubInvocation(toPlayer, "RelationshipChanged", RelationshipView(relB.Relationship)), "friend-request changed (recipient)"); Notifications.FireAndForget(Notifications.SendHubInvocation(fromPlayer, "RelationshipChanged", RelationshipView(relA.Relationship)), "friend-request changed (sender)"); Notifications.FireAndForget(Notifications.SendHubInvocation(toPlayer, "RelationshipsInvalid"), "friend-request invalidate (recipient)"); Notifications.FireAndForget(Notifications.SendHubInvocation(fromPlayer, "RelationshipsInvalid"), "friend-request invalidate (sender)"); return relA.Relationship; } /// /// Befriend with no pending-request step (the in-game handshake). A pending request either /// way, or an existing friendship, is simply accepted / re-confirmed; otherwise the pair is /// minted as a request from and accepted in the same call, so both /// sides end up Friend with the usual "relationship changed" pushes. /// public static RelationshipDetail? AddFriendDirect(ulong me, ulong other) { if (me == 0 || other == 0 || me == other) return null; var accepted = AcceptFriendRequest(other, me); if (accepted != null) return accepted; SendFriendRequest(me, other); return AcceptFriendRequest(me, other); } public static RelationshipDetail? AcceptFriendRequest(ulong fromPlayer, ulong toPlayer) { if (fromPlayer == 0 || toPlayer == 0 || fromPlayer == toPlayer) return null; // The request is being answered: its notification goes, whichever side held it. RemoveFriendInviteMessage(fromPlayer, toPlayer); RemoveFriendInviteMessage(toPlayer, fromPlayer); var col = PlayerFriendDBFile.GetCollection("PlayersFriends"); var relA = col.FindOne(x => x.PlayerID == fromPlayer && x.Relationship.OtherPlayerID == toPlayer); var relB = col.FindOne(x => x.PlayerID == toPlayer && x.Relationship.OtherPlayerID == fromPlayer); static bool IsRequest(YourFriendData? r) => r?.Relationship != null && (r.Relationship.RelationshipType == RelationshipType.FriendRequestSent || r.Relationship.RelationshipType == RelationshipType.FriendRequestReceived); static bool IsFriend(YourFriendData? r) => r?.Relationship != null && r.Relationship.RelationshipType == RelationshipType.Friend; // There must be a real, pending request on at least one side (or an already-formed // friendship we're just re-confirming). This blocks "accepting" a request nobody sent - // otherwise the row-healing below would let anyone force-friend a stranger. if (!IsRequest(relA) && !IsRequest(relB) && !IsFriend(relA) && !IsFriend(relB)) return null; // Heal a half-formed pair: if one side's row is missing (e.g. a partial write or an // orphan cleanup removed it), recreate it so acceptance ALWAYS ends in a mutual // friendship rather than silently doing nothing. Mirrors SendFriendRequest's row shape. YourFriendData MakeRow(ulong owner, ulong other) => new YourFriendData { Id = ObjectId.NewObjectId(), PlayerID = owner, Relationship = new RelationshipDetail { Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), PlayerID = owner, OtherPlayerID = other, RelationshipType = RelationshipType.None, Favorited = ReciprocalStatus.None, Ignored = ReciprocalStatus.None, Muted = ReciprocalStatus.None, VoiceVolume = 100 } }; if (relA == null) { relA = MakeRow(fromPlayer, toPlayer); col.Insert(relA); } if (relB == null) { relB = MakeRow(toPlayer, fromPlayer); col.Insert(relB); } // Work out who ORIGINALLY SENT the request (had FriendRequestSent) vs who ACCEPTED it // (had FriendRequestReceived), BEFORE overwriting both to Friend. The two callers pass // fromPlayer/toPlayer in opposite orders (website: accepter,sender - game: sender,accepter), // so we can't trust the arg order - the row types are the source of truth. Without this // the "X accepted your request" notice went to whoever happened to be fromPlayer, so the // ACCEPTER got told the SENDER accepted (backwards). ulong sender, accepter; if (relA.Relationship.RelationshipType == RelationshipType.FriendRequestSent) { sender = fromPlayer; accepter = toPlayer; } else if (relB.Relationship.RelationshipType == RelationshipType.FriendRequestSent) { sender = toPlayer; accepter = fromPlayer; } else { sender = fromPlayer; accepter = toPlayer; } // no pending side (re-confirm) - harmless default // Set both sides to Friend relA.Relationship.RelationshipType = RelationshipType.Friend; relB.Relationship.RelationshipType = RelationshipType.Friend; col.Update(relA); col.Update(relB); // Relationship-changed -> BOTH clients update their friends list live (order-agnostic). Notifications.SendToPlayer(fromPlayer, JsonConvert.SerializeObject(CreateResponseFriendAccepted(toPlayer, fromPlayer))); Notifications.SendToPlayer(toPlayer, JsonConvert.SerializeObject(CreateResponseFriendAccepted(fromPlayer, toPlayer))); // " accepted your friend request" -> ONLY the original sender, naming the // accepter. Also persisted as a saved message so it shows in the sender's website inbox, // survives, and (via the AddMessage -> SiteNotifications.Notify hook) updates the web live. Notifications.SendToPlayer(sender, JsonConvert.SerializeObject(CreateResponseFriendAcceptedMsg(accepter))); PlayerDB.AddMessage(sender, accepter, WebsocketEvents.MessageType.FriendRequestAccepted, null); // Refresh BOTH friends lists live (the client's own RelationshipsInvalid handler). Notifications.FireAndForget(Notifications.SendHubInvocation(fromPlayer, "RelationshipsInvalid"), "accept invalidate"); Notifications.FireAndForget(Notifications.SendHubInvocation(toPlayer, "RelationshipsInvalid"), "accept invalidate"); // return the accepted version return relA.Relationship; } private static WebsocketEvents.Reponse CreateResponseFriendAcceptedMsg(ulong fromPlayer) => new WebsocketEvents.Reponse { Id = "2", Msg = new { Id = (ulong)new Random().Next(), FromPlayerId = fromPlayer, SentTime = DateTime.UtcNow, Type = WebsocketEvents.MessageType.FriendRequestAccepted, Data = "", RoomId = (ulong?)null, PlayerEventId = (ulong?)null, } }; public static void DeleteFriends(ulong playerId) { if (playerId == 0) return; var col = PlayerFriendDBFile.GetCollection("PlayersFriends"); col.DeleteMany(x => x.PlayerID == playerId); } // The RelationshipChanged (1) payload is EXACTLY the five fields the client's // RelationshipResponse has (reference RelationshipChangedPayload: PlayerID, // RelationshipType, Muted, Ignored, Favorited) - the same strict parser that dropped the // whole /relationships/v2/get array over the extra Id/OtherPlayerID/VoiceVolume keys. // These frames used to carry the raw RelationshipDetail (with its ~1.79-trillion Id), so // the live "you have a friend request" frame never parsed and requests sent from the // website only showed up on the next relationships fetch. PlayerID = the OTHER player. private static object RelWire(ulong otherPlayer, RelationshipType type, bool favorited = false, bool muted = false, ReciprocalStatus ignored = ReciprocalStatus.None) => new { PlayerID = otherPlayer, RelationshipType = (int)type, Muted = muted ? 1 : 0, Ignored = IgnoredWire(ignored), Favorited = favorited ? 1 : 0 }; public static Reponse CreateResponseFriendAccepted(ulong playerid, ulong fromplayerid) => new Reponse { Id = "1", Msg = RelWire(playerid, RelationshipType.Friend) }; public static Reponse CreateResponseFriendRemoved(ulong playerid, ulong fromplayerid) => new Reponse { Id = "1", Msg = RelWire(playerid, RelationshipType.None) }; // Sent to the RECIPIENT. Client convention (see GetRelationships): PlayerID = the OTHER // player (the sender), OtherPlayerID = the caller (the recipient). OtherPlayerID used to be // set to the sender too, which is wrong and can stop the client rendering the request. private static WebsocketEvents.Reponse CreateResponseFriendRequestReceived(ulong fromPlayer, ulong toPlayer) => new WebsocketEvents.Reponse { Id = "1", Msg = RelWire(fromPlayer, RelationshipType.FriendRequestReceived) }; private static WebsocketEvents.Reponse CreateResponseFriendRequestMsg(ulong fromPlayer) => new WebsocketEvents.Reponse { Id = "2", Msg = new { Id = (ulong)new Random().Next(), FromPlayerId = fromPlayer, SentTime = DateTime.UtcNow, Type = WebsocketEvents.MessageType.FriendInvite, Data = "", RoomId = (ulong?)null, PlayerEventId = (ulong?)null, } }; // The relationship as the client's RelationshipChanged handler expects it (the same shape // /relationships/v2/get lists): PlayerID = the OTHER player from the receiver's view. private static object RelationshipView(RelationshipDetail r) => RelWire(r.OtherPlayerID, r.RelationshipType, r.Favorited != ReciprocalStatus.None, r.Muted != ReciprocalStatus.None, r.Ignored); /// /// What the wire `Ignored` means: "I have blocked this player" - the caller's OWN part of /// the pair (Local / Mutual). A Remote (the other player blocked me) is kept for the /// server-side gates but is never shown: the blocked player is not told, and reading it /// as true made their client draw the block as if THEY had blocked the blocker. /// public static bool IsOwnBlock(ReciprocalStatus s) => s == ReciprocalStatus.Local || s == ReciprocalStatus.Mutual; /// /// The wire `Ignored` is the client's ReciprocalStatus, from the receiver's point of view: /// 0 None - nobody blocked anybody /// 1 Local - I blocked them (their card shows Blocked + my Unblock button) /// 2 Remote - they blocked me (their card shows Blocked, no Unblock - not mine to lift) /// 3 Mutual - both (Unblock lifts only MY part; theirs stays -> 2) /// Sending a flat 0/1 was the bug: a 1 on both rows drew the block as the blocked /// player's own (they got an Unblock button), and a 0 hid it from them entirely. /// public static int IgnoredWire(ReciprocalStatus s) => (int)s; /// Drop the stored "X sent you a friend request" notification (and tell the game). public static void RemoveFriendInviteMessage(ulong toPlayer, ulong fromPlayer) { try { var full = PlayerDB.GetFullAccount(toPlayer); var stale = full?.player_Extra?.Messages? .Where(m => m.Type == WebsocketEvents.MessageType.FriendInvite && m.FromPlayerId == fromPlayer) .Select(m => m.Id).ToList(); if (stale == null || stale.Count == 0) return; foreach (var id in stale) { PlayerDB.DeleteMessage(toPlayer, id); Notifications.FireAndForget(Notifications.SendToPlayer(toPlayer, JsonConvert.SerializeObject(WebsocketEvents.CreateMessageDeleted(id))), "friend-request message gone"); } SiteNotifications.Notify(toPlayer); } catch (Exception ex) { Console.WriteLine($"[friends] invite message cleanup {fromPlayer}->{toPlayer}: {ex.Message}"); } } public static RelationshipDetail RemoveFriend(ulong playerA, ulong playerB) { // website extras (nickname / home pin) don't outlive the friendship try { SitePrefsDB.Forget(playerA, playerB); SitePrefsDB.Forget(playerB, playerA); } catch { } // Decline / cancel / unfriend: any pending "sent you a friend request" notification goes. RemoveFriendInviteMessage(playerA, playerB); RemoveFriendInviteMessage(playerB, playerA); var col = PlayerFriendDBFile.GetCollection("PlayersFriends"); // A -> B var relA = col.FindOne(x => x.PlayerID == playerA && x.Relationship.OtherPlayerID == playerB); // B -> A var relB = col.FindOne(x => x.PlayerID == playerB && x.Relationship.OtherPlayerID == playerA); // If they were never connected, return empty relationship if (relA == null && relB == null) { return new RelationshipDetail { PlayerID = playerA, OtherPlayerID = playerB, RelationshipType = RelationshipType.None }; } // Cancelling a PENDING request (or declining one) - tell the other side the request is // gone, so their notification disappears instead of lingering until they act on it. bool wasPending = (relA?.Relationship.RelationshipType == RelationshipType.FriendRequestSent || relA?.Relationship.RelationshipType == RelationshipType.FriendRequestReceived || relB?.Relationship.RelationshipType == RelationshipType.FriendRequestSent || relB?.Relationship.RelationshipType == RelationshipType.FriendRequestReceived); // Delete A->B if (relA != null) col.Delete(relA.Id); // Delete B->A if (relB != null) col.Delete(relB.Id); if (wasPending) { Notifications.FireAndForget(Notifications.SendHubInvocation(playerB, "FriendRequestCancelled", (long)playerA), "friend-request cancelled"); Notifications.FireAndForget(Notifications.SendHubInvocation(playerB, "RelationshipChanged", RelationshipView(new RelationshipDetail { PlayerID = playerB, OtherPlayerID = playerA, RelationshipType = RelationshipType.None, VoiceVolume = 100 })), "friend-request cancelled (changed)"); Notifications.FireAndForget(Notifications.SendHubInvocation(playerA, "RelationshipChanged", RelationshipView(new RelationshipDetail { PlayerID = playerA, OtherPlayerID = playerB, RelationshipType = RelationshipType.None, VoiceVolume = 100 })), "friend-request cancelled (self)"); Console.WriteLine($"[friends] {playerA} cancelled/declined the pending request with {playerB}"); } // Send WS update to player A Notifications.SendToPlayer( playerA, JsonConvert.SerializeObject( CreateResponseFriendRemoved(playerA, playerB) ) ); // Send WS update to player B Notifications.SendToPlayer( playerB, JsonConvert.SerializeObject( CreateResponseFriendRemoved(playerB, playerA) ) ); // Refresh BOTH friends lists live (unfriend / decline / cancel). Notifications.FireAndForget(Notifications.SendHubInvocation(playerA, "RelationshipsInvalid"), "remove invalidate"); Notifications.FireAndForget(Notifications.SendHubInvocation(playerB, "RelationshipsInvalid"), "remove invalidate"); // Return final relationship state return new RelationshipDetail { PlayerID = playerA, OtherPlayerID = playerB, RelationshipType = RelationshipType.None, Favorited = ReciprocalStatus.None, Ignored = ReciprocalStatus.None, Muted = ReciprocalStatus.None, VoiceVolume = 100 }; } public static RelationshipDetail AddFriend(ulong playerA, ulong playerB) { var col = PlayerFriendDBFile.GetCollection("PlayersFriends"); // A -> B var relA = col.FindOne(x => x.PlayerID == playerA && x.Relationship.OtherPlayerID == playerB); if (relA == null) { relA = new YourFriendData { Id = ObjectId.NewObjectId(), PlayerID = playerA, Relationship = new RelationshipDetail { Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), PlayerID = playerA, OtherPlayerID = playerB, Favorited = ReciprocalStatus.None, Ignored = ReciprocalStatus.None, Muted = ReciprocalStatus.None, VoiceVolume = 100 } }; col.Insert(relA); } // B -> A var relB = col.FindOne(x => x.PlayerID == playerB && x.Relationship.OtherPlayerID == playerA); if (relB == null) { relB = new YourFriendData { Id = ObjectId.NewObjectId(), PlayerID = playerB, Relationship = new RelationshipDetail { Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), PlayerID = playerB, OtherPlayerID = playerA, Favorited = ReciprocalStatus.None, Ignored = ReciprocalStatus.None, Muted = ReciprocalStatus.None, VoiceVolume = 100 } }; col.Insert(relB); } // Set both sides to Friend relA.Relationship.RelationshipType = RelationshipType.Friend; relB.Relationship.RelationshipType = RelationshipType.Friend; col.Update(relA); col.Update(relB); // WebSocket updates Notifications.SendToPlayer( playerA, JsonConvert.SerializeObject(CreateResponseFriendAccepted(playerA, playerB)) ); Notifications.SendToPlayer( playerB, JsonConvert.SerializeObject(CreateResponseFriendAccepted(playerB, playerA)) ); return relA.Relationship; } // {"type":1,"target":"Notification","arguments":["{\"id\":\"2\",\"msg\":{\"id\":16212007930,\"fromPlayerId\":1764227894,\"sentTime\":\"2025-06-23T17:20:53.72092Z\",\"type\":4,\"data\":\"\",\"roomId\":null,\"playerEventId\":null}}"]} private static WebsocketEvents.Reponse CreateResponseFriendRequestSent(ulong toPlayer) => new WebsocketEvents.Reponse { Id = "50", Msg = "" }; } }