mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Import december-2020-12-18 from private repo (scrubbed 2026-05-27)
Targets Rec Room 2020.12.18 client.
Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces the dorknet-clientpatch-{key}.zip
Release asset the launcher fetches.
- .github/workflows/release.yml — auto-publishes both Release assets
on v1-december-* tag push.
Scrubbed via maintainer-side scrub-for-public.ps1: same drop rules as
the prior import, plus build-patcher-zip.ps1 added to the tools/
allow-list so it survives the scrub.
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Avatar.V2;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/avatar/v2/gifts — wardrobe gift inbox. Mirrors
|
||||
/// the client's <c>RecNet.Avatars.DowloadGiftPackages</c> +
|
||||
/// <c>LocalRequestGiftPackage</c> + <c>LocalConsumeGiftPackage</c>
|
||||
/// flow. Wire DTO matches <c>RecNet.Avatars+GiftPackage</c>
|
||||
/// (<c>Cpp2IL_CS/.../RecNet/Avatars.cs:192-555</c>) field-for-field
|
||||
/// so the watch's deserialiser doesn't crash.
|
||||
///
|
||||
/// Server semantics:
|
||||
/// - <c>GET v2/gifts</c> returns unconsumed gifts for caller.
|
||||
/// - <c>POST v2/gifts/generate</c> rolls a random unlocked-avatar
|
||||
/// item and stamps a <see cref="GiftPackageEntity"/> for caller —
|
||||
/// used by drop tiles and "free daily" prompts.
|
||||
/// - <c>POST v2/gifts/consume/{id}</c> marks consumed and appends
|
||||
/// the <see cref="GiftPackageEntity.AvatarItemDescOrHairDyeDesc"/>
|
||||
/// to the player's <see cref="AvatarEntity.InventoryJson"/> so it
|
||||
/// shows up in their backpack on next load.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class AvatarGiftsController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
LevelService level) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/avatar/v2/gifts")]
|
||||
[HttpGet("api/avatar/v3/gifts")]
|
||||
[HttpGet("api/avatar/v4/gifts")]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var rows = await db.GiftPackages
|
||||
.Where(g => g.RecipientPlayerId == pid && !g.Consumed)
|
||||
.OrderByDescending(g => g.CreatedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
public sealed record GenerateGiftRequest(int? GiftContext);
|
||||
|
||||
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — server picks
|
||||
/// a random unlocked-avatar item and queues a gift package. The
|
||||
/// 2020 client posts to this when the user clicks a free-gift
|
||||
/// tile or hits a context the client maps to <c>GiftContext</c>.</summary>
|
||||
[HttpPost("api/avatar/v2/gifts/generate")]
|
||||
public async Task<IActionResult> Generate([FromBody] GenerateGiftRequest? req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
// Pick a random wardrobe StoreItem (Slug starts with
|
||||
// "wardrobe-" — that's the convention StoreService uses for
|
||||
// catalogue rows derived from outfits_assets bundles).
|
||||
var pool = await db.StoreItems
|
||||
.Where(s => s.IsActive && s.Slug.StartsWith("wardrobe-"))
|
||||
.Select(s => new { s.Slug, s.DisplayName })
|
||||
.ToListAsync();
|
||||
if (pool.Count == 0) return StatusCode(503, "no wardrobe items in catalogue");
|
||||
|
||||
var rng = new Random();
|
||||
var pick = pool[rng.Next(pool.Count)];
|
||||
var guid = pick.Slug["wardrobe-".Length..];
|
||||
|
||||
// Bare GUIDs crash AvatarItem.FromRecNetString — append `,,,`
|
||||
// so the watch's parse-and-iterate doesn't blow up in
|
||||
// GiftManager.DequeueGift. See AvatarItemsController.cs:31-40
|
||||
// for the canonical wire-format note.
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = pid,
|
||||
FromPlayerId = null,
|
||||
AvatarItemType = 0, // Outfit
|
||||
AvatarItemDescOrHairDyeDesc = guid + ",,,",
|
||||
CurrencyType = 0, // Invalid (no currency in this gift)
|
||||
GiftContext = req?.GiftContext ?? 0,
|
||||
GiftRarity = RollRarity(rng),
|
||||
Message = $"Enjoy your new {pick.DisplayName}!",
|
||||
Platform = -1,
|
||||
PackageVariant = "Standard",
|
||||
PackageMaterial = string.Empty,
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
};
|
||||
db.GiftPackages.Add(gift);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Push so the watch shows the new-gift toast.
|
||||
await notifications.NotifyAsync(pid,
|
||||
PushNotificationId.GiftPackageReceived, ToWire(gift));
|
||||
return Ok(ToWire(gift));
|
||||
}
|
||||
|
||||
[HttpPost("api/avatar/v2/gifts/consume/{id:long}")]
|
||||
public Task<IActionResult> ConsumeViaPath(long id) => ConsumeImpl(id);
|
||||
|
||||
/// <summary>POST <c>/api/avatar/v2/gifts/consume/</c> (note trailing
|
||||
/// slash, no id in path). The watch's <c>LocalConsumeGiftPackage</c>
|
||||
/// (verified at <c>Cpp2IL_ISIL/.../RecNet/Avatars_NestedType__LocalConsumeGiftPackage_d__19.txt:276-301</c>)
|
||||
/// posts to this exact URL with a form-urlencoded body containing
|
||||
/// <c>Id</c> and <c>UnlockedLevel</c> fields. The previous
|
||||
/// implementation took <c>[FromBody] ConsumeGiftRequest</c> which
|
||||
/// only accepts <c>application/json</c> — the watch's form-encoded
|
||||
/// body got 415 Unsupported Media Type
|
||||
/// (<c>output_log.txt:'Failed to consume gift … HTTP Error 415'</c>),
|
||||
/// which left the gift unconsumed → the avatar item never landed
|
||||
/// in the player's inventory → mirror-unequip rendered a missing
|
||||
/// item and crashed.
|
||||
///
|
||||
/// Accept both JSON and form-urlencoded by reading the raw body
|
||||
/// and pulling <c>Id</c> from whichever container we got.</summary>
|
||||
[HttpPost("api/avatar/v2/gifts/consume")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data", "text/plain")]
|
||||
public async Task<IActionResult> ConsumeViaBody()
|
||||
{
|
||||
long id = 0;
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
var raw = form["Id"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(raw)) raw = form["id"].ToString();
|
||||
long.TryParse(raw, out id);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var reader = new System.IO.StreamReader(Request.Body);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
if (!string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(body);
|
||||
if (doc.RootElement.TryGetProperty("Id", out var v) ||
|
||||
doc.RootElement.TryGetProperty("id", out v))
|
||||
{
|
||||
if (v.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
v.TryGetInt64(out id);
|
||||
else if (v.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
long.TryParse(v.GetString(), out id);
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException) { /* fall through; id stays 0 */ }
|
||||
}
|
||||
}
|
||||
if (id == 0)
|
||||
{
|
||||
var raw = Request.Query["Id"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(raw)) raw = Request.Query["id"].ToString();
|
||||
long.TryParse(raw, out id);
|
||||
}
|
||||
if (id == 0) return BadRequest("missing Id");
|
||||
return await ConsumeImpl(id);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ConsumeImpl(long id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var gift = await db.GiftPackages.FirstOrDefaultAsync(g => g.Id == id);
|
||||
if (gift is null) return NotFound();
|
||||
if (gift.RecipientPlayerId != pid) return Forbid();
|
||||
if (gift.Consumed) return Ok(ToWire(gift)); // idempotent
|
||||
|
||||
// Append avatar item to player's inventory list (JSON list of
|
||||
// GUID strings). Defensive parse — corrupted JSON resets to
|
||||
// empty list rather than 500.
|
||||
if (!string.IsNullOrEmpty(gift.AvatarItemDescOrHairDyeDesc))
|
||||
{
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
if (avatar is null)
|
||||
{
|
||||
avatar = new AvatarEntity { PlayerId = pid };
|
||||
db.Avatars.Add(avatar);
|
||||
}
|
||||
try
|
||||
{
|
||||
var list = System.Text.Json.JsonSerializer
|
||||
.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
|
||||
if (!list.Contains(gift.AvatarItemDescOrHairDyeDesc))
|
||||
{
|
||||
list.Add(gift.AvatarItemDescOrHairDyeDesc);
|
||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
new[] { gift.AvatarItemDescOrHairDyeDesc });
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply currency / XP / level rewards bundled with the gift.
|
||||
// The previous implementation only handled the avatar item path,
|
||||
// so token gifts from the admin SPA marked themselves consumed
|
||||
// but never moved the player's wallet — the player tapped the
|
||||
// gift, the box played its animation, and the balance stayed
|
||||
// unchanged. CurrencyType is the canonical RecNet enum
|
||||
// (1 = Tokens, 2 = Coins; 0 means "no currency in this gift").
|
||||
if (gift.CurrencyType > 0 && gift.Currency > 0)
|
||||
{
|
||||
await level.GrantCurrencyAsync(pid, gift.CurrencyType, gift.Currency,
|
||||
$"gift_consumed:{gift.Id}");
|
||||
}
|
||||
if (gift.Xp > 0)
|
||||
{
|
||||
await level.AwardXpAsync(pid, gift.Xp, $"gift_consumed:{gift.Id}");
|
||||
}
|
||||
// Direct level grants bypass XP curves — same pattern
|
||||
// /api/admin/v1/players/{id}/levelup uses. We don't surface
|
||||
// that as a separate helper; bump the column inline.
|
||||
if (gift.Level > 0)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
|
||||
if (player is not null) player.Level += gift.Level;
|
||||
}
|
||||
|
||||
gift.Consumed = true;
|
||||
gift.ConsumedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Profile-update push so the watch's wallet readout refreshes
|
||||
// without waiting for the next /balance poll. Mirrors what
|
||||
// /api/admin/v1/players/{id}/gift does after the inbox push so
|
||||
// the toast number animates up the moment the box closes.
|
||||
if (gift.CurrencyType > 0 || gift.Xp > 0 || gift.Level > 0)
|
||||
{
|
||||
await notifications.NotifyAsync(pid,
|
||||
PushNotificationId.SubscriptionUpdateProfile,
|
||||
new { Reason = "GiftConsumed", gift.CurrencyType, gift.Currency, gift.Xp, gift.Level });
|
||||
}
|
||||
|
||||
return Ok(ToWire(gift));
|
||||
}
|
||||
|
||||
/// <summary>Weighted rarity roll — Common 65, Uncommon 25,
|
||||
/// Rare 8, Epic 1.8, Legendary 0.2 percent.</summary>
|
||||
private static int RollRarity(Random rng) => rng.NextDouble() switch
|
||||
{
|
||||
< 0.65 => 0, // Common
|
||||
< 0.90 => 10, // Uncommon
|
||||
< 0.98 => 20, // Rare
|
||||
< 0.998 => 30, // Epic
|
||||
_ => 50, // Legendary
|
||||
};
|
||||
|
||||
// Wire shape verified against the 2020.12 watch's
|
||||
// NLEKGNENMCO+LOCNECLOHCA gift DTO deserializer (Cpp2IL ISIL
|
||||
// NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946). All keys read
|
||||
// via Util.GetKey<T> (mandatory): Id, Message, FromPlayerId,
|
||||
// AvatarItemType, AvatarItemDesc, ConsumableItemDesc,
|
||||
// EquipmentPrefabName, EquipmentModificationGuid, Platform,
|
||||
// PlatformsToSpawnOn, BalanceType, CurrencyType, Currency, Xp,
|
||||
// GiftRarity, GiftContext. Missing any one → KeyNotFoundException
|
||||
// surfaces as "Failed to download gifts: Malformed Response" from
|
||||
// BootSequence and BLOCKS LOGIN.
|
||||
private static object ToWire(GiftPackageEntity g) => new
|
||||
{
|
||||
g.Id,
|
||||
g.FromPlayerId,
|
||||
g.ConsumableItemDesc,
|
||||
g.AvatarItemType,
|
||||
AvatarItemDesc = g.AvatarItemDescOrHairDyeDesc,
|
||||
g.AvatarItemDescOrHairDyeDesc,
|
||||
g.EquipmentPrefabName,
|
||||
g.EquipmentModificationGuid,
|
||||
g.CurrencyType,
|
||||
g.Currency,
|
||||
g.Xp,
|
||||
g.Level,
|
||||
g.GiftContext,
|
||||
g.GiftRarity,
|
||||
g.Message,
|
||||
g.Platform,
|
||||
// PlatformsToSpawnOn — required by the watch's gift DTO
|
||||
// deserializer. This is a SINGLE int (4-byte field at offset
|
||||
// 0x6C in LOCNECLOHCA, separate enum from Platform itself).
|
||||
// The watch reads it with strict Util.GetKey<int>, so sending
|
||||
// an empty array crashes Convert.ChangeType([], typeof(int))
|
||||
// with "Object must implement IConvertible". -1 mirrors the
|
||||
// sibling Platform field's "every platform" sentinel — no
|
||||
// platform-gated gift restrictions on a private server.
|
||||
PlatformsToSpawnOn = -1,
|
||||
// BalanceType — required mandatory key on the gift DTO. The
|
||||
// enum identifies whether the gift's payload is a Currency
|
||||
// balance update (0) vs Inventory item (1) vs XP (2). For
|
||||
// currency-bearing gifts we'd send 0; the safe default for
|
||||
// our seed gifts (which are item-only) is 0 too since the
|
||||
// watch only reads it to dispatch the balance-update side
|
||||
// and we never carry one. Wrong type doesn't crash.
|
||||
BalanceType = 0,
|
||||
g.PackageMaterial,
|
||||
g.PackageVariant,
|
||||
g.Consumed,
|
||||
g.IsValid,
|
||||
g.ErrorMessage,
|
||||
g.SupportsCurrentPlatform,
|
||||
g.IsGifted,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Avatar.V2;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/avatar/v2 — older Avatar settings endpoint used by
|
||||
/// the 2020 client (the V3 endpoint is for newer builds and uses a
|
||||
/// different shape entirely).
|
||||
///
|
||||
/// Wire shape verified by disassembling RecNet.Avatar.Deserialize at
|
||||
/// RVA 0xFA3C30. Required keys (Util.GetKey<string>): OutfitSelections,
|
||||
/// HairColor, SkinColor, FaceFeatures. Empty strings satisfy the
|
||||
/// deserializer; the client interprets them as "use defaults" and falls
|
||||
/// back to its built-in starter avatar.
|
||||
///
|
||||
/// This implementation persists each set/get to <see cref="AvatarEntity"/>
|
||||
/// instead of stubbing — round-trip across sessions, multi-user-safe
|
||||
/// (one row per player keyed by PlayerId).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/avatar/v2")]
|
||||
public async Task<ActionResult<AvatarV2Dto>> GetAvatar()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid);
|
||||
return Ok(ToDto(a));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/avatar/v2/set — replace the player's avatar settings.
|
||||
///
|
||||
/// CRITICAL: do NOT use <c>[FromBody] AvatarV2Dto</c> here. The
|
||||
/// watch's <c>RecNet.Avatar.Serialize()</c> emits each of
|
||||
/// OutfitSelections / HairColor / SkinColor / FaceFeatures as
|
||||
/// dictionary values whose runtime types are determined by the
|
||||
/// IRecNetObject serializer it uses — FaceFeatures in particular
|
||||
/// sometimes ships as a nested JSON OBJECT instead of a JSON
|
||||
/// string, and the watch may also omit fields entirely when
|
||||
/// they're empty.
|
||||
///
|
||||
/// System.Text.Json's strict type binder responds to either of those
|
||||
/// with a 400 (model state invalid → ApiController auto-400) and
|
||||
/// the watch then retries every frame, accumulating "Failed to
|
||||
/// upload Rec Room player avatar changes: HTTP Error 400" errors
|
||||
/// (output_log.txt:852+, 1000+ repeats) until the player object
|
||||
/// gets destroyed.
|
||||
///
|
||||
/// Parse the body manually as JsonElement so we accept any shape
|
||||
/// the watch sends: string-or-object for FaceFeatures (re-encode
|
||||
/// the object as a string before persisting), missing fields (keep
|
||||
/// existing values), null values (treat as empty).
|
||||
/// </summary>
|
||||
[HttpPost("api/avatar/v2")]
|
||||
[HttpPost("api/avatar/v2/set")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "text/plain")]
|
||||
public async Task<ActionResult<AvatarV2Dto>> SetAvatar()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
|
||||
// Read the raw body so we control parsing — bypasses the
|
||||
// model binder's strict type matching that was causing 400s.
|
||||
string raw;
|
||||
using (var reader = new System.IO.StreamReader(Request.Body))
|
||||
raw = await reader.ReadToEndAsync();
|
||||
|
||||
string? outfitSelections = null;
|
||||
string? hairColor = null;
|
||||
string? skinColor = null;
|
||||
string? faceFeatures = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
outfitSelections = ReadStringField(root, "OutfitSelections");
|
||||
hairColor = ReadStringField(root, "HairColor");
|
||||
skinColor = ReadStringField(root, "SkinColor");
|
||||
faceFeatures = ReadStringField(root, "FaceFeatures");
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
// Fall through with all-null updates if the body isn't
|
||||
// JSON. Log so we can see what the watch actually sends.
|
||||
logger.LogWarning(
|
||||
"[avatar-set] could not parse body as JSON ({Msg}); raw={Raw}",
|
||||
ex.Message, raw.Length > 500 ? raw.Substring(0, 500) + "…" : raw);
|
||||
}
|
||||
}
|
||||
|
||||
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid);
|
||||
if (a is null)
|
||||
{
|
||||
a = new AvatarEntity { PlayerId = pid };
|
||||
db.Avatars.Add(a);
|
||||
}
|
||||
// Only overwrite fields the watch actually sent; missing keys
|
||||
// keep the previously-stored value so a partial PATCH-style
|
||||
// update doesn't accidentally wipe HairColor/SkinColor/etc.
|
||||
if (outfitSelections is not null) a.OutfitSelections = outfitSelections;
|
||||
if (hairColor is not null) a.HairColor = hairColor;
|
||||
if (skinColor is not null) a.SkinColor = skinColor;
|
||||
if (faceFeatures is not null) a.FaceFeatures = faceFeatures;
|
||||
a.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToDto(a));
|
||||
}
|
||||
|
||||
/// <summary>Read a field that should be a string but may arrive
|
||||
/// as <c>null</c>, a JSON object/array (re-encoded back to a JSON
|
||||
/// string), or be missing entirely (return null → caller keeps
|
||||
/// the existing DB value).</summary>
|
||||
private static string? ReadStringField(JsonElement root, string name)
|
||||
{
|
||||
if (!root.TryGetProperty(name, out var v)) return null;
|
||||
return v.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => v.GetString() ?? string.Empty,
|
||||
JsonValueKind.Null => string.Empty,
|
||||
JsonValueKind.Object => v.GetRawText(),
|
||||
JsonValueKind.Array => v.GetRawText(),
|
||||
JsonValueKind.Number => v.GetRawText(),
|
||||
JsonValueKind.True or JsonValueKind.False => v.GetRawText(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Public lookup of another player's equipped avatar — the
|
||||
/// client uses this to render remote players' avatars in shared
|
||||
/// rooms. No auth requirement on read so anonymous calls in the
|
||||
/// boot path don't 401.</summary>
|
||||
[HttpGet("api/avatar/v2/equipped/{playerId:long}")]
|
||||
[HttpGet("api/avatar/v3/equipped/{playerId:long}")]
|
||||
[HttpGet("api/avatar/v4/equipped/{playerId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AvatarV2Dto>> GetEquippedFor(long playerId)
|
||||
{
|
||||
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == playerId);
|
||||
return Ok(ToDto(a));
|
||||
}
|
||||
|
||||
/// <summary>Bare <c>api/avatar/v{2,3}/equipped</c> returns the
|
||||
/// caller's own equipped avatar. Without the bare path the watch's
|
||||
/// "what am I wearing" query falls through to the catch-all and
|
||||
/// gets <c>[]</c>.</summary>
|
||||
[HttpGet("api/avatar/v2/equipped")]
|
||||
[HttpGet("api/avatar/v3/equipped")]
|
||||
[HttpGet("api/avatar/v4/equipped")]
|
||||
public async Task<ActionResult<AvatarV2Dto>> GetEquippedSelf()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid);
|
||||
return Ok(ToDto(a));
|
||||
}
|
||||
|
||||
private static AvatarV2Dto ToDto(AvatarEntity? a) => new()
|
||||
{
|
||||
OutfitSelections = a?.OutfitSelections ?? string.Empty,
|
||||
FaceFeatures = a?.FaceFeatures ?? string.Empty,
|
||||
HairColor = a?.HairColor ?? string.Empty,
|
||||
SkinColor = a?.SkinColor ?? string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
public class AvatarV2Dto
|
||||
{
|
||||
[JsonPropertyName("OutfitSelections")]
|
||||
public string OutfitSelections { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("HairColor")]
|
||||
public string HairColor { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("SkinColor")]
|
||||
public string SkinColor { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("FaceFeatures")]
|
||||
public string FaceFeatures { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Avatar.V3;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Wire
|
||||
/// type <c>RecNet.SavedOutfit</c> (per agent ISIL extraction):
|
||||
/// <c>Slot(int), PreviewImageName(string), OutfitSelections(string),
|
||||
/// FaceFeatures(string), SkinColor(string), HairColor(string)</c>.
|
||||
///
|
||||
/// State persists in <see cref="AvatarEntity.SavedOutfitsJson"/> as
|
||||
/// a JSON list of <see cref="SavedOutfitDto"/> records keyed by
|
||||
/// Slot. Caps at 20 entries per player to keep the column under
|
||||
/// the SQLite text-blob comfort zone.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private const int MaxSlots = 20;
|
||||
|
||||
[HttpGet("api/avatar/v2/saved")]
|
||||
[HttpGet("api/avatar/v3/saved")]
|
||||
[HttpGet("api/avatar/v4/saved")]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
var list = ParseList(avatar?.SavedOutfitsJson);
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
/// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot.
|
||||
/// Body is the SavedOutfit shape directly.</summary>
|
||||
[HttpPost("api/avatar/v3/saved/set")]
|
||||
[HttpPost("api/avatar/v2/saved/set")]
|
||||
public async Task<IActionResult> Set([FromBody] SavedOutfitDto body)
|
||||
{
|
||||
if (body is null) return BadRequest("missing body");
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
if (avatar is null)
|
||||
{
|
||||
avatar = new AvatarEntity { PlayerId = pid };
|
||||
db.Avatars.Add(avatar);
|
||||
}
|
||||
var list = ParseList(avatar.SavedOutfitsJson);
|
||||
var idx = list.FindIndex(o => o.Slot == body.Slot);
|
||||
if (idx >= 0) list[idx] = body;
|
||||
else
|
||||
{
|
||||
if (list.Count >= MaxSlots) return BadRequest($"max {MaxSlots} saved outfits per player");
|
||||
list.Add(body);
|
||||
}
|
||||
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(body);
|
||||
}
|
||||
|
||||
[HttpDelete("api/avatar/v3/saved/{slot:int}")]
|
||||
[HttpDelete("api/avatar/v2/saved/{slot:int}")]
|
||||
public async Task<IActionResult> Delete(int slot)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
if (avatar is null) return NotFound();
|
||||
var list = ParseList(avatar.SavedOutfitsJson);
|
||||
var removed = list.RemoveAll(o => o.Slot == slot);
|
||||
if (removed == 0) return NotFound();
|
||||
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private static List<SavedOutfitDto> ParseList(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new();
|
||||
}
|
||||
catch { return new(); }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SavedOutfitDto
|
||||
{
|
||||
[JsonPropertyName("Slot")] public int Slot { get; set; }
|
||||
[JsonPropertyName("PreviewImageName")] public string PreviewImageName { get; set; } = string.Empty;
|
||||
[JsonPropertyName("OutfitSelections")] public string OutfitSelections { get; set; } = string.Empty;
|
||||
[JsonPropertyName("FaceFeatures")] public string FaceFeatures { get; set; } = string.Empty;
|
||||
[JsonPropertyName("SkinColor")] public string SkinColor { get; set; } = string.Empty;
|
||||
[JsonPropertyName("HairColor")] public string HairColor { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Avatar.V4;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/avatar/v{2,3,4}/items — list of avatar items the
|
||||
/// caller has UNLOCKED. The watch's
|
||||
/// <c>RecNet.Avatars+<DowloadUnlockedAvatarItems></c> coroutine
|
||||
/// (Cpp2IL_ISIL/.../Avatars_NestedType__DowloadUnlockedAvatarItems_d__21.txt:149
|
||||
/// hits <c>"{api/avatar/}v4/items"</c>) parses the result as
|
||||
/// <c>List<UnlockedAvatarItem></c>; the wardrobe UI then
|
||||
/// renders a "buy" button for any item NOT in this list and an
|
||||
/// "equip" affordance for items that are.
|
||||
///
|
||||
/// Wire shape per
|
||||
/// Cpp2IL_ISIL/.../Avatars_NestedType_UnlockedAvatarItem.txt:186-212:
|
||||
/// "AvatarItemType" (int — Outfit=0 / HairDye=1, NOT slot-based),
|
||||
/// "AvatarItemDesc" (string — the item GUID with three empty
|
||||
/// customizations appended, see below),
|
||||
/// "PlatformMask" (int), "FriendlyName" (string),
|
||||
/// "Tooltip" (string), "Rarity" (int).
|
||||
///
|
||||
/// IMPORTANT — <c>AvatarItemDesc</c> format: the client passes this
|
||||
/// string to <c>AvatarItem.FromRecNetString(string)</c>
|
||||
/// (Cpp2IL_ISIL/.../RecRoom/Avatar/Data/Runtime/AvatarItem.txt:81-257).
|
||||
/// The desc is <c>String.Split(',')</c> into <c>guid + customization[0..3]</c>
|
||||
/// — the inner overload then indexes <c>customization[0]</c>,
|
||||
/// <c>customization[1]</c>, <c>customization[2]</c> and throws
|
||||
/// <c>ArgumentException</c> at <c>ArraySegment.get_Current</c> if the
|
||||
/// segment is shorter than 3. Every desc we ship MUST therefore have
|
||||
/// at least 4 comma-separated parts, so we suffix bare GUIDs with
|
||||
/// <c>",,,"</c> (three empty customizations).
|
||||
///
|
||||
/// CATALOG SOURCE — <c>data/avatar_item_lookup.json</c>. Generated by
|
||||
/// the manual/CI-only BepInEx patch under
|
||||
/// <c>tools/AvatarCatalogDumpPatch</c>. That patch reads the watch's bundled
|
||||
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
|
||||
/// SerializedDictionary at boot and writes every (guid, AvatarItemData)
|
||||
/// pair to JSON. THIS is the authoritative safe list — any GUID NOT
|
||||
/// in this file throws <c>InvalidOperationException("Missing data
|
||||
/// for guid …")</c> inside the watch's wardrobe parser, which aborts
|
||||
/// the entire coroutine on the first bad item and leaves the drawer
|
||||
/// empty. Items extracted from <c>outfits_assets_*.bundle</c>
|
||||
/// MonoBehaviours (the older <c>data/wardrobe_items.json</c>) are
|
||||
/// MASK + SWATCH asset GUIDs, NOT AvatarItem GUIDs, so that file
|
||||
/// can't be used as a safelist.
|
||||
///
|
||||
/// PER-PLAYER ownership: the watch intersects each store tile's
|
||||
/// <c>AvatarItemDesc</c> against THIS list to flip the buy-button
|
||||
/// to "Already Owned." Returning the global wardrobe here therefore
|
||||
/// makes every store item appear owned. We instead intersect the
|
||||
/// caller's <see cref="AvatarEntity.InventoryJson"/> with the master
|
||||
/// catalog so the watch sees only items the player actually owns.
|
||||
/// Anonymous calls return [].
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : ControllerBase
|
||||
{
|
||||
/// <summary>Master catalog keyed by GUID. Loaded once on first
|
||||
/// request from <c>data/avatar_item_lookup.json</c>. Same
|
||||
/// lazy-load idiom as before but the catalog itself is now a
|
||||
/// dump of the watch's bundled
|
||||
/// <c>avatarItemDataLookup</c> instead of the asset-bundle
|
||||
/// MonoBehaviour scan in the previous wardrobe_items.json.</summary>
|
||||
private static readonly Lazy<CatalogData> _catalog = new(LoadCatalog);
|
||||
|
||||
[HttpGet("api/avatar/v2/items")]
|
||||
[HttpGet("api/avatar/v3/items")]
|
||||
[HttpGet("api/avatar/v4/items")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<UnlockedAvatarItemDto>>> Get()
|
||||
{
|
||||
// Keep this switch as a fast kill-switch, but default it on now that
|
||||
// the response is bounded below. The risky path is not the parser
|
||||
// itself; it is feeding the 2020 watch too many BrowsableAvatarItem /
|
||||
// DynamicAvatarItemImposter rows at once.
|
||||
if (!config.GetValue("Avatar:EnableUnlockedItems", true))
|
||||
return Ok(new List<UnlockedAvatarItemDto>());
|
||||
|
||||
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 30), 1, 120);
|
||||
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 4), 1, 20);
|
||||
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
|
||||
|
||||
var avatar = await db.Avatars
|
||||
.Where(a => a.PlayerId == me)
|
||||
.Select(a => new { a.InventoryJson, a.OutfitSelections })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var owned = ParseInventory(avatar?.InventoryJson);
|
||||
var ownedHairDyeSlugs = await db.PlayerInventory
|
||||
.Where(i => i.PlayerId == me && i.Quantity > 0)
|
||||
.Select(i => i.ItemSlug)
|
||||
.ToListAsync();
|
||||
var ownedHairDyes = ownedHairDyeSlugs
|
||||
.Where(slug => StoreService.TryGetHairDyePayload(slug, out _))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(new List<UnlockedAvatarItemDto>());
|
||||
|
||||
var catalog = _catalog.Value;
|
||||
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
|
||||
var ordered = owned
|
||||
.OrderByDescending(guid => equipped.Contains(guid))
|
||||
.ThenBy(guid => catalog.SlotByGuid.TryGetValue(guid, out var slot) ? slot : int.MaxValue)
|
||||
.ThenBy(guid => guid, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count);
|
||||
var perSlot = new Dictionary<int, int>();
|
||||
var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems));
|
||||
foreach (var guid in ordered)
|
||||
{
|
||||
if (result.Count >= maxOutfitItems) break;
|
||||
// Intersect against the safelist. Items not in the bundled
|
||||
// lookup would crash the watch's wardrobe parser, so they're
|
||||
// silently dropped here.
|
||||
if (!catalog.Items.TryGetValue(guid, out var item)) continue;
|
||||
|
||||
var slot = catalog.SlotByGuid.TryGetValue(guid, out var value) ? value : -1;
|
||||
perSlot.TryGetValue(slot, out var count);
|
||||
if (count >= maxPerSlot && !equipped.Contains(guid)) continue;
|
||||
|
||||
perSlot[slot] = count + 1;
|
||||
result.Add(item);
|
||||
if (result.Count >= maxItems) break;
|
||||
}
|
||||
|
||||
foreach (var slug in ownedHairDyes)
|
||||
{
|
||||
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 Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>The set of GUIDs the bundled
|
||||
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
|
||||
/// contains. Exposed so other controllers (StoreService seed,
|
||||
/// PlayerService starter inventory) can validate their seed
|
||||
/// data against the same safe list.</summary>
|
||||
public static IReadOnlySet<string> SafeGuids => _catalog.Value.SafeGuids;
|
||||
|
||||
/// <summary>Master catalog of all renderable items, keyed by
|
||||
/// GUID. Used by callers that need item metadata (slot type,
|
||||
/// name, hair-color flags) outside of the per-player ownership
|
||||
/// check.</summary>
|
||||
public static IReadOnlyDictionary<string, UnlockedAvatarItemDto> Catalog
|
||||
=> _catalog.Value.Items;
|
||||
|
||||
/// <summary>GET <c>api/avatar/v1/lockeditems</c> — metadata for
|
||||
/// specific avatar item descriptions the caller does not own. The
|
||||
/// store UI calls this with item descs from purchasable rows and
|
||||
/// expects the same item-detail shape used by unlocked items,
|
||||
/// minus ownership. We resolve against the safe runtime catalog so
|
||||
/// unknown GUIDs never reach the client parser.</summary>
|
||||
[HttpGet("api/avatar/v1/lockeditems")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> LockedItems()
|
||||
{
|
||||
var requested = RequestedAvatarItemGuids().ToList();
|
||||
if (requested.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var owned = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is long me)
|
||||
{
|
||||
var invJson = await db.Avatars
|
||||
.Where(a => a.PlayerId == me)
|
||||
.Select(a => a.InventoryJson)
|
||||
.FirstOrDefaultAsync();
|
||||
foreach (var guid in ParseInventory(invJson))
|
||||
owned.Add(guid);
|
||||
}
|
||||
|
||||
var catalog = _catalog.Value.Items;
|
||||
var rows = requested
|
||||
.Where(guid => !owned.Contains(guid))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(guid => catalog.TryGetValue(guid, out var item) ? item : null)
|
||||
.Where(item => item is not null)
|
||||
.Select(item => new
|
||||
{
|
||||
item!.AvatarItemType,
|
||||
item.AvatarItemDesc,
|
||||
item.FriendlyName,
|
||||
item.Tooltip,
|
||||
item.Rarity,
|
||||
})
|
||||
.ToArray();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
|
||||
/// Used by <see cref="DorkNet.Server.Services.PlayerService"/> to
|
||||
/// build a starter loadout (one per slot) and a starter inventory
|
||||
/// (a few per slot).</summary>
|
||||
public static IReadOnlyDictionary<int, List<string>> BySlot
|
||||
=> _catalog.Value.BySlot;
|
||||
|
||||
public static bool TryGetOutfitType(string guid, out int outfitType) =>
|
||||
_catalog.Value.SlotByGuid.TryGetValue(guid, out outfitType);
|
||||
|
||||
private IEnumerable<string> RequestedAvatarItemGuids()
|
||||
{
|
||||
foreach (var pair in Request.Query)
|
||||
foreach (var raw in pair.Value)
|
||||
foreach (var part in (raw ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var value = part.Trim();
|
||||
if (string.IsNullOrWhiteSpace(value)) continue;
|
||||
var comma = value.IndexOf(',');
|
||||
if (comma >= 0) value = value[..comma];
|
||||
if (Guid.TryParse(value, out _)) yield return value;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ParseInventory(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||
try
|
||||
{
|
||||
var strings = JsonSerializer.Deserialize<List<string>>(json);
|
||||
if (strings is not null) return strings;
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
|
||||
|
||||
var result = new List<string>();
|
||||
foreach (var element in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
string? value = null;
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
value = element.GetString();
|
||||
else if (element.ValueKind == JsonValueKind.Object &&
|
||||
element.TryGetProperty("itemId", out var itemId) &&
|
||||
itemId.ValueKind == JsonValueKind.String)
|
||||
value = itemId.GetString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value)) continue;
|
||||
const string wardrobePrefix = "wardrobe-";
|
||||
if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal))
|
||||
value = value[wardrobePrefix.Length..];
|
||||
var comma = value.IndexOf(',');
|
||||
if (comma >= 0)
|
||||
value = value[..comma];
|
||||
result.Add(value);
|
||||
}
|
||||
|
||||
return result
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
private static HashSet<string> ParseEquippedItemGuids(string? outfitSelections)
|
||||
{
|
||||
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrWhiteSpace(outfitSelections)) return result;
|
||||
|
||||
foreach (var selection in outfitSelections.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var comma = selection.IndexOf(',');
|
||||
var guid = comma >= 0 ? selection[..comma] : selection;
|
||||
if (!string.IsNullOrWhiteSpace(guid))
|
||||
result.Add(guid.Trim());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed record CatalogData(
|
||||
IReadOnlyDictionary<string, UnlockedAvatarItemDto> Items,
|
||||
IReadOnlySet<string> SafeGuids,
|
||||
IReadOnlyDictionary<int, List<string>> BySlot,
|
||||
IReadOnlyDictionary<string, int> SlotByGuid);
|
||||
|
||||
private static CatalogData LoadCatalog()
|
||||
{
|
||||
// Try both lowercase 'data/' (csproj None+Link target) and
|
||||
// uppercase 'Data/' (git-tracked source folder) so case-sensitivity
|
||||
// mismatches between Windows (where the dev wrote the path) and
|
||||
// Linux (where Coolify builds the container) can't lose the lookup
|
||||
// file. Without this fallback, a single missed file → empty
|
||||
// SafeGuids → StoreService.SeedAsync skips its stale-row cleanup
|
||||
// → 418 swatch-GUID rows ship live → every store tile renders as
|
||||
// Hat_Angler.
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "data", "avatar_item_lookup.json"),
|
||||
Path.Combine(AppContext.BaseDirectory, "Data", "avatar_item_lookup.json"),
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "data", "avatar_item_lookup.json"),
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "Data", "avatar_item_lookup.json"),
|
||||
};
|
||||
var path = candidates.FirstOrDefault(System.IO.File.Exists);
|
||||
Console.WriteLine(path is null
|
||||
? $"[avatar-catalog] avatar_item_lookup.json not found in any of: {string.Join(", ", candidates)}"
|
||||
: $"[avatar-catalog] loading from {path}");
|
||||
if (path is null) return new CatalogData(
|
||||
new Dictionary<string, UnlockedAvatarItemDto>(),
|
||||
new HashSet<string>(),
|
||||
new Dictionary<int, List<string>>(),
|
||||
new Dictionary<string, int>());
|
||||
|
||||
using var fs = System.IO.File.OpenRead(path);
|
||||
var dump = JsonSerializer.Deserialize<LookupFile>(fs,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? new LookupFile();
|
||||
|
||||
var items = new Dictionary<string, UnlockedAvatarItemDto>(dump.Items.Count, StringComparer.OrdinalIgnoreCase);
|
||||
var safe = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var bySlot = new Dictionary<int, List<string>>();
|
||||
var slotByGuid = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var row in dump.Items)
|
||||
{
|
||||
if (string.IsNullOrEmpty(row.Guid)) continue;
|
||||
safe.Add(row.Guid);
|
||||
// Prefer the baked display_name from the JSON if present
|
||||
// (tools/add-display-names.py writes it once per refresh).
|
||||
// Falls back to the raw-name cleanup if the field is missing
|
||||
// — e.g. partial dumps or older fixture files.
|
||||
var pretty = !string.IsNullOrEmpty(row.DisplayName)
|
||||
? row.DisplayName
|
||||
: PrettyName(row.Name);
|
||||
items[row.Guid] = new UnlockedAvatarItemDto
|
||||
{
|
||||
// AvatarItemType is the wire enum the watch parses
|
||||
// (Outfit=0 / HairDye=1), not the slot. Rows in
|
||||
// avatarItemDataLookup are wearable avatar items, including
|
||||
// hair meshes that *use* hair color. They must still be sent
|
||||
// as Outfit; sending HairDye makes the client treat the item
|
||||
// GUID as a color GUID and log "Can not find hair dye color".
|
||||
AvatarItemType = AvatarItemType.Outfit,
|
||||
AvatarItemDesc = row.Guid + ",,,",
|
||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||
FriendlyName = pretty,
|
||||
Tooltip = string.Empty,
|
||||
Rarity = 0,
|
||||
};
|
||||
if (!bySlot.TryGetValue(row.OutfitType, out var lst))
|
||||
bySlot[row.OutfitType] = lst = new List<string>();
|
||||
lst.Add(row.Guid);
|
||||
slotByGuid[row.Guid] = row.OutfitType;
|
||||
}
|
||||
|
||||
return new CatalogData(items, safe, bySlot, slotByGuid);
|
||||
}
|
||||
|
||||
private static string PrettyName(string raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw)) return string.Empty;
|
||||
// Strip leading/trailing parens that the bundled config wraps
|
||||
// names in, then turn underscores into spaces. Keep it simple —
|
||||
// the watch shows whatever string we send.
|
||||
var trimmed = raw.Trim().TrimStart('(').TrimEnd(')');
|
||||
// Drop common asset-name suffixes. Items in the runtime config
|
||||
// use the raw asset name (e.g. "Hat_Wizard", "shirt_sherlock");
|
||||
// the watch never normalises these, so we do it server-side.
|
||||
var s = trimmed.Replace('_', ' ').Trim();
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>Shape of <c>data/avatar_item_lookup.json</c> as written
|
||||
/// by <c>tools/AvatarCatalogDumpPatch</c>.</summary>
|
||||
private sealed class LookupFile
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public List<LookupItem> Items { get; set; } = new();
|
||||
|
||||
// Auxiliary GUID lists (prefab/mask/swatch/decal). Not used
|
||||
// server-side yet — they'd matter for resolving asset
|
||||
// references the watch might subsequently request, but the
|
||||
// wire protocol only ever ships the AvatarItem GUID.
|
||||
[JsonPropertyName("prefab_guids")]
|
||||
public List<string> PrefabGuids { get; set; } = new();
|
||||
[JsonPropertyName("mask_guids")]
|
||||
public List<string> MaskGuids { get; set; } = new();
|
||||
[JsonPropertyName("swatch_guids")]
|
||||
public List<string> SwatchGuids { get; set; } = new();
|
||||
[JsonPropertyName("decal_guids")]
|
||||
public List<string> DecalGuids { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class LookupItem
|
||||
{
|
||||
[JsonPropertyName("guid")] public string Guid { get; set; } = string.Empty;
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
|
||||
// Algorithmically derived from `name` by
|
||||
// tools/add-display-names.py — Rec Room's official UI strings
|
||||
// live in Unity asset bundles we can't read from a code dump,
|
||||
// so this is a best-effort cleanup. Optional — server falls
|
||||
// back to PrettyName() if missing.
|
||||
[JsonPropertyName("display_name")] public string? DisplayName { get; set; }
|
||||
[JsonPropertyName("outfit_type")] public int OutfitType { get; set; }
|
||||
[JsonPropertyName("outfit_set")] public int OutfitSet { get; set; }
|
||||
[JsonPropertyName("drawer_index")] public int DrawerIndex { get; set; }
|
||||
[JsonPropertyName("hides_hair")] public bool HidesHair { get; set; }
|
||||
[JsonPropertyName("hides_hand")] public bool HidesHand { get; set; }
|
||||
// hides_left_ear / hides_right_ear were added to AvatarItemData
|
||||
// in the 2020.12.18 client build. Optional in the JSON so older
|
||||
// dumps still deserialize.
|
||||
[JsonPropertyName("hides_left_ear")] public bool HidesLeftEar { get; set; }
|
||||
[JsonPropertyName("hides_right_ear")] public bool HidesRightEar { get; set; }
|
||||
[JsonPropertyName("uses_hair_color")] public bool UsesHairColor { get; set; }
|
||||
[JsonPropertyName("uses_team_color")] public bool UsesTeamColor { get; set; }
|
||||
[JsonPropertyName("is_team_item")] public bool IsTeamItem { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Mirrors <c>RecNet.Avatars+AvatarItemType</c>
|
||||
/// (Avatars.cs:186-190).</summary>
|
||||
private static class AvatarItemType
|
||||
{
|
||||
public const int Outfit = 0;
|
||||
public const int HairDye = 1;
|
||||
}
|
||||
|
||||
/// <summary>OutfitType slot enum mirrored from
|
||||
/// <c>RecRoom.Avatar.Data.Shared.OutfitType</c>. Used by other
|
||||
/// callers (PlayerService, StoreService) for slot-aware seeding.</summary>
|
||||
public static class Slot
|
||||
{
|
||||
public const int Hat = 0;
|
||||
public const int BackHead = 1;
|
||||
public const int Hair = 2;
|
||||
public const int Eye = 10;
|
||||
public const int Mouth = 20;
|
||||
public const int Neck = 100;
|
||||
public const int Shirt = 101;
|
||||
public const int Belt = 102;
|
||||
public const int Pocket = 103;
|
||||
public const int TeamShirt = 104;
|
||||
public const int Wrist = 200;
|
||||
public const int Glove = 201;
|
||||
public const int Watch = 202;
|
||||
public const int TeamWrist = 203;
|
||||
}
|
||||
}
|
||||
|
||||
public class UnlockedAvatarItemDto
|
||||
{
|
||||
[JsonPropertyName("AvatarItemType")]
|
||||
public int AvatarItemType { get; set; }
|
||||
|
||||
[JsonPropertyName("AvatarItemDesc")]
|
||||
public string AvatarItemDesc { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("PlatformMask")]
|
||||
public int PlatformMask { get; set; }
|
||||
|
||||
[JsonPropertyName("FriendlyName")]
|
||||
public string FriendlyName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Tooltip")]
|
||||
public string Tooltip { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Rarity")]
|
||||
public int Rarity { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user