diff --git a/DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs b/DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs index afc3c77..3626eba 100644 --- a/DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs +++ b/DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs @@ -24,6 +24,7 @@ public class MessagesController( PrivateInstanceService privateInstances, OnlinePresenceService onlinePresence, PlayerPresenceService presence, + ServerSettingsService serverSettings, ILogger logger) : ControllerBase { private long Me => this.RequireCurrentPlayerId(); @@ -534,11 +535,8 @@ public class MessagesController( [HttpGet("/api/messages/v1/onlineStatus")] public async Task FavoriteFriendOnlineStatus() { - var friendIds = 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(); + // Honors the global-friends toggle (everyone is a friend when on). + var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, Me); var online = onlinePresence.OnlinePlayerIds().ToHashSet(); return Ok(friendIds.Select(id => new { diff --git a/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs b/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs index a424e3c..f2df83d 100644 --- a/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs +++ b/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs @@ -48,7 +48,7 @@ namespace DorkNet.Server.Controllers.API.Relationships.V2; /// [ApiController] [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 // in the patched dump.cs. @@ -72,6 +72,13 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no public async Task 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(); @@ -79,6 +86,55 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no return Ok(wire); } + /// 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. + private async Task>> BuildGlobalFriendListAsync(long me) + { + var realRows = await db.Relationships + .Where(r => r.RequesterId == me || r.TargetId == me) + .ToListAsync(); + var realByOther = new Dictionary(); + 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>(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; + } + + /// A synthetic Friend relationship for global-friends mode, + /// carrying mute/ignore/favorite from the caller's real row when present. + private static Dictionary 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, + }; + } + /// GET api/relationships/v2/personaldetails/{playerId} /// — single relationship row from the caller's perspective. Used /// 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; if (playerId <= 0 || playerId == me) return Ok(EmptyRelationship(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)); } diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index d1cd777..0fac837 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -1390,12 +1390,41 @@ public class AdminController( return Ok(new { row.SignupsDisabled, + row.GlobalFriendsEnabled, row.WeeklyChallengesCompletedRequired, row.UpdatedAt, }); } public sealed record SignupsToggleRequest(bool Disabled); + public sealed record GlobalFriendsToggleRequest(bool Enabled); + + /// POST api/admin/v1/settings/global-friends — flip the + /// "everyone is friends" toggle. No relationship rows are written; the + /// friend graph is synthesized at read time (see + /// ). After flipping, broadcast + /// RelationshipsInvalid to every connected watch so it re-fetches + /// api/relationships/v2/get and shows the new state live — no + /// relog (the 2020 watch handles that push via + /// Relationships.RefreshList). + [HttpPost("settings/global-friends")] + public async Task 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, + }); + } /// POST api/admin/v1/settings/signups — flip the /// server-wide account-creation kill switch. While disabled, both @@ -1412,6 +1441,7 @@ public class AdminController( return Ok(new { row.SignupsDisabled, + row.GlobalFriendsEnabled, row.WeeklyChallengesCompletedRequired, row.UpdatedAt, }); diff --git a/DorkNet.Server/Controllers/Match/GoToController.cs b/DorkNet.Server/Controllers/Match/GoToController.cs index 5459c71..3193b91 100644 --- a/DorkNet.Server/Controllers/Match/GoToController.cs +++ b/DorkNet.Server/Controllers/Match/GoToController.cs @@ -40,6 +40,7 @@ public class GoToController( DorkNet.Server.Data.DorkNetDbContext db, NotificationService notifications, JoinTimeoutService joinTimeouts, + ServerSettingsService serverSettings, ILogger logger) : ControllerBase { [HttpPost("/goto/room/{roomName}")] @@ -1005,11 +1006,10 @@ public class GoToController( private async Task NotifyFriendsOfMoveAsync(long playerId, RoomInstanceDto room) { - var friendIds = await db.Relationships - .Where(r => r.Status == DorkNet.Server.Data.Entities.RelationshipStatus.Friend && - (r.RequesterId == playerId || r.TargetId == playerId)) - .Select(r => r.RequesterId == playerId ? r.TargetId : r.RequesterId) - .ToListAsync(); + // Honors the global-friends toggle: with it on, every other player is + // a "friend" and gets the presence ping (so the whole small server + // sees each other's room moves). + var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, playerId); logger.LogInformation( "[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName} → notifying {FriendCount} friend(s)", playerId, room.RoomId, room.Name, friendIds.Count); diff --git a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs index d090142..4d3c979 100644 --- a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs +++ b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs @@ -22,6 +22,16 @@ public class ServerSettingsEntity /// brand-new account requests are blocked. public bool SignupsDisabled { get; set; } + /// 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. + public bool GlobalFriendsEnabled { get; set; } + /// When true, the challenge map tells the 2020 watch that /// weekly challenge completion is required for the weekly reward flow. /// Individual player challenge rows still default to incomplete until diff --git a/DorkNet.Server/Services/RelationshipQueries.cs b/DorkNet.Server/Services/RelationshipQueries.cs new file mode 100644 index 0000000..3a126c2 --- /dev/null +++ b/DorkNet.Server/Services/RelationshipQueries.cs @@ -0,0 +1,62 @@ +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace DorkNet.Server.Services; + +/// +/// Effective-friend resolution that honors the +/// 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 () is never offered as a friend. +/// +/// Live propagation: flipping the toggle broadcasts +/// PushNotificationId.RelationshipsInvalid, which the 2020 watch +/// handles by calling Relationships.RefreshList — a full re-fetch of +/// api/relationships/v2/get — so connected players see the change +/// without relogging (verified against the readable March decompile: +/// RecNet/Relationships.txt → OnRelationshipsInvalid → RefreshList). +/// +public static class RelationshipQueries +{ + /// 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. + public const long SystemAccountId = 1; + + /// The effective friend ids for . With the + /// global-friends toggle off this is the real Friend graph; with it on + /// it's every other (non-system, non-blocked) account. + public static async Task> 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(); + } +} diff --git a/DorkNet.Server/Services/ServerSettingsService.cs b/DorkNet.Server/Services/ServerSettingsService.cs index 2a3dd6d..72230bc 100644 --- a/DorkNet.Server/Services/ServerSettingsService.cs +++ b/DorkNet.Server/Services/ServerSettingsService.cs @@ -48,6 +48,26 @@ public class ServerSettingsService(DorkNetDbContext db) return existing; } + /// When true, every account is treated as a friend of every + /// other account (synthesized at read time — no relationship rows are + /// written). See + /// and . + public async Task IsGlobalFriendsEnabledAsync() + { + var row = await db.ServerSettings.AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == RowId); + return row?.GlobalFriendsEnabled ?? false; + } + + public async Task SetGlobalFriendsEnabledAsync(bool enabled) + { + var existing = await GetTrackedRowAsync(); + existing.GlobalFriendsEnabled = enabled; + existing.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return existing; + } + public async Task GetWeeklyChallengesAsync() { var row = await GetAsync(); diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index 67cde5a..7857bdf 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -248,6 +248,8 @@ public static class DatabaseBootstrap @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""RecCenterDoorsJson"" text NOT NULL DEFAULT '';"); await db.Database.ExecuteSqlRawAsync( @"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; } @@ -286,6 +288,18 @@ public static class DatabaseBootstrap // ignore the duplicate-column error; any other schema problem // 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. + } } /// SQLite migration-history reconciliation. Handles two diff --git a/DorkNet.Server/admin-ui/src/pages/Settings.tsx b/DorkNet.Server/admin-ui/src/pages/Settings.tsx index 013b1ec..f518e91 100644 --- a/DorkNet.Server/admin-ui/src/pages/Settings.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Settings.tsx @@ -6,6 +6,7 @@ import { Plus, RefreshCw, Trash } from '../components/Icons'; interface ServerSettings { signupsDisabled: boolean; + globalFriendsEnabled: boolean; weeklyChallengesCompletedRequired: boolean; 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('/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) => { setWeekly((current) => { if (!current) return current; @@ -428,6 +453,38 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) { )} + {settings && ( +
+
+
+

Everyone is friends

+

+ 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. +

+

+ Last changed {new Date(settings.updatedAt).toLocaleString()}. +

+
+ +
+
+ {settings.globalFriendsEnabled + ? Everyone is friends + : Normal friend lists} +
+
+ )} + {gameConfigs && (