From e27b06ce5b34b42c9a7373742419fd2e92ddd966 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:37:47 +0300 Subject: [PATCH] feat: Implement "everyone owns all avatar items" toggle; update server settings, controller logic, and admin UI --- .../API/Avatar/V4/AvatarItemsController.cs | 53 ++++++++++++++++- .../Controllers/Admin/AdminController.cs | 33 +++++++++++ .../Data/Entities/ServerSettingsEntity.cs | 10 ++++ .../Services/ServerSettingsService.cs | 20 +++++++ DorkNet.Server/Startup/DatabaseBootstrap.cs | 14 +++++ .../Startup/RequestTracingExtensions.cs | 22 ++++++- .../admin-ui/src/pages/Settings.tsx | 57 +++++++++++++++++++ 7 files changed, 205 insertions(+), 4 deletions(-) diff --git a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs index 4ddabff..17c1bbc 100644 --- a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs +++ b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs @@ -62,7 +62,10 @@ namespace DorkNet.Server.Controllers.API.Avatar.V4; /// Anonymous calls return []. /// [ApiController] -public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : ControllerBase +public class AvatarItemsController( + DorkNetDbContext db, + IConfiguration config, + ServerSettingsService serverSettings) : ControllerBase { /// Master catalog keyed by GUID. Loaded once on first /// request from data/avatar_item_lookup.json. Same @@ -97,6 +100,15 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 4000), 1, 8000); var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 1000), 1, 2000); + // "Everyone owns all avatar items" admin toggle (mirrors the + // "everyone is friends" one): report the ENTIRE master catalog plus + // all permanent hair dyes as owned, for every caller, without reading + // or writing any inventory row. Flipping the toggle off instantly + // reverts to the real per-player wardrobe below. Bounded by the same + // maxItems ceiling so a future watch build can't be over-fed. + if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync()) + return Ok(BuildEntireCatalogOwned(maxItems)); + var pid = this.CurrentPlayerId(); if (pid is not long me) return Ok(new List()); @@ -187,6 +199,39 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : return Ok(result); } + /// The full owned set when the "everyone owns all avatar + /// items" toggle is on: every catalog item (already carrying the + /// "{guid},,," desc) plus every permanent hair dye, capped at + /// . Hair dyes are emitted in the same + /// shape the per-player path uses (AvatarItemType.HairDye, desc = + /// hair-color GUID) so the wardrobe renders them identically. + private static List BuildEntireCatalogOwned(int maxItems) + { + var catalog = _catalog.Value; + var result = new List(Math.Min(maxItems, catalog.Items.Count + 32)); + foreach (var item in catalog.Items.Values) + { + if (result.Count >= maxItems) return result; + result.Add(item); + } + foreach (var slug in StoreService.AllHairDyeSlugs) + { + if (result.Count >= maxItems) break; + if (!StoreService.TryGetHairDyePayload(slug, out var hairColorGuid)) continue; + StoreService.TryGetHairDyeName(slug, out var friendlyName); + result.Add(new UnlockedAvatarItemDto + { + AvatarItemType = AvatarItemType.HairDye, + AvatarItemDesc = hairColorGuid, + PlatformMask = unchecked((int)0xFFFFFFFF), + FriendlyName = friendlyName, + Tooltip = string.Empty, + Rarity = 0, + }); + } + return result; + } + [HttpGet("api/avatar/v1/defaultbaseavataritems")] [AllowAnonymous] public IActionResult DefaultBaseAvatarItems() @@ -230,6 +275,12 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : var requested = RequestedAvatarItemGuids().ToList(); if (requested.Count == 0) return Ok(Array.Empty()); + // When everyone owns everything, nothing is "locked" — the store's + // owned/locked diff must see an empty locked set so every tile reads + // as owned, consistent with the full wardrobe returned by Get(). + if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync()) + return Ok(Array.Empty()); + var owned = new HashSet(StringComparer.OrdinalIgnoreCase); var pid = this.CurrentPlayerId(); if (pid is long me) diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index f4f8637..d339dce 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -1498,6 +1498,7 @@ public class AdminController( row.GlobalFriendsEnabled, row.WeeklyChallengesCompletedRequired, row.ProfanityFilterDisabled, + row.AllAvatarItemsOwned, row.UpdatedAt, }); } @@ -1505,6 +1506,7 @@ public class AdminController( public sealed record SignupsToggleRequest(bool Disabled); public sealed record GlobalFriendsToggleRequest(bool Enabled); public sealed record ProfanityFilterToggleRequest(bool Disabled); + public sealed record AllAvatarItemsToggleRequest(bool Enabled); /// POST api/admin/v1/settings/profanity — flip the /// server-side profanity filter. While disabled, every @@ -1523,6 +1525,33 @@ public class AdminController( row.GlobalFriendsEnabled, row.WeeklyChallengesCompletedRequired, row.ProfanityFilterDisabled, + row.AllAvatarItemsOwned, + row.UpdatedAt, + }); + } + + /// POST api/admin/v1/settings/all-avatar-items — flip the + /// "everyone owns all avatar items" toggle. When on, the wardrobe/store + /// "unlocked items" endpoints report the full master catalog (plus all + /// permanent hair dyes) as owned for every account. Nothing is written to + /// the inventory tables — it's synthesized at read time (see + /// ), so + /// flipping it off instantly reverts to each player's real wardrobe. + /// Players see the change the next time the watch pulls its unlocked + /// items (avatar menu open / next boot). + [HttpPost("settings/all-avatar-items")] + public async Task SetAllAvatarItemsOwned([FromBody] AllAvatarItemsToggleRequest body) + { + var row = await serverSettings.SetAllAvatarItemsOwnedEnabledAsync(body.Enabled); + await LogAsync(body.Enabled ? "all_avatar_items_enabled" : "all_avatar_items_disabled", "system", 0, ""); + await db.SaveChangesAsync(); + return Ok(new + { + row.SignupsDisabled, + row.GlobalFriendsEnabled, + row.WeeklyChallengesCompletedRequired, + row.ProfanityFilterDisabled, + row.AllAvatarItemsOwned, row.UpdatedAt, }); } @@ -1636,6 +1665,8 @@ public class AdminController( row.SignupsDisabled, row.GlobalFriendsEnabled, row.WeeklyChallengesCompletedRequired, + row.ProfanityFilterDisabled, + row.AllAvatarItemsOwned, row.UpdatedAt, }); } @@ -1657,6 +1688,8 @@ public class AdminController( row.SignupsDisabled, row.GlobalFriendsEnabled, row.WeeklyChallengesCompletedRequired, + row.ProfanityFilterDisabled, + row.AllAvatarItemsOwned, row.UpdatedAt, }); } diff --git a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs index 36c009c..2415396 100644 --- a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs +++ b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs @@ -32,6 +32,16 @@ public class ServerSettingsEntity /// and the system/coach account (Id=1) is excluded. public bool GlobalFriendsEnabled { get; set; } + /// When true, every account is reported as owning every + /// avatar item in the master catalog (plus all permanent hair dyes) — + /// the wardrobe/store "unlocked items" endpoints return the full + /// catalog for every player regardless of their real inventory. Like + /// , nothing is written to the + /// inventory tables: it's synthesized at read time, so flipping it off + /// instantly reverts to each player's actually-owned items. Color + /// variants and player-made custom (UGC) items are out of scope. + public bool AllAvatarItemsOwned { 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/ServerSettingsService.cs b/DorkNet.Server/Services/ServerSettingsService.cs index f9aec38..b6094fd 100644 --- a/DorkNet.Server/Services/ServerSettingsService.cs +++ b/DorkNet.Server/Services/ServerSettingsService.cs @@ -68,6 +68,26 @@ public class ServerSettingsService(DorkNetDbContext db) return existing; } + /// When true, every account is treated as owning every avatar + /// item in the master catalog (synthesized at read time — no inventory + /// rows are written). See + /// and . + public async Task IsAllAvatarItemsOwnedEnabledAsync() + { + var row = await db.ServerSettings.AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == RowId); + return row?.AllAvatarItemsOwned ?? false; + } + + public async Task SetAllAvatarItemsOwnedEnabledAsync(bool enabled) + { + var existing = await GetTrackedRowAsync(); + existing.AllAvatarItemsOwned = 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 0bd1658..2db2a22 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -496,6 +496,8 @@ public static class DatabaseBootstrap @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""ProfanityFilterDisabled"" boolean NOT NULL DEFAULT false;"); await db.Database.ExecuteSqlRawAsync( @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""CharadesSlotBindingsJson"" text NOT NULL DEFAULT '';"); + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""AllAvatarItemsOwned"" boolean NOT NULL DEFAULT false;"); return; } @@ -570,6 +572,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 ""AllAvatarItemsOwned"" 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. + } } private static async Task EnsureInventorySchemaAsync(DorkNetDbContext db) diff --git a/DorkNet.Server/Startup/RequestTracingExtensions.cs b/DorkNet.Server/Startup/RequestTracingExtensions.cs index 057972f..a7cd320 100644 --- a/DorkNet.Server/Startup/RequestTracingExtensions.cs +++ b/DorkNet.Server/Startup/RequestTracingExtensions.cs @@ -107,7 +107,7 @@ public static class RequestTracingExtensions try { capture.Seek(0, SeekOrigin.Begin); - if (ShouldReadResponseBody(ctx.Response.ContentType, status)) + if (ShouldReadResponseBody(ctx.Response.ContentType, status, path)) responseBody = await new StreamReader(capture, leaveOpen: true).ReadToEndAsync(); capture.Seek(0, SeekOrigin.Begin); await capture.CopyToAsync(originalBody); @@ -133,6 +133,19 @@ public static class RequestTracingExtensions : status >= 400 ? LogLevel.Warning : LogLevel.Information; + // TEMP diagnostic (presence "others online show offline"): the + // normal [req] line truncates to 300 chars and never reads 2xx + // bodies, so we can't see the /player roster the watch renders + // from. Dump the FULL redacted body for /player on any status. + // Remove once the presence-offline bug is localized. + if (path.Equals("/player", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(safeResponse)) + { + reqLogger.LogInformation( + "[presence-debug] {Method} {Host}{Path}{Query} status={Status} body={Body}", + ctx.Request.Method, ctx.Request.Host, ctx.Request.Path, + ctx.Request.QueryString.Value ?? "", status, safeResponse); + } + reqLogger.Log(level, thrown, "[req] {Status} {Method} {Host}{Path}{Query} {ElapsedMs}ms auth={AuthPresence} contentType={ContentType} contentLength={ContentLength} req={ReqBody} resp={RespBody}", status, ctx.Request.Method, ctx.Request.Host, ctx.Request.Path, @@ -228,9 +241,12 @@ public static class RequestTracingExtensions || ext.Equals(".log", StringComparison.OrdinalIgnoreCase); } - private static bool ShouldReadResponseBody(string? contentType, int status) + private static bool ShouldReadResponseBody(string? contentType, int status, string path) { - if (status < 400) return false; + // TEMP diagnostic: always read the /player roster body (even 200s) so + // [presence-debug] can dump the exact online payload the watch renders. + var isPresence = path.Equals("/player", StringComparison.OrdinalIgnoreCase); + if (status < 400 && !isPresence) return false; if (string.IsNullOrWhiteSpace(contentType)) return true; return contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) || contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase); diff --git a/DorkNet.Server/admin-ui/src/pages/Settings.tsx b/DorkNet.Server/admin-ui/src/pages/Settings.tsx index cfdac5a..4f3f6ee 100644 --- a/DorkNet.Server/admin-ui/src/pages/Settings.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Settings.tsx @@ -9,6 +9,7 @@ interface ServerSettings { globalFriendsEnabled: boolean; weeklyChallengesCompletedRequired: boolean; profanityFilterDisabled: boolean; + allAvatarItemsOwned: boolean; updatedAt: string; } @@ -216,6 +217,29 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) { } }; + const toggleAllAvatarItems = async () => { + if (!settings) return; + const next = !settings.allAvatarItemsOwned; + if (!confirm( + next + ? 'Give every account every avatar item? The whole wardrobe catalog (plus all hair dyes) shows as owned for everyone. Nothing is written to inventories — it\'s synthesized live, and reverts instantly when you toggle it off. Players see it next time the avatar menu opens.' + : 'Turn off all-items? Players revert to only the avatar items they actually own.', + )) return; + setBusy(true); + try { + const updated = await api('/settings/all-avatar-items', { + method: 'POST', + body: { Enabled: next }, + }); + setSettings(updated); + toast.push(next ? 'Everyone now owns all avatar items.' : 'All-items turned off.', 'success'); + } catch (e) { + toast.push((e as Error).message, 'error'); + } finally { + setBusy(false); + } + }; + const toggleProfanity = async () => { if (!settings) return; const next = !settings.profanityFilterDisabled; @@ -510,6 +534,39 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) { )} + {settings && ( +
+
+
+

Everyone owns all avatar items

+

+ When on, every account is reported as owning the entire avatar wardrobe catalog + (plus all permanent hair dyes), so the store shows everything as "owned" and every + item is equippable. Nothing is written to inventories — it's synthesized live, and + turning it off instantly reverts each player to the items they actually own. Players + pick up the change the next time the watch loads unlocked items (avatar menu open or + next boot). Color variants and player-made custom items aren't included. +

+

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

+
+ +
+
+ {settings.allAvatarItemsOwned + ? Everyone owns everything + : Normal wardrobes} +
+
+ )} + {settings && (