Files
DorkNet/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs
T
Alexa 0779aa8439 feat: Add new API controllers for progression events, room consumables, currencies, earnings distributions, screen share reports, subscription seasons, and UGC purchasables
- Implemented ProgressionEventsController to handle progression event retrieval and active events.
- Created RoomConsumablesController for listing and purchasing room consumables.
- Developed RoomCurrenciesController for managing room currencies, including creation, updates, and balance retrieval.
- Added RoomEarningsDistributionsController to fetch earnings distribution data for rooms.
- Introduced ScreenShareReportsController for reporting screen share incidents.
- Established SubscriptionSeasonsController to provide current subscription season details and rewards.
- Launched UgcPurchasablesController for managing user-generated content purchasables, including bulk operations.
- Added new data entities for customization and room economy, including custom avatar items, room currencies, and UGC purchasables.
2026-06-22 16:08:23 +03:00

606 lines
27 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
// Storage-side enum aliased so the wire mapping stays explicit at
// every callsite. Wire enum values (RelationshipType /
// ReciprocalStatus) live in this file as constants — they're tied
// to the disassembly and not used elsewhere.
using EntityStatus = DorkNet.Server.Data.Entities.RelationshipStatus;
namespace DorkNet.Server.Controllers.API.Relationships.V2;
/// <summary>
/// api/relationships/v2 — friendship + reciprocal-state endpoints.
///
/// All routes / wire shapes verified against the patched 2020 client:
/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Relationships.txt
/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Relationship.txt
///
/// Wire response = a Relationship object (or List of them for /get).
/// Relationship.Deserialize strict-reads via Util.GetKey&lt;T&gt;:
/// "PlayerID" (int) — REQUIRED — the OTHER account's id
/// "RelationshipType" (int) — REQUIRED — 0..3
/// "Muted" (int) — REQUIRED — ReciprocalStatus 0..3
/// "Ignored" (int) — REQUIRED — ReciprocalStatus 0..3
/// "Favorited" (int) — optional (Util.GetKeyOrDefault, defaults 0)
///
/// RelationshipType (from caller's perspective):
/// 0 None, 1 FriendRequestSent, 2 FriendRequestReceived, 3 Friend
/// ReciprocalStatus (symmetric flag — who set it):
/// 0 None, 1 Local (just me), 2 Remote (just them), 3 Mutual
///
/// URLs called from the watch (Relationships.txt, all under
/// "api/relationships/"):
/// POST v2/addfriend?id={N} AddFriend
/// POST v2/removefriend?id={N} RemoveFriend / cancel-pending
/// POST v2/sendfriendrequest?id={N} SendFriendRequest
/// POST v2/acceptfriendrequest?id={N} AcceptFriendRequest
/// POST v1/favorite?id={N} FavoritePlayer
/// POST v1/unfavorite?id={N} UnfavoritePlayer
/// POST v1/mute / unmute / ignore / unignore (PostPreferenceChange)
/// GET v2/get GetMyRelationships
/// All POST URLs return one Relationship; GET returns List&lt;Relationship&gt;.
/// </summary>
[ApiController]
[Authorize]
public class RelationshipsController(DorkNetDbContext db, NotificationService notifications, ServerSettingsService settings) : ControllerBase
{
// RelationshipType — int values match Relationship.RelationshipType
// in the patched dump.cs.
private const int RT_None = 0;
private const int RT_FriendRequestSent = 1;
private const int RT_FriendRequestReceived = 2;
private const int RT_Friend = 3;
// ReciprocalStatus values — also match the dump.cs enum.
private const int RS_None = 0;
private const int RS_Local = 1;
private const int RS_Remote = 2;
private const int RS_Mutual = 3;
private long CurrentPlayerId => this.RequireCurrentPlayerId();
// ── GET /api/relationships/v2/get ────────────────────────────────
// Watch URL: "api/relationships/v2/get". Returns the caller's full
// relationship list as List<Relationship>.
[HttpGet("api/relationships/v2/get")]
public async Task<ActionResult> GetMyRelationships()
{
var me = CurrentPlayerId;
// Global-friends mode (admin toggle): synthesize a Friend row for
// every other account WITHOUT persisting anything. Real rows are
// overlaid so mute/favorite still apply and Blocked stays non-friend.
if (await settings.IsGlobalFriendsEnabledAsync())
return Ok(await BuildGlobalFriendListAsync(me));
var rels = await db.Relationships
.Where(r => r.RequesterId == me || r.TargetId == me)
.ToListAsync();
// Merge the (up to two) rows per pair so a favorite/mute/ignore the
// caller set on a friendship the OTHER player initiated isn't dropped
// by the watch (which keys its list by PlayerID — duplicate ids lose).
var wire = rels
.GroupBy(r => r.RequesterId == me ? r.TargetId : r.RequesterId)
.Select(g => BuildMerged(me, g.Key, g.ToList()))
.ToList();
return Ok(wire);
}
/// <summary>Synthesize the caller's friend list as "everyone" for the
/// global-friends toggle. One Friend entry per other account; real rows
/// are overlaid so a Block stays None and mute/favorite carry through.</summary>
private async Task<List<Dictionary<string, object>>> BuildGlobalFriendListAsync(long me)
{
var realRows = await db.Relationships
.Where(r => r.RequesterId == me || r.TargetId == me)
.ToListAsync();
// Group both directional rows per pair so the caller's favorite/mute/
// ignore (stored on the row where they're the requester) survives even
// when the friendship row itself was created by the other player.
var realByOther = realRows
.GroupBy(r => r.RequesterId == me ? r.TargetId : r.RequesterId)
.ToDictionary(g => g.Key, g => g.ToList());
var others = await db.Players
.Where(p => p.Id != me && p.Id != RelationshipQueries.SystemAccountId)
.Select(p => new { p.Id, p.Username })
.ToListAsync();
var wire = new List<Dictionary<string, object>>(others.Count);
foreach (var other in others)
{
var otherId = other.Id;
if (realByOther.TryGetValue(otherId, out var pair))
{
// A real block wins — that pair is genuinely not friends.
if (pair.Any(r => r.Status == EntityStatus.Blocked)) { wire.Add(EmptyRelationship(otherId)); continue; }
// Carry the caller's own preference bits from their row.
var mineRow = pair.FirstOrDefault(r => r.RequesterId == me);
wire.Add(SynthFriend(otherId, mineRow, me));
}
else
{
// Hide auto-generated Player_NNN placeholders from the
// synthesized list (they never set a real username).
if (RelationshipQueries.IsPlaceholderUsername(other.Username)) continue;
wire.Add(SynthFriend(otherId, null, me));
}
}
return wire;
}
/// <summary>A synthetic Friend relationship for global-friends mode,
/// carrying mute/ignore/favorite from the caller's real row when present.</summary>
private static Dictionary<string, object> SynthFriend(long other, RelationshipEntity? real, long me)
{
var mineDir = real is not null && real.RequesterId == me;
return new()
{
["PlayerID"] = (int)other,
["RelationshipType"] = RT_Friend,
["Muted"] = mineDir && real!.Muted ? 1 : 0,
["Ignored"] = mineDir && real!.Ignored ? 1 : 0,
["Favorited"] = mineDir && real!.Favorited ? 1 : 0,
};
}
/// <summary>GET <c>api/relationships/v2/personaldetails/{playerId}</c>
/// — single relationship row from the caller's perspective. Used
/// by the watch's profile-card view to determine whether to show
/// the "Add Friend" or "Remove Friend" affordance. Returns the
/// same Relationship wire shape as <c>v2/get</c>'s list elements.</summary>
[HttpGet("api/relationships/v2/personaldetails/{playerId:long}")]
public async Task<ActionResult> PersonalDetails(long playerId)
{
var me = CurrentPlayerId;
if (playerId <= 0 || playerId == me) return Ok(EmptyRelationship(playerId));
var pair = await db.Relationships
.Where(r => (r.RequesterId == me && r.TargetId == playerId) ||
(r.RequesterId == playerId && r.TargetId == me))
.ToListAsync();
var blocked = pair.Any(r => r.Status == EntityStatus.Blocked);
var mineRow = pair.FirstOrDefault(r => r.RequesterId == me);
// Global-friends mode: report Friend for any real account that isn't
// the system account and isn't explicitly blocked. Placeholder
// Player_NNN accounts are hidden unless there's a genuine row.
if (playerId != RelationshipQueries.SystemAccountId
&& !blocked
&& await settings.IsGlobalFriendsEnabledAsync())
{
var isPlaceholder = pair.Count == 0 && RelationshipQueries.IsPlaceholderUsername(
await db.Players.Where(p => p.Id == playerId)
.Select(p => p.Username).FirstOrDefaultAsync());
if (!isPlaceholder) return Ok(SynthFriend(playerId, mineRow, me));
}
return Ok(pair.Count == 0 ? EmptyRelationship(playerId) : BuildMerged(me, playerId, pair));
}
// ── /api/relationships/v2/sendfriendrequest?id={N} ───────────────
// Watch URL from Relationships.txt: "{0}v2/sendfriendrequest?id={1}".
// The ISIL labels the call site Core.Post but the live trace shows
// the watch sending GET — accepting both is harmless and the
// safest cross-build choice.
[HttpGet("api/relationships/v2/sendfriendrequest")]
[HttpPost("api/relationships/v2/sendfriendrequest")]
public async Task<ActionResult> SendFriendRequest([FromQuery] long id)
{
var me = CurrentPlayerId;
if (id <= 0 || id == me)
return Ok(EmptyRelationship(id));
var pair = await db.Relationships
.Where(r => (r.RequesterId == me && r.TargetId == id) ||
(r.RequesterId == id && r.TargetId == me))
.ToListAsync();
// Anything that already defines a status (friend/pending/block) wins —
// a None row is preference-only and doesn't count as a relationship.
if (pair.Any(r => r.Status != EntityStatus.None))
return Ok(BuildMerged(me, id, pair));
// Promote the caller's own preference-only row if present (keeping its
// favorite/mute/ignore bits) rather than spawning a second row.
var row = pair.FirstOrDefault(r => r.RequesterId == me);
if (row is not null)
{
row.Status = EntityStatus.PendingSent;
row.UpdatedAt = DateTime.UtcNow;
}
else
{
row = new RelationshipEntity
{
RequesterId = me,
TargetId = id,
Status = EntityStatus.PendingSent,
};
db.Relationships.Add(row);
}
await db.SaveChangesAsync();
await notifications.FriendRequestReceived(id, me);
return Ok(BuildRelationship(row, me));
}
// ── /api/relationships/v2/acceptfriendrequest?id={N} ────────────
[HttpGet("api/relationships/v2/acceptfriendrequest")]
[HttpPost("api/relationships/v2/acceptfriendrequest")]
public async Task<ActionResult> AcceptFriendRequest([FromQuery] long id)
{
var me = CurrentPlayerId;
if (id <= 0)
return Ok(EmptyRelationship(id));
// Only an incoming pending row can be accepted. The other side
// must have requested us — RequesterId=them, TargetId=me.
var rel = await db.Relationships.FirstOrDefaultAsync(r =>
r.RequesterId == id && r.TargetId == me &&
r.Status == EntityStatus.PendingSent);
if (rel is null)
return Ok(EmptyRelationship(id));
rel.Status = EntityStatus.Friend;
rel.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await notifications.FriendRequestAccepted(id, me);
return Ok(BuildRelationship(rel, me));
}
// ── /api/relationships/v2/removefriend?id={N} ────────────────────
// Symmetrical: undoes a friendship OR cancels a pending request OR
// declines an incoming one. Block rows are left alone — the watch
// has a separate explicit unblock path. Live trace shows the watch
// sending GET (BestHTTP queueing oddity vs the ISIL Core.Post call);
// accept both verbs.
[HttpGet("api/relationships/v2/removefriend")]
[HttpPost("api/relationships/v2/removefriend")]
public async Task<ActionResult> RemoveFriend([FromQuery] long id)
{
var me = CurrentPlayerId;
if (id <= 0)
return Ok(EmptyRelationship(id));
var pair = await db.Relationships
.Where(r => (r.RequesterId == me && r.TargetId == id) ||
(r.RequesterId == id && r.TargetId == me))
.ToListAsync();
if (pair.Count == 0)
return Ok(EmptyRelationship(id));
// Drop every non-block row for the pair (friendship row + any stray
// preference row), leaving a block intact — the watch unblocks
// through a separate path.
var removable = pair.Where(r => r.Status != EntityStatus.Blocked).ToList();
if (removable.Count == 0)
return Ok(BuildMerged(me, id, pair));
db.Relationships.RemoveRange(removable);
await db.SaveChangesAsync();
await notifications.FriendRemoved(id, me);
var remaining = pair.Except(removable).ToList();
return Ok(remaining.Count == 0 ? EmptyRelationship(id) : BuildMerged(me, id, remaining));
}
// ── /api/relationships/v2/addfriend?id={N} ───────────────────────
// Per the disassembly, AddFriend is its own endpoint distinct from
// SendFriendRequest. Live trace shows the watch sending GET; ISIL
// says Core.Post. Accept both verbs.
[HttpGet("api/relationships/v2/addfriend")]
[HttpPost("api/relationships/v2/addfriend")]
public async Task<ActionResult> AddFriend([FromQuery] long id)
{
var me = CurrentPlayerId;
if (id <= 0 || id == me)
return Ok(EmptyRelationship(id));
var pair = await db.Relationships
.Where(r => (r.RequesterId == me && r.TargetId == id) ||
(r.RequesterId == id && r.TargetId == me))
.ToListAsync();
// Don't override a block, and don't spawn a duplicate when a
// friendship row already exists (in either direction).
if (pair.Any(r => r.Status == EntityStatus.Blocked) ||
pair.Any(r => r.Status == EntityStatus.Friend))
return Ok(BuildMerged(me, id, pair));
// Promote the caller's own row (preference-only or pending) to Friend,
// or create one if none exists.
var existing = pair.FirstOrDefault(r => r.RequesterId == me);
if (existing is null)
{
existing = new RelationshipEntity
{
RequesterId = me,
TargetId = id,
Status = EntityStatus.Friend,
};
db.Relationships.Add(existing);
}
else
{
existing.Status = EntityStatus.Friend;
existing.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
return Ok(BuildRelationship(existing, me));
}
// ── v1 favorite / unfavorite ─────────────────────────────────────
// Watch URLs: "api/relationships/v1/favorite?id={N}" and
// "api/relationships/v1/unfavorite?id={N}". These return a
// boolean-ish RecNetResult per the FavoritePlayer/UnfavoritePlayer
// disassembly, not a Relationship. The flag is persisted on the caller's
// directional row and surfaced back through v2/get + personaldetails
// (see BuildMerged).
//
// VERB: the 2020 watch sends these as GET (RecNet.Relationships
// FavoritePlayer/UnfavoritePlayer/etc. call Core.Get — verified in the
// readable March dump at Relationships.txt:1675/1834, and the December
// build hits the same obfuscated Core-GET wrapper). The endpoints were
// POST-only, so the watch's GET returned HTTP 405 ("Error favoriting
// player: HTTP Error 405"). Accept GET (real verb) + POST (cross-build
// safety), mirroring the friend endpoints above.
[HttpGet("api/relationships/v1/favorite")]
[HttpPost("api/relationships/v1/favorite")]
public Task<IActionResult> Favorite([FromQuery] long id) => SetPreference(id, favorited: true);
[HttpGet("api/relationships/v1/unfavorite")]
[HttpPost("api/relationships/v1/unfavorite")]
public Task<IActionResult> Unfavorite([FromQuery] long id) => SetPreference(id, favorited: false);
[HttpGet("api/relationships/v1/mute")]
[HttpPost("api/relationships/v1/mute")]
public Task<IActionResult> Mute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, muted: true);
[HttpGet("api/relationships/v1/unmute")]
[HttpPost("api/relationships/v1/unmute")]
public Task<IActionResult> Unmute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, muted: false);
[HttpGet("api/relationships/v1/ignore")]
[HttpPost("api/relationships/v1/ignore")]
public Task<IActionResult> Ignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: true);
[HttpGet("api/relationships/v1/unignore")]
[HttpPost("api/relationships/v1/unignore")]
public Task<IActionResult> Unignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: false);
/// <summary>Update favorited/muted/ignored on the caller's row.
/// Creates a placeholder row when none exists (so an "ignore"
/// before any friend request is still recorded).</summary>
private async Task<IActionResult> SetPreference(long otherId,
bool? favorited = null, bool? muted = null, bool? ignored = null)
{
var me = this.RequireCurrentPlayerId();
if (otherId <= 0 || otherId == me)
return Ok(new Models.Auth.RecNetResult { Success = false, Error = "invalid_id" });
var row = await db.Relationships.FirstOrDefaultAsync(r =>
r.RequesterId == me && r.TargetId == otherId);
if (row is null)
{
// A preference (favorite/mute/ignore) is the caller's own bit and
// lives on their own directional row. Status None — favorite/mute/
// ignore must NOT fabricate a friendship; the real friendship row
// (this direction or the other) carries the actual status and the
// read path merges the two.
row = new RelationshipEntity
{
RequesterId = me,
TargetId = otherId,
Status = EntityStatus.None,
};
db.Relationships.Add(row);
}
if (favorited is bool f) row.Favorited = f;
if (muted is bool m) row.Muted = m;
if (ignored is bool i) row.Ignored = i;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty });
}
public sealed class BulkIgnoreRequest
{
public int Platform { get; set; }
public List<string>? PlatformUserIds { get; set; }
}
/// <summary>POST <c>api/relationships/v1/bulkignoreplatformusers</c>
/// — bulk-import Steam/Oculus blocks. Persists to
/// <see cref="PlatformIgnoreEntity"/> so when those users sign
/// up later we can auto-set their relationship to Ignored.</summary>
[HttpPost("api/relationships/v1/bulkignoreplatformusers")]
[Authorize]
public async Task<IActionResult> BulkIgnorePlatformUsers([FromBody] BulkIgnoreRequest req)
{
var me = this.RequireCurrentPlayerId();
if (req?.PlatformUserIds is null || req.PlatformUserIds.Count == 0)
return Ok(new { Imported = 0 });
var added = 0;
foreach (var puid in req.PlatformUserIds.Distinct())
{
if (string.IsNullOrWhiteSpace(puid)) continue;
var exists = await db.PlatformIgnores.AnyAsync(i =>
i.PlayerId == me && i.Platform == req.Platform && i.PlatformUserId == puid);
if (exists) continue;
db.PlatformIgnores.Add(new PlatformIgnoreEntity
{
PlayerId = me,
Platform = req.Platform,
PlatformUserId = puid,
});
added++;
}
await db.SaveChangesAsync();
return Ok(new { Imported = added });
}
[HttpGet("api/relationships/mutualfriends")]
public async Task<IActionResult> MutualFriends([FromQuery] long playerId)
{
var me = CurrentPlayerId;
if (playerId <= 0 || playerId == me) return Ok(Array.Empty<object>());
var mine = await FriendIdsAsync(me);
var theirs = await FriendIdsAsync(playerId);
var mutualIds = mine.Intersect(theirs).Take(100).ToList();
var players = await db.Players
.Where(p => mutualIds.Contains(p.Id))
.Select(p => new
{
AccountId = (int)p.Id,
p.Username,
p.DisplayName,
ProfileImage = p.ProfileImageName ?? string.Empty,
})
.ToListAsync();
return Ok(players);
}
[HttpPost("api/relationships/sendfriendintroductions")]
public async Task<IActionResult> SendFriendIntroductions(
[FromForm] long playerId,
[FromForm] long introducedPlayerId)
{
var me = CurrentPlayerId;
if (playerId <= 0 || introducedPlayerId <= 0)
return BadRequest("missing_player");
db.Messages.Add(new MessageEntity
{
SenderPlayerId = me,
RecipientPlayerId = playerId,
Type = 30,
Body = $"Friend introduction:{introducedPlayerId}",
});
await db.SaveChangesAsync();
return Ok(new { Success = true });
}
[HttpPost("api/relationships/v1/addfriendwithcode")]
[HttpGet("api/relationships/v1/addfriendwithcode")]
public async Task<ActionResult> AddFriendWithCode([FromQuery] string? code, [FromForm] string? formCode)
{
var raw = (code ?? formCode ?? string.Empty).Trim();
if (raw.Length == 0) return Ok(EmptyRelationship(0));
long? id = long.TryParse(raw, out var parsed) ? parsed : null;
if (id is null)
{
id = await db.Players
.Where(p => p.Username == raw)
.Select(p => (long?)p.Id)
.FirstOrDefaultAsync();
}
if (id is not long playerId) return Ok(EmptyRelationship(0));
return await AddFriend(playerId);
}
// ── helpers ──────────────────────────────────────────────────────
private async Task<HashSet<long>> FriendIdsAsync(long playerId)
{
var rows = await db.Relationships
.Where(r => r.Status == EntityStatus.Friend
&& (r.RequesterId == playerId || r.TargetId == playerId))
.Select(r => r.RequesterId == playerId ? r.TargetId : r.RequesterId)
.ToListAsync();
return rows.ToHashSet();
}
/// <summary>Map a storage row to the watch's wire RelationshipType
/// from <paramref name="me"/>'s perspective. PendingSent flips
/// direction depending on which side originated.</summary>
private static int MapType(RelationshipEntity r, long me) => r.Status switch
{
EntityStatus.Friend => RT_Friend,
EntityStatus.Blocked => RT_None, // Blocked is not a friendship; the watch reads block state via a separate flag we don't track yet.
EntityStatus.PendingSent => r.RequesterId == me ? RT_FriendRequestSent : RT_FriendRequestReceived,
EntityStatus.PendingReceived => r.RequesterId == me ? RT_FriendRequestReceived : RT_FriendRequestSent,
_ => RT_None,
};
/// <summary>
/// Build the wire object the patched <c>Relationship.Deserialize</c>
/// reads. Keys are case-sensitive (Util.GetKey is a Dictionary
/// TryGetValue) — we ship exactly what's in the disassembly.
/// Implemented as a Dictionary because System.Text.Json's
/// case-insensitive collision check rejects anonymous types with
/// PascalCase property names like "PlayerID" + "Muted" if any
/// other property could collide on lowercase — using a Dictionary
/// avoids that whole class of trouble.
/// </summary>
private static Dictionary<string, object> BuildRelationship(RelationshipEntity r, long me)
{
var other = r.RequesterId == me ? r.TargetId : r.RequesterId;
// ReciprocalStatus enum: 0=None, 1=ByMe, 2=ByThem, 3=Both.
// Our schema only tracks "by me" (the requester's perspective)
// so we can answer ByMe / None; ByThem / Both would require
// looking up the OTHER direction's row too. Cheap extra query
// omitted — the watch's UI only consumes the ByMe bit.
var mineDir = r.RequesterId == me;
var muted = mineDir && r.Muted ? 1 : 0;
var ignored = mineDir && r.Ignored ? 1 : 0;
var favored = mineDir && r.Favorited ? 1 : 0;
return new()
{
["PlayerID"] = (int)other,
["RelationshipType"] = MapType(r, me),
["Muted"] = muted,
["Ignored"] = ignored,
["Favorited"] = favored,
};
}
/// <summary>Merge all rows for one pair (up to two — one per direction)
/// into a single wire object from <paramref name="me"/>'s view. The
/// relationship type comes from whichever row defines it (Blocked &gt;
/// Friend &gt; Pending); the favorite/mute/ignore bits are the caller's
/// own, read from the row where the caller is the requester. Without this
/// a favorite the caller set on a friendship the OTHER player initiated
/// lands on a separate row and is dropped by the watch (its list is keyed
/// by PlayerID, so a duplicate id loses).</summary>
private static Dictionary<string, object> BuildMerged(long me, long other, List<RelationshipEntity> pair)
{
var statusRow =
pair.FirstOrDefault(r => r.Status == EntityStatus.Blocked)
?? pair.FirstOrDefault(r => r.Status == EntityStatus.Friend)
?? pair.FirstOrDefault(r => r.Status is EntityStatus.PendingSent or EntityStatus.PendingReceived)
?? pair[0];
var mineRow = pair.FirstOrDefault(r => r.RequesterId == me);
return new()
{
["PlayerID"] = (int)other,
["RelationshipType"] = MapType(statusRow, me),
["Muted"] = mineRow is { Muted: true } ? 1 : 0,
["Ignored"] = mineRow is { Ignored: true } ? 1 : 0,
["Favorited"] = mineRow is { Favorited: true } ? 1 : 0,
};
}
/// <summary>"None" relationship for a target the caller has nothing
/// on file with — used as a no-op success response so the watch's
/// Relationship.Deserialize stays satisfied.</summary>
private static Dictionary<string, object> EmptyRelationship(long other) => new()
{
["PlayerID"] = (int)other,
["RelationshipType"] = RT_None,
["Muted"] = RS_None,
["Ignored"] = RS_None,
["Favorited"] = RS_None,
};
}