mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: add endpoints to unlock all items for players, including cosmetics and hair dyes
This commit is contained in:
@@ -340,27 +340,41 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
// Watch URLs: "api/relationships/v1/favorite?id={N}" and
|
// Watch URLs: "api/relationships/v1/favorite?id={N}" and
|
||||||
// "api/relationships/v1/unfavorite?id={N}". These return a
|
// "api/relationships/v1/unfavorite?id={N}". These return a
|
||||||
// boolean-ish RecNetResult per the FavoritePlayer/UnfavoritePlayer
|
// boolean-ish RecNetResult per the FavoritePlayer/UnfavoritePlayer
|
||||||
// disassembly (Core.Post … ExpectHttpStatusSuccess), not a
|
// disassembly, not a Relationship. The flag is persisted on the caller's
|
||||||
// Relationship. The flag is persisted on the caller's directional row
|
// directional row and surfaced back through v2/get + personaldetails
|
||||||
// and surfaced back through v2/get + personaldetails (see BuildMerged).
|
// (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")]
|
[HttpPost("api/relationships/v1/favorite")]
|
||||||
public Task<IActionResult> Favorite([FromQuery] long id) => SetPreference(id, favorited: true);
|
public Task<IActionResult> Favorite([FromQuery] long id) => SetPreference(id, favorited: true);
|
||||||
|
|
||||||
|
[HttpGet("api/relationships/v1/unfavorite")]
|
||||||
[HttpPost("api/relationships/v1/unfavorite")]
|
[HttpPost("api/relationships/v1/unfavorite")]
|
||||||
public Task<IActionResult> Unfavorite([FromQuery] long id) => SetPreference(id, favorited: false);
|
public Task<IActionResult> Unfavorite([FromQuery] long id) => SetPreference(id, favorited: false);
|
||||||
|
|
||||||
|
[HttpGet("api/relationships/v1/mute")]
|
||||||
[HttpPost("api/relationships/v1/mute")]
|
[HttpPost("api/relationships/v1/mute")]
|
||||||
public Task<IActionResult> Mute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
|
public Task<IActionResult> Mute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
|
||||||
=> SetPreference(id != 0 ? id : formId ?? 0, muted: true);
|
=> SetPreference(id != 0 ? id : formId ?? 0, muted: true);
|
||||||
|
|
||||||
|
[HttpGet("api/relationships/v1/unmute")]
|
||||||
[HttpPost("api/relationships/v1/unmute")]
|
[HttpPost("api/relationships/v1/unmute")]
|
||||||
public Task<IActionResult> Unmute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
|
public Task<IActionResult> Unmute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
|
||||||
=> SetPreference(id != 0 ? id : formId ?? 0, muted: false);
|
=> SetPreference(id != 0 ? id : formId ?? 0, muted: false);
|
||||||
|
|
||||||
|
[HttpGet("api/relationships/v1/ignore")]
|
||||||
[HttpPost("api/relationships/v1/ignore")]
|
[HttpPost("api/relationships/v1/ignore")]
|
||||||
public Task<IActionResult> Ignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
|
public Task<IActionResult> Ignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
|
||||||
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: true);
|
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: true);
|
||||||
|
|
||||||
|
[HttpGet("api/relationships/v1/unignore")]
|
||||||
[HttpPost("api/relationships/v1/unignore")]
|
[HttpPost("api/relationships/v1/unignore")]
|
||||||
public Task<IActionResult> Unignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
|
public Task<IActionResult> Unignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
|
||||||
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: false);
|
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: false);
|
||||||
|
|||||||
@@ -2067,6 +2067,106 @@ public class AdminController(
|
|||||||
return Ok(new { granted = body.ItemId, quantity = qty });
|
return Ok(new { granted = body.ItemId, quantity = qty });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>POST <c>api/admin/v1/players/{id}/inventory/unlock-all</c>
|
||||||
|
/// — grant the player the ENTIRE catalog: every avatar cosmetic GUID
|
||||||
|
/// (merged into <see cref="AvatarEntity.InventoryJson"/>), every
|
||||||
|
/// permanent hair dye, and every store equipment/consumable slug (as
|
||||||
|
/// <see cref="PlayerInventoryEntity"/> 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.</summary>
|
||||||
|
[HttpPost("players/{id:long}/inventory/unlock-all")]
|
||||||
|
public async Task<ActionResult> 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<string>(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<string>(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<string> ParseOwnedDescs(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json) || json == "[]") return new();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var strings = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
|
||||||
|
if (strings is not null && strings.Count > 0) return strings;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
var result = new List<string>();
|
||||||
|
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);
|
public sealed record CurrencyRequest(int CurrencyType, long Amount, string? Reason);
|
||||||
|
|
||||||
/// <summary>Adjust a player's currency wallet. Positive amounts
|
/// <summary>Adjust a player's currency wallet. Positive amounts
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
|
|||||||
("hairdye-pastel-pink", "Permanent Maker Mauve Hair Dye", "[hairdyepotionconsumable_pastelpink]", "G129V_kVXkSLN2OG8EjSqQ"),
|
("hairdye-pastel-pink", "Permanent Maker Mauve Hair Dye", "[hairdyepotionconsumable_pastelpink]", "G129V_kVXkSLN2OG8EjSqQ"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>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 <see cref="PlayerInventoryEntity"/> (hair dyes are owned as
|
||||||
|
/// consumable slugs, not avatar GUIDs).</summary>
|
||||||
|
public static IEnumerable<string> AllHairDyeSlugs =>
|
||||||
|
PermanentHairDyes.Select(d => d.Slug);
|
||||||
|
|
||||||
private bool EnableWatchGiftDrops =>
|
private bool EnableWatchGiftDrops =>
|
||||||
config.GetValue("Store:EnableWatchGiftDrops", true);
|
config.GetValue("Store:EnableWatchGiftDrops", true);
|
||||||
|
|
||||||
|
|||||||
@@ -472,6 +472,17 @@ function GrantsTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => v
|
|||||||
>Unlock all categories</button>
|
>Unlock all categories</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card !p-4 md:col-span-2">
|
||||||
|
<h3 className="text-sm font-semibold text-ink-50 mb-1">Unlock all items</h3>
|
||||||
|
<p className="text-xs text-ink-400 mb-3">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => run('Unlock all', () => api(`/players/${data.id}/inventory/unlock-all`, { method: 'POST' }))}
|
||||||
|
className="btn-primary text-xs"
|
||||||
|
>Unlock everything</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card !p-4 md:col-span-2">
|
<div className="card !p-4 md:col-span-2">
|
||||||
<h3 className="text-sm font-semibold text-ink-50 mb-1">Grant inventory item</h3>
|
<h3 className="text-sm font-semibold text-ink-50 mb-1">Grant inventory item</h3>
|
||||||
<p className="text-xs text-ink-400 mb-3">Appends an item GUID to the player's inventory JSON. Use the Store catalog to find slugs / GUIDs.</p>
|
<p className="text-xs text-ink-400 mb-3">Appends an item GUID to the player's inventory JSON. Use the Store catalog to find slugs / GUIDs.</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user