feat: Update PlayerCheer and Avatar handling; enhance API responses and add new fields for 2023 client compatibility

This commit is contained in:
Alexa
2026-07-12 23:54:11 +03:00
parent 3d7d7ebdf1
commit 757308b44d
8 changed files with 415 additions and 185 deletions
@@ -78,6 +78,8 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
string? hairColor = null;
string? skinColor = null;
string? faceFeatures = null;
string? outfitSelectionsV2 = null;
string? customAvatarItems = null;
if (!string.IsNullOrWhiteSpace(raw))
{
@@ -91,6 +93,11 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
hairColor = ReadStringField(root, "HairColor");
skinColor = ReadStringField(root, "SkinColor");
faceFeatures = ReadStringField(root, "FaceFeatures");
// 2023 client (HHDLNAPEMGP writer EKHJKDNHOPB) also
// uploads OutfitSelectionsV2 + CustomAvatarItems —
// persist them so remote-player fetches round-trip.
outfitSelectionsV2 = ReadStringField(root, "OutfitSelectionsV2");
customAvatarItems = ReadStringField(root, "CustomAvatarItems");
}
}
catch (JsonException ex)
@@ -116,6 +123,16 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
if (hairColor is not null) a.HairColor = hairColor;
if (skinColor is not null) a.SkinColor = skinColor;
if (faceFeatures is not null) a.FaceFeatures = faceFeatures;
if (outfitSelectionsV2 is not null) a.OutfitSelectionsV2 = outfitSelectionsV2;
if (customAvatarItems is not null)
{
// CustomAvatarItems arrives as a JSON ARRAY of
// {"CustomAvatarItemId":guid,"BodyPart":byte}; ReadStringField
// re-encodes arrays as raw JSON. Guard against non-array junk.
a.CustomAvatarItemsJson = customAvatarItems.TrimStart().StartsWith('[')
? customAvatarItems
: "[]";
}
a.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToDto(a));
@@ -140,6 +157,25 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
};
}
/// <summary>GET <c>api/avatar/v2/{accountId}</c> — the 2023 client's
/// remote-player avatar settings fetch. Decompile evidence
/// (2023.03.21 ISIL, RecNet.Runtime FFFIMAGLKEG.txt:4845-5006,
/// method KAFJNMDBKDB): URL is String.Format("{0}v2/{1}",
/// "api/avatar/", accountId) — the account id is a bare path segment
/// directly under v2 (NOT under /equipped/). The response is parsed
/// as object HHDLNAPEMGP whose reader (EKHJKDNHOPB.txt:471-766)
/// registers OutfitSelections / OutfitSelectionsV2 / FaceFeatures /
/// SkinColor / HairColor / CustomAvatarItems. Without this route the
/// request 404'd with an empty body → "Failed to load Rec Room
/// remote player avatar settings" (Player.log:2597).</summary>
[HttpGet("api/avatar/v2/{playerId:long}")]
[AllowAnonymous]
public async Task<ActionResult<AvatarV2Dto>> GetSettingsFor(long playerId)
{
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == playerId);
return Ok(ToDto(a));
}
/// <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
@@ -174,7 +210,27 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
FaceFeatures = a?.FaceFeatures ?? string.Empty,
HairColor = a?.HairColor ?? string.Empty,
SkinColor = a?.SkinColor ?? string.Empty,
OutfitSelectionsV2 = a?.OutfitSelectionsV2 ?? string.Empty,
CustomAvatarItems = ParseCustomAvatarItems(a?.CustomAvatarItemsJson),
};
/// <summary>Re-hydrate the stored CustomAvatarItems JSON into typed
/// wire items so the response always carries a well-formed array
/// (never a raw string). Bad/legacy blobs degrade to [].</summary>
private static List<CustomAvatarItemRefDto> ParseCustomAvatarItems(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try
{
return JsonSerializer.Deserialize<List<CustomAvatarItemRefDto>>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
}
catch (JsonException)
{
return new();
}
}
}
public class AvatarV2Dto
@@ -190,4 +246,28 @@ public class AvatarV2Dto
[JsonPropertyName("FaceFeatures")]
public string FaceFeatures { get; set; } = string.Empty;
/// <summary>2023 client field — HHDLNAPEMGP reader key
/// "OutfitSelectionsV2" (EKHJKDNHOPB.txt:498-514). Extra keys are
/// skipped by the 2020 reader, so this is safe cross-version.</summary>
[JsonPropertyName("OutfitSelectionsV2")]
public string OutfitSelectionsV2 { get; set; } = string.Empty;
/// <summary>2023 client field — HHDLNAPEMGP reader key
/// "CustomAvatarItems" (EKHJKDNHOPB.txt:594-610), a list of
/// BGNNOMBFMLH items.</summary>
[JsonPropertyName("CustomAvatarItems")]
public List<CustomAvatarItemRefDto> CustomAvatarItems { get; set; } = new();
}
/// <summary>BGNNOMBFMLH — { Guid CustomAvatarItemId, Byte BodyPart }
/// (property types BGNNOMBFMLH.txt:3-39; JSON keys registered by reader
/// MKAIABAJIAJ.txt:215-258).</summary>
public class CustomAvatarItemRefDto
{
[JsonPropertyName("CustomAvatarItemId")]
public Guid CustomAvatarItemId { get; set; }
[JsonPropertyName("BodyPart")]
public byte BodyPart { get; set; }
}
@@ -10,28 +10,50 @@ namespace DorkNet.Server.Controllers.API.InfluencerPartnerProgram;
[ApiController]
public class InfluencerPartnerProgramController(DorkNetDbContext db) : ControllerBase
{
// The 2023 client's influencer API (RecNet.Runtime IBEEFFGHGND) reads
// BARE scalars, NOT wrapped account objects:
// influencers → List<Int32> (account ids)
// influencer → Nullable<Int32> (the account id, or null)
// myinfluencer → Nullable<Int32> (supported influencer's id, or null)
// Returning an object here crashes the strict reader
// ("expected:'Number Token', actual:'{'"), which is what broke the
// profile view (and the cheer flow that opens it).
// 2023 client influencer API (RecNet.Runtime IBEEFFGHGND) — REAL wire
// shapes verified against the 2023.03.21 ISIL decompile:
//
// influencers → OBJECT {"InfluencerIds":[...],"ContinuationToken":null}
// - IBEEFFGHGND.txt:1612 — response callback is
// Func<FEAJJMACBMF, FGLDKEJLAKB<List<Int32>>>, i.e. the body is
// deserialized as DTO object FEAJJMACBMF, NOT a bare array
// (a bare array crashes the strict reader:
// "FGNDDNDAFCI: expected:'{', actual:'['").
// - FLEDNKHKIND.txt:215-265 — FEAJJMACBMF's generated reader
// registers keys "InfluencerIds" and "ContinuationToken"
// (Pascal/camel/lower probes, so camelCase is accepted).
// - IBEEFFGHGND_NestedType___c.txt:318-358
// (<GetAllInfluencersInternal>b__12_0) — a non-null
// ContinuationToken triggers a recursive next-page fetch, so it
// must be null once the single page is exhausted.
// - IBEEFFGHGND.txt:1547-1575 (OELDHDEIFGP) — client sends
// take=1000 + continuationToken query params.
[HttpGet("api/influencerpartnerprogram/influencers")]
[AllowAnonymous]
public async Task<IActionResult> Influencers([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
take = Math.Clamp(take, 1, 1000);
var creatorIds = await db.Rooms.Select(r => r.CreatorPlayerId)
.Concat(db.Inventions.Where(i => !i.IsDeleted).Select(i => i.CreatorPlayerId))
.Concat(db.CustomAvatarItems.Select(i => i.CreatorPlayerId))
.Distinct()
.Take(take)
.ToListAsync();
return Ok(creatorIds.Select(id => (int)id).ToArray());
return Ok(new
{
InfluencerIds = creatorIds.Select(id => (int)id).ToArray(),
ContinuationToken = (string?)null,
});
}
// influencer / myinfluencer → bare Int32 scalar, "no influencer" = 0:
// - IBEEFFGHGND.txt:598/951 — both pipelines parse the body as Int32
// and convert via Func<Int32, Nullable<Int32>>.
// - IBEEFFGHGND_NestedType___c.txt:61-111
// (<GetMySupportedInfluencer>b__8_0) — the converter maps
// id == 0 → null, else (int?)id. So the correct "none" response is
// the literal number 0. A "null"/empty body throws
// CHGIJBGGJAG "Response was empty" (Player.log:1947-2031).
[HttpGet("api/influencerpartnerprogram/influencer")]
[AllowAnonymous]
public async Task<IActionResult> Influencer([FromQuery] long? accountId = null, [FromQuery] string? username = null)
@@ -39,7 +61,7 @@ public class InfluencerPartnerProgramController(DorkNetDbContext db) : Controlle
var playerId = accountId is long id
? await db.Players.Where(p => p.Id == id).Select(p => (long?)p.Id).FirstOrDefaultAsync()
: await db.Players.Where(p => p.Username == username).Select(p => (long?)p.Id).FirstOrDefaultAsync();
return Content(playerId is long pid ? ((int)pid).ToString() : "null", "application/json");
return Content(playerId is long pid ? ((int)pid).ToString() : "0", "application/json");
}
[HttpGet("api/influencerpartnerprogram/myinfluencer")]
@@ -52,7 +74,7 @@ public class InfluencerPartnerProgramController(DorkNetDbContext db) : Controlle
return Content(
setting is not null && long.TryParse(setting.Value, out var influencerId)
? ((int)influencerId).ToString()
: "null",
: "0",
"application/json");
}
@@ -1,134 +1,130 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using System.Security.Cryptography;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.ItemWishlists;
/// <summary>
/// api.rec.net/api/itemWishlists — consumable/gift-drop wishlist
/// (RecNet.Runtime BPAIBFBKKCN). Wire contract verified against the
/// 2023.03.21 ISIL decompile:
///
/// GET api/itemWishlists/v1/wishlist/{accountId|me}
/// - BPAIBFBKKCN.txt:700-763 (ELPBIBPOBJF) — URL is
/// String.Concat("api/itemWishlists/v1/wishlist/", idOrMe) where
/// idOrMe is "me" for the local account, else the numeric account
/// id as a PATH SEGMENT (not a query param). Missing this route
/// 404s with an empty body → CHGIJBGGJAG "Response was empty" →
/// "Failed to get wishlist for player" (Player.log:2832-2872).
/// - Return type FGLDKEJLAKB&lt;List&lt;BFJNGMGONED&gt;&gt; with no converter
/// Func → the body is a BARE ARRAY of BFJNGMGONED.
/// - BFJNGMGONED = { Guid WishlistItemId, Int32 AccountId,
/// Int32 PurchasableItemId, DateTime CreatedAt }
/// (property types BFJNGMGONED.txt:3-97; JSON keys registered by
/// reader MCDADMNCDEI.txt:343-434, Pascal/camel/lower probes).
///
/// PUT api/itemWishlists/v1/wishlist/me/{purchasableItemId}
/// - BPAIBFBKKCN.txt:161-215 (OOPPFFPOAFH) — format string
/// "{0}/v1/wishlist/me/{1}", verb field = 3 = PUT
/// (verb table HNLCIDLIIBO.txt:878-903). Response is parsed as the
/// AECMPGPHAII&lt;BFJNGMGONED&gt; envelope {"Value","Success","Error"}
/// (envelope reader keys LEEAJGDIOHI.txt:243-286).
///
/// DELETE api/itemWishlists/v1/wishlist/me/{purchasableItemId}
/// - BPAIBFBKKCN.txt:375-419 (IBLKDCAIODF) — same URL, verb 4 =
/// DELETE, fire-and-forget (LDGADANDBIO) — body ignored.
///
/// Storage: rows live in ItemWishlists with the purchasable item id
/// serialized into the existing ItemKey column (no schema change);
/// WishlistItemId is derived deterministically from the row id.
/// </summary>
[ApiController]
[Route("api/itemWishlists")]
[Authorize]
public class ItemWishlistsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet]
[HttpGet("v1/wishlist/me")]
public async Task<IActionResult> Mine()
[HttpGet("api/itemWishlists/v1/wishlist/me")]
public async Task<IActionResult> Mine() => await ForPlayer(Me);
[HttpGet("api/itemWishlists/v1/wishlist/{accountId:long}")]
public async Task<IActionResult> ForPlayer(long accountId)
{
var rows = await db.ItemWishlists
.Where(w => w.PlayerId == Me)
.Where(w => w.PlayerId == accountId)
.OrderByDescending(w => w.CreatedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
return Ok(rows.Select(ToWire).Where(w => w is not null).ToArray());
}
[HttpGet("v1/wishlist")]
public async Task<IActionResult> ForPlayer([FromQuery] long? playerId)
[HttpPut("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
[HttpPost("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
public async Task<IActionResult> Add(int purchasableItemId)
{
var pid = playerId is > 0 ? playerId.Value : Me;
var rows = await db.ItemWishlists
.Where(w => w.PlayerId == pid)
.OrderByDescending(w => w.CreatedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost]
[HttpPost("v1/wishlist")]
public async Task<IActionResult> Add()
{
var req = await ReadRequestAsync();
var key = (req.ItemKey ?? req.ItemId ?? string.Empty).Trim();
if (key.Length == 0) return BadRequest(new { error = "missing_item" });
if (key.Length > 128) key = key[..128];
var itemType = req.ItemType ?? 0;
var exists = await db.ItemWishlists.AnyAsync(w =>
w.PlayerId == Me && w.ItemKey == key && w.ItemType == itemType);
if (!exists)
var key = purchasableItemId.ToString();
var row = await db.ItemWishlists.FirstOrDefaultAsync(w =>
w.PlayerId == Me && w.ItemKey == key);
if (row is null)
{
db.ItemWishlists.Add(new ItemWishlistEntity
row = new ItemWishlistEntity
{
PlayerId = Me,
ItemKey = key,
ItemType = itemType,
});
ItemType = 0,
};
db.ItemWishlists.Add(row);
await db.SaveChangesAsync();
}
return await Mine();
// AECMPGPHAII<BFJNGMGONED> envelope: {"Value","Success","Error"}
// (LEEAJGDIOHI.txt:243-286).
return Ok(new
{
Value = ToWire(row),
Success = true,
Error = (string?)null,
});
}
[HttpDelete("v1/wishlist")]
[HttpPost("v1/wishlist/remove")]
public async Task<IActionResult> Remove()
[HttpDelete("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
public async Task<IActionResult> Remove(int purchasableItemId)
{
var req = await ReadRequestAsync();
var key = (req.ItemKey ?? req.ItemId ?? string.Empty).Trim();
if (key.Length == 0) return BadRequest(new { error = "missing_item" });
var itemType = req.ItemType ?? 0;
var key = purchasableItemId.ToString();
await db.ItemWishlists
.Where(w => w.PlayerId == Me && w.ItemKey == key && w.ItemType == itemType)
.Where(w => w.PlayerId == Me && w.ItemKey == key)
.ExecuteDeleteAsync();
return await Mine();
// Fire-and-forget on the client (LDGADANDBIO) — body ignored.
return Ok(new { Success = true });
}
private async Task<WishlistRequest> ReadRequestAsync()
/// <summary>BFJNGMGONED wire item. Rows whose ItemKey isn't a
/// purchasable-item integer (legacy free-form keys) are skipped.</summary>
private static object? ToWire(ItemWishlistEntity row)
{
if (Request.HasFormContentType)
if (!int.TryParse(row.ItemKey, out var purchasableItemId)) return null;
return new
{
var form = await Request.ReadFormAsync();
return new WishlistRequest
{
ItemKey = form["itemKey"].FirstOrDefault() ?? form["ItemKey"].FirstOrDefault(),
ItemId = form["itemId"].FirstOrDefault() ?? form["ItemId"].FirstOrDefault(),
ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var type) ? type : null,
};
}
var queryKey = Request.Query["itemKey"].FirstOrDefault() ?? Request.Query["ItemKey"].FirstOrDefault();
var queryId = Request.Query["itemId"].FirstOrDefault() ?? Request.Query["ItemId"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(queryKey) || !string.IsNullOrWhiteSpace(queryId))
{
return new WishlistRequest
{
ItemKey = queryKey,
ItemId = queryId,
ItemType = int.TryParse(Request.Query["itemType"].FirstOrDefault() ?? Request.Query["ItemType"].FirstOrDefault(), out var type) ? type : null,
};
}
try
{
return await JsonSerializer.DeserializeAsync<WishlistRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new WishlistRequest();
}
catch (JsonException)
{
return new WishlistRequest();
}
WishlistItemId = StableGuid(row.Id, row.PlayerId),
AccountId = (int)row.PlayerId,
PurchasableItemId = purchasableItemId,
row.CreatedAt,
};
}
private static object ToWire(ItemWishlistEntity row) => new
/// <summary>Deterministic per-row Guid — the client only uses
/// WishlistItemId as an opaque identity, but it must stay stable
/// across requests.</summary>
private static Guid StableGuid(long rowId, long playerId)
{
row.Id,
row.PlayerId,
row.ItemKey,
ItemId = row.ItemKey,
row.ItemType,
row.CreatedAt,
};
public sealed class WishlistRequest
{
public string? ItemKey { get; set; }
public string? ItemId { get; set; }
public int? ItemType { get; set; }
Span<byte> input = stackalloc byte[16];
BitConverter.TryWriteBytes(input, rowId);
BitConverter.TryWriteBytes(input[8..], playerId);
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(input, hash);
return new Guid(hash[..16]);
}
}
@@ -25,44 +25,78 @@ public class PlayerCheerController(
{
private long Me => this.RequireCurrentPlayerId();
/// <summary>POST <c>api/PlayerCheer/v1/create</c> — caller cheers
/// the target player. <c>Type</c> matches the same enum as
/// <c>CheerEntity.Type</c>. Idempotent on (caller, target, type)
/// to match <c>PlayerStateController.CheerPlayer</c>, so the same
/// category click doesn't inflate the count.</summary>
/// <summary>POST <c>api/PlayerCheer/v1/create</c> — caller cheers the
/// target player. Request fields (verified from RecNet.Runtime
/// KFJKDMGHKHE.EMKBFCLIOGN, KFJKDMGHKHE.txt:696-743, all posted via
/// BestHTTP AddField → form): <c>PlayerIdTo</c> (target account id),
/// <c>CheerCategory</c> (the cheer enum), <c>RoomId</c> (optional room
/// context), <c>Anonymous</c> (bool). The OLD binding read
/// <c>TargetAccountId</c>/<c>Type</c>, which never matched — the target
/// bound to null, the handler reported failure, and the watch surfaced
/// "Failed to cheer player — Something went wrong". The client parses the
/// response into PHMHCPEMABG { bool, string } and gates on the bool, so
/// the body must report success. Idempotent on (caller, target, room,
/// category).</summary>
[HttpPost("api/PlayerCheer/v1/create")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> Create(
[FromForm(Name = "TargetAccountId")] long? targetAccountId,
[FromForm(Name = "Type")] int type = 0)
[FromForm(Name = "PlayerIdTo")] long? playerIdTo,
[FromForm(Name = "CheerCategory")] int cheerCategory = 0,
[FromForm(Name = "RoomId")] long? roomId = null,
[FromForm(Name = "Anonymous")] bool anonymous = false)
{
if (targetAccountId is not long target || target <= 0)
return Ok(new { success = false, error = "missing_target" });
if (playerIdTo is not long target || target <= 0)
return CheerResult(false, "Invalid cheer target.");
if (Me == target)
return Ok(new { success = false, error = "cannot_cheer_self" });
return CheerResult(false, "You can't cheer yourself.");
var targetRoomId = roomId ?? 0;
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == Me && c.TargetPlayerId == target &&
c.TargetRoomId == 0 && c.Type == type);
if (existing is not null) return Ok(new { success = true, already_cheered = true });
c.TargetRoomId == targetRoomId && c.Type == cheerCategory);
if (existing is not null)
// Already cheered — still a success from the client's POV (the
// string carries the client's own "very recently" phrasing).
return CheerResult(true, "You have already cheered this player very recently.");
db.Cheers.Add(new CheerEntity
{
FromPlayerId = Me,
TargetPlayerId = target,
Type = type,
TargetRoomId = targetRoomId,
Type = cheerCategory,
});
await db.SaveChangesAsync();
await level.AwardXpAsync(target, LevelService.CheerReceivedXp, $"cheer_from:{Me}");
// Real notification: a PlayerCheer message the client renders as
// "X cheered you" (resolved from FromPlayerId), persisted to the
// recipient's notification feed. Replaces the old blank-account
// SubscriptionUpdateProfile misuse.
// Real notification: a PlayerCheer message the recipient renders as
// "X cheered you". Anonymous cheers use the anonymous message type and
// don't reveal the cheerer.
await systemNotifications.SendAsync(target,
SystemNotificationService.MessageType.PlayerCheer, fromPlayerId: Me);
return Ok(new { success = true, error = "" });
anonymous
? SystemNotificationService.MessageType.PlayerCheerAnonymous
: SystemNotificationService.MessageType.PlayerCheer,
fromPlayerId: anonymous ? 0 : Me);
return CheerResult(true, "");
}
/// <summary>The create response deserializes into PHMHCPEMABG
/// { bool GJLFIFEJDEH, string ABGLLJPIMIO } (KFJKDMGHKHE.txt maps the raw
/// body via Func&lt;PHMHCPEMABG, LDGADANDBIO&gt;). The JSON keys are
/// obfuscated with no literals in the dump, so emit the success bool and
/// message under the common key spellings via a Dictionary (literal keys
/// bypass the camelCase policy; LitJson ignores the ones it doesn't
/// read).</summary>
private IActionResult CheerResult(bool success, string message) =>
Ok(new Dictionary<string, object?>
{
["Success"] = success,
["success"] = success,
["Message"] = message,
["message"] = message,
["Error"] = success ? string.Empty : message,
["error"] = success ? string.Empty : message,
});
/// <summary>POST <c>api/PlayerCheer/v1/SetSelectedCheer</c> — pin
/// a cheer category as the caller's selected badge. Stored on the
/// PlayerSetting key <c>SelectedCheer</c> (string-form int).