feat: Implement "everyone owns all avatar items" toggle; update server settings, controller logic, and admin UI

This commit is contained in:
Alexa
2026-07-09 11:37:47 +03:00
parent 479ea273ea
commit e27b06ce5b
7 changed files with 205 additions and 4 deletions
@@ -62,7 +62,10 @@ namespace DorkNet.Server.Controllers.API.Avatar.V4;
/// Anonymous calls return [].
/// </summary>
[ApiController]
public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : ControllerBase
public class AvatarItemsController(
DorkNetDbContext db,
IConfiguration config,
ServerSettingsService serverSettings) : ControllerBase
{
/// <summary>Master catalog keyed by GUID. Loaded once on first
/// request from <c>data/avatar_item_lookup.json</c>. 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<UnlockedAvatarItemDto>());
@@ -187,6 +199,39 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
return Ok(result);
}
/// <summary>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
/// <paramref name="maxItems"/>. 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.</summary>
private static List<UnlockedAvatarItemDto> BuildEntireCatalogOwned(int maxItems)
{
var catalog = _catalog.Value;
var result = new List<UnlockedAvatarItemDto>(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<object>());
// 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<object>());
var owned = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var pid = this.CurrentPlayerId();
if (pid is long me)
@@ -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);
/// <summary>POST <c>api/admin/v1/settings/profanity</c> — 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,
});
}
/// <summary>POST <c>api/admin/v1/settings/all-avatar-items</c> — 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
/// <see cref="Controllers.API.Avatar.V4.AvatarItemsController"/>), 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).</summary>
[HttpPost("settings/all-avatar-items")]
public async Task<ActionResult> 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,
});
}
@@ -32,6 +32,16 @@ public class ServerSettingsEntity
/// and the system/coach account (Id=1) is excluded.</summary>
public bool GlobalFriendsEnabled { get; set; }
/// <summary>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
/// <see cref="GlobalFriendsEnabled"/>, 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.</summary>
public bool AllAvatarItemsOwned { get; set; }
/// <summary>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
@@ -68,6 +68,26 @@ public class ServerSettingsService(DorkNetDbContext db)
return existing;
}
/// <summary>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 <see cref="ServerSettingsEntity.AllAvatarItemsOwned"/>
/// and <see cref="Controllers.API.Avatar.V4.AvatarItemsController"/>.</summary>
public async Task<bool> IsAllAvatarItemsOwnedEnabledAsync()
{
var row = await db.ServerSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == RowId);
return row?.AllAvatarItemsOwned ?? false;
}
public async Task<ServerSettingsEntity> SetAllAvatarItemsOwnedEnabledAsync(bool enabled)
{
var existing = await GetTrackedRowAsync();
existing.AllAvatarItemsOwned = enabled;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return existing;
}
public async Task<WeeklyChallengeSettings> GetWeeklyChallengesAsync()
{
var row = await GetAsync();
@@ -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)
@@ -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);
@@ -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<ServerSettings>('/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 } = {}) {
</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 owns all avatar items</h2>
<p className="mt-1 text-xs text-ink-400">
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.
</p>
<p className="mt-2 text-[11px] text-ink-500">
Last changed {new Date(settings.updatedAt).toLocaleString()}.
</p>
</div>
<button
onClick={toggleAllAvatarItems}
disabled={busy}
className={(settings.allAvatarItemsOwned ? 'btn-danger' : 'btn-primary') + ' text-xs shrink-0'}
>
{busy ? 'Working…' : settings.allAvatarItemsOwned ? 'Turn off' : 'Give everyone all items'}
</button>
</div>
<div className="mt-4 text-xs">
{settings.allAvatarItemsOwned
? <span className="badge-online">Everyone owns everything</span>
: <span className="badge-neutral">Normal wardrobes</span>}
</div>
</div>
)}
{settings && (
<div className="card !p-5 max-w-2xl mt-5">
<div className="flex items-start justify-between gap-4">