Files
2026-10-14 06:59:51 +02:00

787 lines
41 KiB
C#

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<RelationshipDetail> GetRelationships(ulong playerId)
{
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
List<RelationshipDetail> 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<YourFriendData>("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
};
}
/// <summary>The caller's voice volume for one player (their own side of the row).</summary>
/// <returns>The caller's relationship with the target afterwards, in the client's shape
/// (the client caches the update's response as that player's relationship).</returns>
public static object SetVoiceVolume(ulong caller, ulong target, int volume)
{
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
var row = GetOrCreateRow(col, caller, target);
row.Relationship.VoiceVolume = volume;
col.Update(row);
return RelationshipView(row.Relationship);
}
/// <summary>True when either of the two has blocked the other.</summary>
public static bool IsBlockedEitherWay(ulong a, ulong b)
{
if (a == 0 || b == 0 || a == b) return false;
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("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;
}
/// <summary>True when <paramref name="blocker"/> has blocked <paramref name="other"/> (their own Local part).</summary>
public static bool HasBlocked(ulong blocker, ulong other)
{
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("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<YourFriendData> 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<YourFriendData>("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<YourFriendData>("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 "<sender> 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;
}
/// <summary>
/// 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 <paramref name="me"/> and accepted in the same call, so both
/// sides end up Friend with the usual "relationship changed" pushes.
/// </summary>
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<YourFriendData>("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)));
// "<accepter> 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<YourFriendData>("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.
//
// VoiceVolume IS a field of that response (the client's formatter reads PlayerID,
// RelationshipType, Muted, Ignored, Favorited and VoiceVolume - a nullable byte, 0-200,
// null = the default 100). It was dropped along with Id/OtherPlayerID, but it is what the
// per-player volume slider on a player's card reads, so it has to ride along.
private static object RelWire(ulong otherPlayer, RelationshipType type, bool favorited = false, bool muted = false, ReciprocalStatus ignored = ReciprocalStatus.None, int? voiceVolume = null) => new
{
PlayerID = otherPlayer,
RelationshipType = (int)type,
Muted = muted ? 1 : 0,
Ignored = IgnoredWire(ignored),
Favorited = favorited ? 1 : 0,
VoiceVolume = VoiceVolumeWire(voiceVolume)
};
/// <summary>The wire VoiceVolume: 0-200 (the client's slider range), null = default.</summary>
public static int? VoiceVolumeWire(int? v) => v == null ? null : Math.Clamp(v.Value, 0, 200);
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, r.VoiceVolume);
/// <summary>
/// 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.
/// </summary>
public static bool IsOwnBlock(ReciprocalStatus s) => s == ReciprocalStatus.Local || s == ReciprocalStatus.Mutual;
/// <summary>
/// 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.
/// </summary>
public static int IgnoredWire(ReciprocalStatus s) => (int)s;
/// <summary>Drop the stored "X sent you a friend request" notification (and tell the game).</summary>
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<YourFriendData>("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<YourFriendData>("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 = ""
};
}
}