Implement "Everyone is friends" toggle: add global friends functionality with server settings and UI integration

This commit is contained in:
Alexa
2026-06-04 13:40:15 +03:00
parent 31e2cf7389
commit 8dfe4c8d63
10 changed files with 281 additions and 11 deletions
@@ -24,6 +24,7 @@ public class MessagesController(
PrivateInstanceService privateInstances, PrivateInstanceService privateInstances,
OnlinePresenceService onlinePresence, OnlinePresenceService onlinePresence,
PlayerPresenceService presence, PlayerPresenceService presence,
ServerSettingsService serverSettings,
ILogger<MessagesController> logger) : ControllerBase ILogger<MessagesController> logger) : ControllerBase
{ {
private long Me => this.RequireCurrentPlayerId(); private long Me => this.RequireCurrentPlayerId();
@@ -534,11 +535,8 @@ public class MessagesController(
[HttpGet("/api/messages/v1/onlineStatus")] [HttpGet("/api/messages/v1/onlineStatus")]
public async Task<ActionResult> FavoriteFriendOnlineStatus() public async Task<ActionResult> FavoriteFriendOnlineStatus()
{ {
var friendIds = await db.Relationships // Honors the global-friends toggle (everyone is a friend when on).
.Where(r => r.Status == RelationshipStatus.Friend && var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, Me);
(r.RequesterId == Me || r.TargetId == Me))
.Select(r => r.RequesterId == Me ? r.TargetId : r.RequesterId)
.ToListAsync();
var online = onlinePresence.OnlinePlayerIds().ToHashSet(); var online = onlinePresence.OnlinePlayerIds().ToHashSet();
return Ok(friendIds.Select(id => new return Ok(friendIds.Select(id => new
{ {
@@ -48,7 +48,7 @@ namespace DorkNet.Server.Controllers.API.Relationships.V2;
/// </summary> /// </summary>
[ApiController] [ApiController]
[Authorize] [Authorize]
public class RelationshipsController(DorkNetDbContext db, NotificationService notifications) : ControllerBase public class RelationshipsController(DorkNetDbContext db, NotificationService notifications, ServerSettingsService settings) : ControllerBase
{ {
// RelationshipType — int values match Relationship.RelationshipType // RelationshipType — int values match Relationship.RelationshipType
// in the patched dump.cs. // in the patched dump.cs.
@@ -72,6 +72,13 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
public async Task<ActionResult> GetMyRelationships() public async Task<ActionResult> GetMyRelationships()
{ {
var me = CurrentPlayerId; 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 var rels = await db.Relationships
.Where(r => r.RequesterId == me || r.TargetId == me) .Where(r => r.RequesterId == me || r.TargetId == me)
.ToListAsync(); .ToListAsync();
@@ -79,6 +86,55 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
return Ok(wire); 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();
var realByOther = new Dictionary<long, RelationshipEntity>();
foreach (var r in realRows)
realByOther[r.RequesterId == me ? r.TargetId : r.RequesterId] = r;
var others = await db.Players
.Where(p => p.Id != me && p.Id != RelationshipQueries.SystemAccountId)
.Select(p => p.Id)
.ToListAsync();
var wire = new List<Dictionary<string, object>>(others.Count);
foreach (var otherId in others)
{
if (realByOther.TryGetValue(otherId, out var row))
{
// A real block wins — that pair is genuinely not friends.
if (row.Status == EntityStatus.Blocked) { wire.Add(EmptyRelationship(otherId)); continue; }
wire.Add(SynthFriend(otherId, row, me));
}
else
{
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> /// <summary>GET <c>api/relationships/v2/personaldetails/{playerId}</c>
/// — single relationship row from the caller's perspective. Used /// — single relationship row from the caller's perspective. Used
/// by the watch's profile-card view to determine whether to show /// by the watch's profile-card view to determine whether to show
@@ -90,6 +146,14 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
var me = CurrentPlayerId; var me = CurrentPlayerId;
if (playerId <= 0 || playerId == me) return Ok(EmptyRelationship(playerId)); if (playerId <= 0 || playerId == me) return Ok(EmptyRelationship(playerId));
var row = await FindAsync(me, playerId); var row = await FindAsync(me, playerId);
// Global-friends mode: report Friend for any real account that isn't
// the system account and isn't explicitly blocked.
if (playerId != RelationshipQueries.SystemAccountId
&& (row is null || row.Status != EntityStatus.Blocked)
&& await settings.IsGlobalFriendsEnabledAsync())
return Ok(SynthFriend(playerId, row, me));
return Ok(row is null ? EmptyRelationship(playerId) : BuildRelationship(row, me)); return Ok(row is null ? EmptyRelationship(playerId) : BuildRelationship(row, me));
} }
@@ -1390,12 +1390,41 @@ public class AdminController(
return Ok(new return Ok(new
{ {
row.SignupsDisabled, row.SignupsDisabled,
row.GlobalFriendsEnabled,
row.WeeklyChallengesCompletedRequired, row.WeeklyChallengesCompletedRequired,
row.UpdatedAt, row.UpdatedAt,
}); });
} }
public sealed record SignupsToggleRequest(bool Disabled); public sealed record SignupsToggleRequest(bool Disabled);
public sealed record GlobalFriendsToggleRequest(bool Enabled);
/// <summary>POST <c>api/admin/v1/settings/global-friends</c> — flip the
/// "everyone is friends" toggle. No relationship rows are written; the
/// friend graph is synthesized at read time (see
/// <see cref="RelationshipQueries"/>). After flipping, broadcast
/// <c>RelationshipsInvalid</c> to every connected watch so it re-fetches
/// <c>api/relationships/v2/get</c> and shows the new state live — no
/// relog (the 2020 watch handles that push via
/// <c>Relationships.RefreshList</c>).</summary>
[HttpPost("settings/global-friends")]
public async Task<ActionResult> SetGlobalFriends([FromBody] GlobalFriendsToggleRequest body)
{
var row = await serverSettings.SetGlobalFriendsEnabledAsync(body.Enabled);
await LogAsync(body.Enabled ? "global_friends_enabled" : "global_friends_disabled", "system", 0, "");
await db.SaveChangesAsync();
// Live refresh: every connected watch re-pulls its relationship list.
await notifications.BroadcastAsync(DorkNet.Models.Notification.PushNotificationId.RelationshipsInvalid);
return Ok(new
{
row.SignupsDisabled,
row.GlobalFriendsEnabled,
row.WeeklyChallengesCompletedRequired,
row.UpdatedAt,
});
}
/// <summary>POST <c>api/admin/v1/settings/signups</c> — flip the /// <summary>POST <c>api/admin/v1/settings/signups</c> — flip the
/// server-wide account-creation kill switch. While disabled, both /// server-wide account-creation kill switch. While disabled, both
@@ -1412,6 +1441,7 @@ public class AdminController(
return Ok(new return Ok(new
{ {
row.SignupsDisabled, row.SignupsDisabled,
row.GlobalFriendsEnabled,
row.WeeklyChallengesCompletedRequired, row.WeeklyChallengesCompletedRequired,
row.UpdatedAt, row.UpdatedAt,
}); });
@@ -40,6 +40,7 @@ public class GoToController(
DorkNet.Server.Data.DorkNetDbContext db, DorkNet.Server.Data.DorkNetDbContext db,
NotificationService notifications, NotificationService notifications,
JoinTimeoutService joinTimeouts, JoinTimeoutService joinTimeouts,
ServerSettingsService serverSettings,
ILogger<GoToController> logger) : ControllerBase ILogger<GoToController> logger) : ControllerBase
{ {
[HttpPost("/goto/room/{roomName}")] [HttpPost("/goto/room/{roomName}")]
@@ -1005,11 +1006,10 @@ public class GoToController(
private async Task NotifyFriendsOfMoveAsync(long playerId, RoomInstanceDto room) private async Task NotifyFriendsOfMoveAsync(long playerId, RoomInstanceDto room)
{ {
var friendIds = await db.Relationships // Honors the global-friends toggle: with it on, every other player is
.Where(r => r.Status == DorkNet.Server.Data.Entities.RelationshipStatus.Friend && // a "friend" and gets the presence ping (so the whole small server
(r.RequesterId == playerId || r.TargetId == playerId)) // sees each other's room moves).
.Select(r => r.RequesterId == playerId ? r.TargetId : r.RequesterId) var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, playerId);
.ToListAsync();
logger.LogInformation( logger.LogInformation(
"[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName} → notifying {FriendCount} friend(s)", "[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName} → notifying {FriendCount} friend(s)",
playerId, room.RoomId, room.Name, friendIds.Count); playerId, room.RoomId, room.Name, friendIds.Count);
@@ -22,6 +22,16 @@ public class ServerSettingsEntity
/// brand-new account requests are blocked.</summary> /// brand-new account requests are blocked.</summary>
public bool SignupsDisabled { get; set; } public bool SignupsDisabled { get; set; }
/// <summary>When true, every account on the server is treated as a
/// friend of every other account — the relationships list, friend
/// online-status HUD, and room-move fan-out all behave as if everyone
/// is mutually friended. Built for small private servers where
/// searching + manually friend-requesting each other is friction; no
/// rows are written, so flipping it off instantly reverts to the real
/// relationship graph. Blocked relationships still suppress the pairing,
/// and the system/coach account (Id=1) is excluded.</summary>
public bool GlobalFriendsEnabled { get; set; }
/// <summary>When true, the challenge map tells the 2020 watch that /// <summary>When true, the challenge map tells the 2020 watch that
/// weekly challenge completion is required for the weekly reward flow. /// weekly challenge completion is required for the weekly reward flow.
/// Individual player challenge rows still default to incomplete until /// Individual player challenge rows still default to incomplete until
@@ -0,0 +1,62 @@
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace DorkNet.Server.Services;
/// <summary>
/// Effective-friend resolution that honors the
/// <see cref="ServerSettingsEntity.GlobalFriendsEnabled"/> toggle.
///
/// When the toggle is ON, every account on the server is treated as a
/// friend of every other account WITHOUT writing any relationship rows —
/// the friend graph is synthesized at read time. This keeps a small private
/// server frictionless (no searching + manual friend requests) and reverts
/// instantly when the toggle flips off, since nothing was persisted.
///
/// Blocked relationships still suppress the pairing, and the system/coach
/// account (<see cref="SystemAccountId"/>) is never offered as a friend.
///
/// Live propagation: flipping the toggle broadcasts
/// <c>PushNotificationId.RelationshipsInvalid</c>, which the 2020 watch
/// handles by calling <c>Relationships.RefreshList</c> — a full re-fetch of
/// <c>api/relationships/v2/get</c> — so connected players see the change
/// without relogging (verified against the readable March decompile:
/// <c>RecNet/Relationships.txt → OnRelationshipsInvalid → RefreshList</c>).
/// </summary>
public static class RelationshipQueries
{
/// <summary>Coach/system account seeded at startup (Player.Id = 1).
/// Excluded from the global-friends set so it never shows up as a
/// friend in anyone's list.</summary>
public const long SystemAccountId = 1;
/// <summary>The effective friend ids for <paramref name="me"/>. With the
/// global-friends toggle off this is the real Friend graph; with it on
/// it's every other (non-system, non-blocked) account.</summary>
public static async Task<List<long>> EffectiveFriendIdsAsync(
DorkNetDbContext db, ServerSettingsService settings, long me)
{
if (!await settings.IsGlobalFriendsEnabledAsync())
{
return await db.Relationships
.Where(r => r.Status == RelationshipStatus.Friend &&
(r.RequesterId == me || r.TargetId == me))
.Select(r => r.RequesterId == me ? r.TargetId : r.RequesterId)
.ToListAsync();
}
// Global friends: everyone except myself, the system account, and
// anyone in a Blocked relationship with me (either direction).
var blocked = await db.Relationships
.Where(r => r.Status == RelationshipStatus.Blocked &&
(r.RequesterId == me || r.TargetId == me))
.Select(r => r.RequesterId == me ? r.TargetId : r.RequesterId)
.ToListAsync();
return await db.Players
.Where(p => p.Id != me && p.Id != SystemAccountId && !blocked.Contains(p.Id))
.Select(p => p.Id)
.ToListAsync();
}
}
@@ -48,6 +48,26 @@ public class ServerSettingsService(DorkNetDbContext db)
return existing; return existing;
} }
/// <summary>When true, every account is treated as a friend of every
/// other account (synthesized at read time — no relationship rows are
/// written). See <see cref="ServerSettingsEntity.GlobalFriendsEnabled"/>
/// and <see cref="RelationshipQueries"/>.</summary>
public async Task<bool> IsGlobalFriendsEnabledAsync()
{
var row = await db.ServerSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == RowId);
return row?.GlobalFriendsEnabled ?? false;
}
public async Task<ServerSettingsEntity> SetGlobalFriendsEnabledAsync(bool enabled)
{
var existing = await GetTrackedRowAsync();
existing.GlobalFriendsEnabled = enabled;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return existing;
}
public async Task<WeeklyChallengeSettings> GetWeeklyChallengesAsync() public async Task<WeeklyChallengeSettings> GetWeeklyChallengesAsync()
{ {
var row = await GetAsync(); var row = await GetAsync();
@@ -248,6 +248,8 @@ public static class DatabaseBootstrap
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""RecCenterDoorsJson"" text NOT NULL DEFAULT '';"); @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""RecCenterDoorsJson"" text NOT NULL DEFAULT '';");
await db.Database.ExecuteSqlRawAsync( await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""DiscoveredGameConfigsJson"" text NOT NULL DEFAULT '';"); @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""DiscoveredGameConfigsJson"" text NOT NULL DEFAULT '';");
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""GlobalFriendsEnabled"" boolean NOT NULL DEFAULT false;");
return; return;
} }
@@ -286,6 +288,18 @@ public static class DatabaseBootstrap
// ignore the duplicate-column error; any other schema problem // ignore the duplicate-column error; any other schema problem
// will surface when EF reads ServerSettings. // will surface when EF reads ServerSettings.
} }
try
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""ServerSettings"" ADD COLUMN ""GlobalFriendsEnabled"" INTEGER NOT NULL DEFAULT 0;");
}
catch
{
// SQLite has no ADD COLUMN IF NOT EXISTS. If it already exists,
// ignore the duplicate-column error; any other schema problem
// will surface when EF reads ServerSettings.
}
} }
/// <summary>SQLite migration-history reconciliation. Handles two /// <summary>SQLite migration-history reconciliation. Handles two
@@ -6,6 +6,7 @@ import { Plus, RefreshCw, Trash } from '../components/Icons';
interface ServerSettings { interface ServerSettings {
signupsDisabled: boolean; signupsDisabled: boolean;
globalFriendsEnabled: boolean;
weeklyChallengesCompletedRequired: boolean; weeklyChallengesCompletedRequired: boolean;
updatedAt: string; updatedAt: string;
} }
@@ -190,6 +191,30 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
} }
}; };
const toggleGlobalFriends = async () => {
if (!settings) return;
const next = !settings.globalFriendsEnabled;
const verb = next ? 'make everyone friends' : 'turn off all-friends';
if (!confirm(
next
? 'Make every account friends with every other account? No friend rows are written — it\'s synthesized live, and connected players refresh instantly. Toggle off to revert.'
: `Really ${verb}? Players will revert to their real friends list.`,
)) return;
setBusy(true);
try {
const updated = await api<ServerSettings>('/settings/global-friends', {
method: 'POST',
body: { Enabled: next },
});
setSettings(updated);
toast.push(next ? 'Everyone is now friends — players refreshing live.' : 'All-friends turned off.', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
const updateWeeklyChallenge = (index: number, patch: Partial<WeeklyChallenge>) => { const updateWeeklyChallenge = (index: number, patch: Partial<WeeklyChallenge>) => {
setWeekly((current) => { setWeekly((current) => {
if (!current) return current; if (!current) return current;
@@ -428,6 +453,38 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
</div> </div>
)} )}
{settings && (
<div className="card !p-5 max-w-2xl mt-5">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-ink-50">Everyone is friends</h2>
<p className="mt-1 text-xs text-ink-400">
When on, every account is treated as a friend of every other account, so players
don't have to search and friend-request each other on a small server. Nothing is
written to the database — it's synthesized live, and connected players get a refresh
signal so their friends list updates without relogging. Turn it off to instantly
revert to real friend lists. Blocks are still respected.
</p>
<p className="mt-2 text-[11px] text-ink-500">
Last changed {new Date(settings.updatedAt).toLocaleString()}.
</p>
</div>
<button
onClick={toggleGlobalFriends}
disabled={busy}
className={(settings.globalFriendsEnabled ? 'btn-danger' : 'btn-primary') + ' text-xs shrink-0'}
>
{busy ? 'Working…' : settings.globalFriendsEnabled ? 'Turn off' : 'Make everyone friends'}
</button>
</div>
<div className="mt-4 text-xs">
{settings.globalFriendsEnabled
? <span className="badge-online">Everyone is friends</span>
: <span className="badge-neutral">Normal friend lists</span>}
</div>
</div>
)}
{gameConfigs && ( {gameConfigs && (
<DiscoveredGameConfigsPanel <DiscoveredGameConfigsPanel
settings={gameConfigs} settings={gameConfigs}
+15
View File
@@ -176,6 +176,21 @@ hand it to the player, and they redeem it on the site's **`/join`** page —
which creates their account bound to the device their game client which creates their account bound to the device their game client
reported, so the next launch logs straight in. reported, so the next launch logs straight in.
### Everyone-is-friends toggle
For small servers where searching + friend-requesting each other is
friction, admin **Settings → Server → "Everyone is friends"** makes every
account a friend of every other account. It writes **no** relationship
rows — the friend graph is synthesized at read time
(`RelationshipQueries.EffectiveFriendIdsAsync`, gated on
`ServerSettings.GlobalFriendsEnabled`), so flipping it off reverts
instantly. Blocks are still honored and the system/coach account is
excluded. Flipping it broadcasts `RelationshipsInvalid` to every connected
watch, which calls `Relationships.RefreshList` and re-pulls
`api/relationships/v2/get` — so players see everyone appear (or disappear)
**without relogging**. It covers the friends list, the friends-online HUD,
and room-move presence fan-out.
--- ---
## Contributing ## Contributing