diff --git a/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs b/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs index f652972..bbb0838 100644 --- a/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs +++ b/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs @@ -340,27 +340,41 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no // Watch URLs: "api/relationships/v1/favorite?id={N}" and // "api/relationships/v1/unfavorite?id={N}". These return a // boolean-ish RecNetResult per the FavoritePlayer/UnfavoritePlayer - // disassembly (Core.Post … ExpectHttpStatusSuccess), not a - // Relationship. The flag is persisted on the caller's directional row - // and surfaced back through v2/get + personaldetails (see BuildMerged). + // disassembly, not a Relationship. The flag is persisted on the caller's + // directional row and surfaced back through v2/get + personaldetails + // (see BuildMerged). + // + // VERB: the 2020 watch sends these as GET (RecNet.Relationships + // FavoritePlayer/UnfavoritePlayer/etc. call Core.Get — verified in the + // readable March dump at Relationships.txt:1675/1834, and the December + // build hits the same obfuscated Core-GET wrapper). The endpoints were + // POST-only, so the watch's GET returned HTTP 405 ("Error favoriting + // player: HTTP Error 405"). Accept GET (real verb) + POST (cross-build + // safety), mirroring the friend endpoints above. + [HttpGet("api/relationships/v1/favorite")] [HttpPost("api/relationships/v1/favorite")] public Task Favorite([FromQuery] long id) => SetPreference(id, favorited: true); + [HttpGet("api/relationships/v1/unfavorite")] [HttpPost("api/relationships/v1/unfavorite")] public Task Unfavorite([FromQuery] long id) => SetPreference(id, favorited: false); + [HttpGet("api/relationships/v1/mute")] [HttpPost("api/relationships/v1/mute")] public Task Mute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId) => SetPreference(id != 0 ? id : formId ?? 0, muted: true); + [HttpGet("api/relationships/v1/unmute")] [HttpPost("api/relationships/v1/unmute")] public Task Unmute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId) => SetPreference(id != 0 ? id : formId ?? 0, muted: false); + [HttpGet("api/relationships/v1/ignore")] [HttpPost("api/relationships/v1/ignore")] public Task Ignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId) => SetPreference(id != 0 ? id : formId ?? 0, ignored: true); + [HttpGet("api/relationships/v1/unignore")] [HttpPost("api/relationships/v1/unignore")] public Task Unignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId) => SetPreference(id != 0 ? id : formId ?? 0, ignored: false); diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index 1de4655..cd070dd 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -2067,6 +2067,106 @@ public class AdminController( return Ok(new { granted = body.ItemId, quantity = qty }); } + /// POST api/admin/v1/players/{id}/inventory/unlock-all + /// — grant the player the ENTIRE catalog: every avatar cosmetic GUID + /// (merged into ), every + /// permanent hair dye, and every store equipment/consumable slug (as + /// rows). Idempotent — only adds + /// what's missing. Pushes SubscriptionUpdateProfile so the watch + /// re-fetches without a relog. + /// + /// Cosmetics use the canonical InventoryJson shape — a plain JSON array + /// of GUID strings (what the buy/gift flow writes and + /// AvatarItemsController.ParseInventoryEntries reads). Only BARE GUIDs + /// from the authoritative safe-list catalog are added, so every + /// unlocked item is one this build actually ships (no missing-bundle + /// crashes). NOTE: the 2020 watch's wardrobe only renders up to + /// Avatar:MaxUnlockedItems (hard cap 120) owned cosmetics at once, so + /// the player won't see thousands of tiles even though all are granted. + [HttpPost("players/{id:long}/inventory/unlock-all")] + public async Task UnlockAllItems(long id) + { + var player = await db.Players.FirstOrDefaultAsync(x => x.Id == id); + if (player is null) return NotFound(); + + // 1) Avatar cosmetics → InventoryJson (JSON array of GUID strings). + var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id); + if (avatar is null) { avatar = new AvatarEntity { PlayerId = id }; db.Avatars.Add(avatar); } + + var owned = ParseOwnedDescs(avatar.InventoryJson); + var ownedSet = new HashSet(owned, StringComparer.OrdinalIgnoreCase); + var catalog = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog; + var addedCosmetics = 0; + foreach (var guid in catalog.Keys) + if (ownedSet.Add(guid)) { owned.Add(guid); addedCosmetics++; } + avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(owned); + avatar.UpdatedAt = DateTime.UtcNow; + + // 2) Hair dyes + store equipment/consumables → PlayerInventory (by slug). + var existingSlugs = await db.PlayerInventory + .Where(pi => pi.PlayerId == id) + .Select(pi => pi.ItemSlug) + .ToListAsync(); + var slugSet = new HashSet(existingSlugs, StringComparer.OrdinalIgnoreCase); + var allSlugs = StoreService.AllHairDyeSlugs + .Concat(await db.StoreItems.Select(s => s.Slug).ToListAsync()); + var addedSlugs = 0; + foreach (var slug in allSlugs) + { + if (string.IsNullOrWhiteSpace(slug) || !slugSet.Add(slug)) continue; + db.PlayerInventory.Add(new PlayerInventoryEntity { PlayerId = id, ItemSlug = slug, Quantity = 1 }); + addedSlugs++; + } + + await LogAsync("unlock_all_items", "player", id, + $"cosmetics+{addedCosmetics} (catalog={catalog.Count}) slugs+{addedSlugs}"); + await db.SaveChangesAsync(); + + await notifications.NotifyAsync(id, + PushNotificationId.SubscriptionUpdateProfile, + new { Reason = "AdminUnlockAll" }); + + return Ok(new + { + id, + addedCosmetics, + totalCosmetics = catalog.Count, + addedSlugs, + ownedCosmetics = owned.Count, + }); + } + + // Tolerant parse of InventoryJson into a flat list of owned item descs. + // Canonical shape is a JSON string array; also accepts the legacy + // [{itemId,...}] object shape so existing data is never clobbered. + private static List ParseOwnedDescs(string? json) + { + if (string.IsNullOrWhiteSpace(json) || json == "[]") return new(); + try + { + var strings = System.Text.Json.JsonSerializer.Deserialize>(json); + if (strings is not null && strings.Count > 0) return strings; + } + catch { } + var result = new List(); + try + { + using var doc = System.Text.Json.JsonDocument.Parse(json); + if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array) + foreach (var el in doc.RootElement.EnumerateArray()) + { + if (el.ValueKind == System.Text.Json.JsonValueKind.String) + result.Add(el.GetString() ?? string.Empty); + else if (el.ValueKind == System.Text.Json.JsonValueKind.Object && + el.TryGetProperty("itemId", out var iid) && + iid.ValueKind == System.Text.Json.JsonValueKind.String) + result.Add(iid.GetString() ?? string.Empty); + } + } + catch { } + return result; + } + public sealed record CurrencyRequest(int CurrencyType, long Amount, string? Reason); /// Adjust a player's currency wallet. Positive amounts diff --git a/DorkNet.Server/Services/StoreService.cs b/DorkNet.Server/Services/StoreService.cs index 94980fc..045e427 100644 --- a/DorkNet.Server/Services/StoreService.cs +++ b/DorkNet.Server/Services/StoreService.cs @@ -94,6 +94,13 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio ("hairdye-pastel-pink", "Permanent Maker Mauve Hair Dye", "[hairdyepotionconsumable_pastelpink]", "G129V_kVXkSLN2OG8EjSqQ"), }; + /// Every permanent hair-dye store slug — used by the admin + /// "unlock all items" action to drop the whole hair-dye set into a + /// player's (hair dyes are owned as + /// consumable slugs, not avatar GUIDs). + public static IEnumerable AllHairDyeSlugs => + PermanentHairDyes.Select(d => d.Slug); + private bool EnableWatchGiftDrops => config.GetValue("Store:EnableWatchGiftDrops", true); diff --git a/DorkNet.Server/admin-ui/src/pages/Players.tsx b/DorkNet.Server/admin-ui/src/pages/Players.tsx index a0a9ba0..aeaa644 100644 --- a/DorkNet.Server/admin-ui/src/pages/Players.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Players.tsx @@ -472,6 +472,17 @@ function GrantsTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => v >Unlock all categories +
+

Unlock all items

+

+ Grants the player the entire catalog: every avatar cosmetic, every permanent hair dye, and every store equipment/consumable. Idempotent — only adds what's missing. The watch's wardrobe only displays up to ~120 owned cosmetics at once (hard client cap), but all are granted and store items show as owned. +

+ +
+

Grant inventory item

Appends an item GUID to the player's inventory JSON. Use the Store catalog to find slugs / GUIDs.