mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 15:38:45 -07:00
feat: Implement "everyone owns all avatar items" toggle; update server settings, controller logic, and admin UI
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user