mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -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 [].
|
/// Anonymous calls return [].
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[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
|
/// <summary>Master catalog keyed by GUID. Loaded once on first
|
||||||
/// request from <c>data/avatar_item_lookup.json</c>. Same
|
/// 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 maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 4000), 1, 8000);
|
||||||
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 1000), 1, 2000);
|
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();
|
var pid = this.CurrentPlayerId();
|
||||||
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
|
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);
|
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")]
|
[HttpGet("api/avatar/v1/defaultbaseavataritems")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public IActionResult DefaultBaseAvatarItems()
|
public IActionResult DefaultBaseAvatarItems()
|
||||||
@@ -230,6 +275,12 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
|||||||
var requested = RequestedAvatarItemGuids().ToList();
|
var requested = RequestedAvatarItemGuids().ToList();
|
||||||
if (requested.Count == 0) return Ok(Array.Empty<object>());
|
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 owned = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
var pid = this.CurrentPlayerId();
|
var pid = this.CurrentPlayerId();
|
||||||
if (pid is long me)
|
if (pid is long me)
|
||||||
|
|||||||
@@ -1498,6 +1498,7 @@ public class AdminController(
|
|||||||
row.GlobalFriendsEnabled,
|
row.GlobalFriendsEnabled,
|
||||||
row.WeeklyChallengesCompletedRequired,
|
row.WeeklyChallengesCompletedRequired,
|
||||||
row.ProfanityFilterDisabled,
|
row.ProfanityFilterDisabled,
|
||||||
|
row.AllAvatarItemsOwned,
|
||||||
row.UpdatedAt,
|
row.UpdatedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1505,6 +1506,7 @@ public class AdminController(
|
|||||||
public sealed record SignupsToggleRequest(bool Disabled);
|
public sealed record SignupsToggleRequest(bool Disabled);
|
||||||
public sealed record GlobalFriendsToggleRequest(bool Enabled);
|
public sealed record GlobalFriendsToggleRequest(bool Enabled);
|
||||||
public sealed record ProfanityFilterToggleRequest(bool Disabled);
|
public sealed record ProfanityFilterToggleRequest(bool Disabled);
|
||||||
|
public sealed record AllAvatarItemsToggleRequest(bool Enabled);
|
||||||
|
|
||||||
/// <summary>POST <c>api/admin/v1/settings/profanity</c> — flip the
|
/// <summary>POST <c>api/admin/v1/settings/profanity</c> — flip the
|
||||||
/// server-side profanity filter. While disabled, every
|
/// server-side profanity filter. While disabled, every
|
||||||
@@ -1523,6 +1525,33 @@ public class AdminController(
|
|||||||
row.GlobalFriendsEnabled,
|
row.GlobalFriendsEnabled,
|
||||||
row.WeeklyChallengesCompletedRequired,
|
row.WeeklyChallengesCompletedRequired,
|
||||||
row.ProfanityFilterDisabled,
|
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,
|
row.UpdatedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1636,6 +1665,8 @@ public class AdminController(
|
|||||||
row.SignupsDisabled,
|
row.SignupsDisabled,
|
||||||
row.GlobalFriendsEnabled,
|
row.GlobalFriendsEnabled,
|
||||||
row.WeeklyChallengesCompletedRequired,
|
row.WeeklyChallengesCompletedRequired,
|
||||||
|
row.ProfanityFilterDisabled,
|
||||||
|
row.AllAvatarItemsOwned,
|
||||||
row.UpdatedAt,
|
row.UpdatedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1657,6 +1688,8 @@ public class AdminController(
|
|||||||
row.SignupsDisabled,
|
row.SignupsDisabled,
|
||||||
row.GlobalFriendsEnabled,
|
row.GlobalFriendsEnabled,
|
||||||
row.WeeklyChallengesCompletedRequired,
|
row.WeeklyChallengesCompletedRequired,
|
||||||
|
row.ProfanityFilterDisabled,
|
||||||
|
row.AllAvatarItemsOwned,
|
||||||
row.UpdatedAt,
|
row.UpdatedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ public class ServerSettingsEntity
|
|||||||
/// and the system/coach account (Id=1) is excluded.</summary>
|
/// and the system/coach account (Id=1) is excluded.</summary>
|
||||||
public bool GlobalFriendsEnabled { get; set; }
|
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
|
/// <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
|
||||||
|
|||||||
@@ -68,6 +68,26 @@ public class ServerSettingsService(DorkNetDbContext db)
|
|||||||
return existing;
|
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()
|
public async Task<WeeklyChallengeSettings> GetWeeklyChallengesAsync()
|
||||||
{
|
{
|
||||||
var row = await GetAsync();
|
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;");
|
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""ProfanityFilterDisabled"" boolean NOT NULL DEFAULT false;");
|
||||||
await db.Database.ExecuteSqlRawAsync(
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""CharadesSlotBindingsJson"" text NOT NULL DEFAULT '';");
|
@"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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,6 +572,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 ""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)
|
private static async Task EnsureInventorySchemaAsync(DorkNetDbContext db)
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ public static class RequestTracingExtensions
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
capture.Seek(0, SeekOrigin.Begin);
|
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();
|
responseBody = await new StreamReader(capture, leaveOpen: true).ReadToEndAsync();
|
||||||
capture.Seek(0, SeekOrigin.Begin);
|
capture.Seek(0, SeekOrigin.Begin);
|
||||||
await capture.CopyToAsync(originalBody);
|
await capture.CopyToAsync(originalBody);
|
||||||
@@ -133,6 +133,19 @@ public static class RequestTracingExtensions
|
|||||||
: status >= 400 ? LogLevel.Warning
|
: status >= 400 ? LogLevel.Warning
|
||||||
: LogLevel.Information;
|
: 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,
|
reqLogger.Log(level, thrown,
|
||||||
"[req] {Status} {Method} {Host}{Path}{Query} {ElapsedMs}ms auth={AuthPresence} contentType={ContentType} contentLength={ContentLength} req={ReqBody} resp={RespBody}",
|
"[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,
|
status, ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
|
||||||
@@ -228,9 +241,12 @@ public static class RequestTracingExtensions
|
|||||||
|| ext.Equals(".log", StringComparison.OrdinalIgnoreCase);
|
|| 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;
|
if (string.IsNullOrWhiteSpace(contentType)) return true;
|
||||||
return contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)
|
return contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)
|
||||||
|| contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase);
|
|| contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface ServerSettings {
|
|||||||
globalFriendsEnabled: boolean;
|
globalFriendsEnabled: boolean;
|
||||||
weeklyChallengesCompletedRequired: boolean;
|
weeklyChallengesCompletedRequired: boolean;
|
||||||
profanityFilterDisabled: boolean;
|
profanityFilterDisabled: boolean;
|
||||||
|
allAvatarItemsOwned: boolean;
|
||||||
updatedAt: string;
|
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 () => {
|
const toggleProfanity = async () => {
|
||||||
if (!settings) return;
|
if (!settings) return;
|
||||||
const next = !settings.profanityFilterDisabled;
|
const next = !settings.profanityFilterDisabled;
|
||||||
@@ -510,6 +534,39 @@ 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 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 && (
|
{settings && (
|
||||||
<div className="card !p-5 max-w-2xl mt-5">
|
<div className="card !p-5 max-w-2xl mt-5">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user