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
@@ -29,11 +29,16 @@ public sealed class SystemNotificationTests : IClassFixture<DorkNetServerFactory
var target = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); var target = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(cheerer); using var client = ApiClient(cheerer);
// Real client field names (RecNet.Runtime KFJKDMGHKHE.EMKBFCLIOGN):
// PlayerIdTo + CheerCategory (+ RoomId, Anonymous). The old test used
// TargetAccountId/Type, which matched the buggy handler binding but
// NOT the game client — so it passed while cheers were broken live.
using var resp = await client.PostAsync("/api/PlayerCheer/v1/create", using var resp = await client.PostAsync("/api/PlayerCheer/v1/create",
new FormUrlEncodedContent(new Dictionary<string, string> new FormUrlEncodedContent(new Dictionary<string, string>
{ {
["TargetAccountId"] = target.PlayerId.ToString(), ["PlayerIdTo"] = target.PlayerId.ToString(),
["Type"] = "0", ["CheerCategory"] = "0",
["Anonymous"] = "false",
})); }));
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
@@ -78,6 +78,8 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
string? hairColor = null; string? hairColor = null;
string? skinColor = null; string? skinColor = null;
string? faceFeatures = null; string? faceFeatures = null;
string? outfitSelectionsV2 = null;
string? customAvatarItems = null;
if (!string.IsNullOrWhiteSpace(raw)) if (!string.IsNullOrWhiteSpace(raw))
{ {
@@ -91,6 +93,11 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
hairColor = ReadStringField(root, "HairColor"); hairColor = ReadStringField(root, "HairColor");
skinColor = ReadStringField(root, "SkinColor"); skinColor = ReadStringField(root, "SkinColor");
faceFeatures = ReadStringField(root, "FaceFeatures"); 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) catch (JsonException ex)
@@ -116,6 +123,16 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
if (hairColor is not null) a.HairColor = hairColor; if (hairColor is not null) a.HairColor = hairColor;
if (skinColor is not null) a.SkinColor = skinColor; if (skinColor is not null) a.SkinColor = skinColor;
if (faceFeatures is not null) a.FaceFeatures = faceFeatures; 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; a.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(ToDto(a)); 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 /// <summary>Public lookup of another player's equipped avatar — the
/// client uses this to render remote players' avatars in shared /// client uses this to render remote players' avatars in shared
/// rooms. No auth requirement on read so anonymous calls in the /// 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, FaceFeatures = a?.FaceFeatures ?? string.Empty,
HairColor = a?.HairColor ?? string.Empty, HairColor = a?.HairColor ?? string.Empty,
SkinColor = a?.SkinColor ?? 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 public class AvatarV2Dto
@@ -190,4 +246,28 @@ public class AvatarV2Dto
[JsonPropertyName("FaceFeatures")] [JsonPropertyName("FaceFeatures")]
public string FaceFeatures { get; set; } = string.Empty; 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] [ApiController]
public class InfluencerPartnerProgramController(DorkNetDbContext db) : ControllerBase public class InfluencerPartnerProgramController(DorkNetDbContext db) : ControllerBase
{ {
// The 2023 client's influencer API (RecNet.Runtime IBEEFFGHGND) reads // 2023 client influencer API (RecNet.Runtime IBEEFFGHGND) — REAL wire
// BARE scalars, NOT wrapped account objects: // shapes verified against the 2023.03.21 ISIL decompile:
// influencers → List<Int32> (account ids) //
// influencer → Nullable<Int32> (the account id, or null) // influencers → OBJECT {"InfluencerIds":[...],"ContinuationToken":null}
// myinfluencer → Nullable<Int32> (supported influencer's id, or null) // - IBEEFFGHGND.txt:1612 — response callback is
// Returning an object here crashes the strict reader // Func<FEAJJMACBMF, FGLDKEJLAKB<List<Int32>>>, i.e. the body is
// ("expected:'Number Token', actual:'{'"), which is what broke the // deserialized as DTO object FEAJJMACBMF, NOT a bare array
// profile view (and the cheer flow that opens it). // (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")] [HttpGet("api/influencerpartnerprogram/influencers")]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> Influencers([FromQuery] int take = 50) 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) var creatorIds = await db.Rooms.Select(r => r.CreatorPlayerId)
.Concat(db.Inventions.Where(i => !i.IsDeleted).Select(i => i.CreatorPlayerId)) .Concat(db.Inventions.Where(i => !i.IsDeleted).Select(i => i.CreatorPlayerId))
.Concat(db.CustomAvatarItems.Select(i => i.CreatorPlayerId)) .Concat(db.CustomAvatarItems.Select(i => i.CreatorPlayerId))
.Distinct() .Distinct()
.Take(take) .Take(take)
.ToListAsync(); .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")] [HttpGet("api/influencerpartnerprogram/influencer")]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> Influencer([FromQuery] long? accountId = null, [FromQuery] string? username = null) 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 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.Id == id).Select(p => (long?)p.Id).FirstOrDefaultAsync()
: await db.Players.Where(p => p.Username == username).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")] [HttpGet("api/influencerpartnerprogram/myinfluencer")]
@@ -52,7 +74,7 @@ public class InfluencerPartnerProgramController(DorkNetDbContext db) : Controlle
return Content( return Content(
setting is not null && long.TryParse(setting.Value, out var influencerId) setting is not null && long.TryParse(setting.Value, out var influencerId)
? ((int)influencerId).ToString() ? ((int)influencerId).ToString()
: "null", : "0",
"application/json"); "application/json");
} }
@@ -1,134 +1,130 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Text.Json; using System.Security.Cryptography;
using DorkNet.Server.Auth; using DorkNet.Server.Auth;
using DorkNet.Server.Data; using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities; using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.ItemWishlists; 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] [ApiController]
[Route("api/itemWishlists")]
[Authorize] [Authorize]
public class ItemWishlistsController(DorkNetDbContext db) : ControllerBase public class ItemWishlistsController(DorkNetDbContext db) : ControllerBase
{ {
private long Me => this.RequireCurrentPlayerId(); private long Me => this.RequireCurrentPlayerId();
[HttpGet] [HttpGet("api/itemWishlists/v1/wishlist/me")]
[HttpGet("v1/wishlist/me")] public async Task<IActionResult> Mine() => await ForPlayer(Me);
public async Task<IActionResult> Mine()
[HttpGet("api/itemWishlists/v1/wishlist/{accountId:long}")]
public async Task<IActionResult> ForPlayer(long accountId)
{ {
var rows = await db.ItemWishlists var rows = await db.ItemWishlists
.Where(w => w.PlayerId == Me) .Where(w => w.PlayerId == accountId)
.OrderByDescending(w => w.CreatedAt) .OrderByDescending(w => w.CreatedAt)
.ToListAsync(); .ToListAsync();
return Ok(rows.Select(ToWire)); return Ok(rows.Select(ToWire).Where(w => w is not null).ToArray());
} }
[HttpGet("v1/wishlist")] [HttpPut("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
public async Task<IActionResult> ForPlayer([FromQuery] long? playerId) [HttpPost("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
public async Task<IActionResult> Add(int purchasableItemId)
{ {
var pid = playerId is > 0 ? playerId.Value : Me; var key = purchasableItemId.ToString();
var rows = await db.ItemWishlists var row = await db.ItemWishlists.FirstOrDefaultAsync(w =>
.Where(w => w.PlayerId == pid) w.PlayerId == Me && w.ItemKey == key);
.OrderByDescending(w => w.CreatedAt) if (row is null)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost]
[HttpPost("v1/wishlist")]
public async Task<IActionResult> Add()
{ {
var req = await ReadRequestAsync(); row = new ItemWishlistEntity
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)
{
db.ItemWishlists.Add(new ItemWishlistEntity
{ {
PlayerId = Me, PlayerId = Me,
ItemKey = key, ItemKey = key,
ItemType = itemType, ItemType = 0,
}); };
db.ItemWishlists.Add(row);
await db.SaveChangesAsync(); 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")] [HttpDelete("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
[HttpPost("v1/wishlist/remove")] public async Task<IActionResult> Remove(int purchasableItemId)
public async Task<IActionResult> Remove()
{ {
var req = await ReadRequestAsync(); var key = purchasableItemId.ToString();
var key = (req.ItemKey ?? req.ItemId ?? string.Empty).Trim();
if (key.Length == 0) return BadRequest(new { error = "missing_item" });
var itemType = req.ItemType ?? 0;
await db.ItemWishlists await db.ItemWishlists
.Where(w => w.PlayerId == Me && w.ItemKey == key && w.ItemType == itemType) .Where(w => w.PlayerId == Me && w.ItemKey == key)
.ExecuteDeleteAsync(); .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(); WishlistItemId = StableGuid(row.Id, row.PlayerId),
return new WishlistRequest AccountId = (int)row.PlayerId,
{ PurchasableItemId = purchasableItemId,
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();
}
}
private static object ToWire(ItemWishlistEntity row) => new
{
row.Id,
row.PlayerId,
row.ItemKey,
ItemId = row.ItemKey,
row.ItemType,
row.CreatedAt, row.CreatedAt,
}; };
}
public sealed class WishlistRequest /// <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)
{ {
public string? ItemKey { get; set; } Span<byte> input = stackalloc byte[16];
public string? ItemId { get; set; } BitConverter.TryWriteBytes(input, rowId);
public int? ItemType { get; set; } 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(); private long Me => this.RequireCurrentPlayerId();
/// <summary>POST <c>api/PlayerCheer/v1/create</c> — caller cheers /// <summary>POST <c>api/PlayerCheer/v1/create</c> — caller cheers the
/// the target player. <c>Type</c> matches the same enum as /// target player. Request fields (verified from RecNet.Runtime
/// <c>CheerEntity.Type</c>. Idempotent on (caller, target, type) /// KFJKDMGHKHE.EMKBFCLIOGN, KFJKDMGHKHE.txt:696-743, all posted via
/// to match <c>PlayerStateController.CheerPlayer</c>, so the same /// BestHTTP AddField → form): <c>PlayerIdTo</c> (target account id),
/// category click doesn't inflate the count.</summary> /// <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")] [HttpPost("api/PlayerCheer/v1/create")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> Create( public async Task<IActionResult> Create(
[FromForm(Name = "TargetAccountId")] long? targetAccountId, [FromForm(Name = "PlayerIdTo")] long? playerIdTo,
[FromForm(Name = "Type")] int type = 0) [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) if (playerIdTo is not long target || target <= 0)
return Ok(new { success = false, error = "missing_target" }); return CheerResult(false, "Invalid cheer target.");
if (Me == 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 => var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == Me && c.TargetPlayerId == target && c.FromPlayerId == Me && c.TargetPlayerId == target &&
c.TargetRoomId == 0 && c.Type == type); c.TargetRoomId == targetRoomId && c.Type == cheerCategory);
if (existing is not null) return Ok(new { success = true, already_cheered = true }); 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 db.Cheers.Add(new CheerEntity
{ {
FromPlayerId = Me, FromPlayerId = Me,
TargetPlayerId = target, TargetPlayerId = target,
Type = type, TargetRoomId = targetRoomId,
Type = cheerCategory,
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await level.AwardXpAsync(target, LevelService.CheerReceivedXp, $"cheer_from:{Me}"); await level.AwardXpAsync(target, LevelService.CheerReceivedXp, $"cheer_from:{Me}");
// Real notification: a PlayerCheer message the client renders as // Real notification: a PlayerCheer message the recipient renders as
// "X cheered you" (resolved from FromPlayerId), persisted to the // "X cheered you". Anonymous cheers use the anonymous message type and
// recipient's notification feed. Replaces the old blank-account // don't reveal the cheerer.
// SubscriptionUpdateProfile misuse.
await systemNotifications.SendAsync(target, await systemNotifications.SendAsync(target,
SystemNotificationService.MessageType.PlayerCheer, fromPlayerId: Me); anonymous
return Ok(new { success = true, error = "" }); ? 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 /// <summary>POST <c>api/PlayerCheer/v1/SetSelectedCheer</c> — pin
/// a cheer category as the caller's selected badge. Stored on the /// a cheer category as the caller's selected badge. Stored on the
/// PlayerSetting key <c>SelectedCheer</c> (string-form int). /// PlayerSetting key <c>SelectedCheer</c> (string-form int).
@@ -1,32 +1,114 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth; using DorkNet.Server.Auth;
using DorkNet.Server.Data; using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities; using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.PlatformNotifications; namespace DorkNet.Server.Controllers.PlatformNotifications;
/// <summary>
/// platformnotifications.rec.net — the 2023 client's platform push-
/// notification preference service (RecNet.Runtime LELAJKMOMIA; the
/// service host enum value 15 resolves to the PlatformNotifications
/// entry of the service map, relative paths "preferences",
/// "config/categories", "badge", "device").
///
/// Wire contracts verified against the 2023.03.21 ISIL decompile:
///
/// GET preferences → RecNet.PlatformNotificationPreferencesDTO — an
/// OBJECT whose only key is "MutedCategories" (a list of category
/// enum values). Generated reader JLHCGPANJCB.txt:151-185 registers
/// exactly that key. LELAJKMOMIA.txt:1102 (DCGHGFDPAMM) issues the
/// GET; an array/empty body throws the strict-reader error seen in
/// Player.log:1409-1711 ("expected:'{', actual:'['" /
/// "Malformed Response: '[]'" → "Failed to get notification
/// preferences").
///
/// PUT preferences → LELAJKMOMIA.txt:1480 (KKPAKGOCPEA) sends the
/// same DTO ({"MutedCategories":[...]}) with HTTP verb 3 = PUT
/// (verb table HNLCIDLIIBO.txt:878-903). Fire-and-forget
/// (LDGADANDBIO) — response body ignored.
///
/// GET config/categories → parsed as envelope MKIFDCBPEAD
/// {"Results":[...],"TotalResults":N} (reader JBGKGCLPCHP.txt:203-238)
/// then converted via Func&lt;MKIFDCBPEAD, List&lt;PlatformNotification-
/// CategoryConfigDTO&gt;&gt; (LELAJKMOMIA.txt:1930 region). Each item is
/// {"CategoryId","Importance","Name","Description","IsMuteable"}
/// (reader JNFDPPCIPHG.txt:371-462).
/// </summary>
[ApiController] [ApiController]
[Authorize] [Authorize]
public class PlatformNotificationsController(DorkNetDbContext db) : ControllerBase public class PlatformNotificationsController(DorkNetDbContext db) : ControllerBase
{ {
private long Me => this.RequireCurrentPlayerId(); private long Me => this.RequireCurrentPlayerId();
/// <summary>The category catalogue we expose. CategoryId values are
/// round-tripped between config/categories and MutedCategories, so
/// they only need to be internally consistent.</summary>
private static readonly (int CategoryId, string Name, string Description)[] CategoryCatalog =
{
(1, "Messages", "Direct messages from other players"),
(2, "Friend Requests", "Incoming friend requests"),
(3, "Event Invites", "Invitations to events"),
(4, "Announcements", "News and announcements from Rec Room"),
};
[HttpGet("/preferences")] [HttpGet("/preferences")]
[HttpGet("/platformnotifications/preferences")] [HttpGet("/platformnotifications/preferences")]
public async Task<IActionResult> Preferences() public async Task<IActionResult> Preferences()
{ {
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == Me); var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == Me);
return Ok(ToPreferencesWire(row)); // Exact DTO shape: {"MutedCategories":[...]} — see class doc.
return Ok(new { MutedCategories = MutedCategoriesFor(row) });
} }
/// <summary>PUT preferences — body is the same DTO
/// ({"MutedCategories":[1,4]}). Parsed manually so we tolerate any
/// key casing and missing/null lists. Response body is ignored by
/// the client (LDGADANDBIO), but we echo the stored DTO anyway.</summary>
[HttpPut("/preferences")] [HttpPut("/preferences")]
[HttpPost("/preferences")] [HttpPost("/preferences")]
[HttpPut("/platformnotifications/preferences")] [HttpPut("/platformnotifications/preferences")]
[HttpPost("/platformnotifications/preferences")] [HttpPost("/platformnotifications/preferences")]
public async Task<IActionResult> SetPreferences([FromBody] PlatformNotificationPrefsRequest? body) public async Task<IActionResult> SetPreferences()
{ {
List<int>? muted = null;
string raw;
using (var reader = new StreamReader(Request.Body))
raw = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(raw))
{
try
{
using var doc = JsonDocument.Parse(raw);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!prop.Name.Equals("MutedCategories", StringComparison.OrdinalIgnoreCase))
continue;
if (prop.Value.ValueKind != JsonValueKind.Array) continue;
muted = new List<int>();
foreach (var el in prop.Value.EnumerateArray())
{
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt32(out var v))
muted.Add(v);
else if (el.ValueKind == JsonValueKind.String
&& int.TryParse(el.GetString(), out var s))
muted.Add(s);
}
}
}
}
catch (JsonException)
{
// Non-JSON body — treat as "no change".
}
}
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == Me); var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == Me);
if (row is null) if (row is null)
{ {
@@ -38,85 +120,52 @@ public class PlatformNotificationsController(DorkNetDbContext db) : ControllerBa
db.NotificationPrefs.Add(row); db.NotificationPrefs.Add(row);
} }
if (body is not null) if (muted is not null)
{ {
row.AllowAll = body.AllowAll ?? body.Enabled ?? row.AllowAll; row.AllowMessage = !muted.Contains(1);
row.AllowMessage = body.AllowMessages ?? body.Messages ?? row.AllowMessage; row.AllowFriendRequest = !muted.Contains(2);
row.AllowFriendRequest = body.AllowFriendRequests ?? body.FriendRequests ?? row.AllowFriendRequest; row.AllowEventInvite = !muted.Contains(3);
row.AllowEventInvite = body.AllowEventInvites ?? body.EventInvites ?? row.AllowEventInvite; row.AllowAnnouncements = !muted.Contains(4);
row.AllowAnnouncements = body.AllowAnnouncements ?? body.Announcements ?? row.AllowAnnouncements;
row.UpdatedAt = DateTime.UtcNow; row.UpdatedAt = DateTime.UtcNow;
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(ToPreferencesWire(row)); return Ok(new { MutedCategories = MutedCategoriesFor(row) });
} }
/// <summary>GET config/categories — {"Results":[...],"TotalResults":N}
/// envelope of PlatformNotificationCategoryConfigDTO items.</summary>
[HttpGet("/config/categories")] [HttpGet("/config/categories")]
[HttpGet("/platformnotifications/config/categories")] [HttpGet("/platformnotifications/config/categories")]
[AllowAnonymous] [AllowAnonymous]
public IActionResult Categories() public IActionResult Categories()
{ {
var categories = new[] var categories = CategoryCatalog
.Select(c => new
{ {
Category(1, "messages", "Messages"), c.CategoryId,
Category(2, "friendRequests", "Friend Requests"), Importance = 0,
Category(3, "eventInvites", "Event Invites"), c.Name,
Category(4, "announcements", "Announcements"), c.Description,
}; IsMuteable = true,
})
.ToArray();
return Ok(new return Ok(new
{ {
Categories = categories,
Results = categories, Results = categories,
TotalResults = categories.Length, TotalResults = categories.Length,
}); });
} }
private static object Category(int id, string key, string name) => new private static int[] MutedCategoriesFor(NotificationPrefsEntity? row)
{ {
Id = id, if (row is null) return Array.Empty<int>();
CategoryId = id, var muted = new List<int>(4);
Key = key, if (!row.AllowMessage) muted.Add(1);
Name = name, if (!row.AllowFriendRequest) muted.Add(2);
DisplayName = name, if (!row.AllowEventInvite) muted.Add(3);
IsEnabled = true, if (!row.AllowAnnouncements) muted.Add(4);
Enabled = true, return muted.ToArray();
DefaultEnabled = true,
};
private static object ToPreferencesWire(NotificationPrefsEntity? row) => new
{
AllowAll = row?.AllowAll ?? true,
Enabled = row?.AllowAll ?? true,
AllowMessages = row?.AllowMessage ?? true,
Messages = row?.AllowMessage ?? true,
AllowFriendRequests = row?.AllowFriendRequest ?? true,
FriendRequests = row?.AllowFriendRequest ?? true,
AllowEventInvites = row?.AllowEventInvite ?? true,
EventInvites = row?.AllowEventInvite ?? true,
AllowAnnouncements = row?.AllowAnnouncements ?? true,
Announcements = row?.AllowAnnouncements ?? true,
Categories = new[]
{
new { CategoryId = 1, Key = "messages", Enabled = row?.AllowMessage ?? true },
new { CategoryId = 2, Key = "friendRequests", Enabled = row?.AllowFriendRequest ?? true },
new { CategoryId = 3, Key = "eventInvites", Enabled = row?.AllowEventInvite ?? true },
new { CategoryId = 4, Key = "announcements", Enabled = row?.AllowAnnouncements ?? true },
},
};
public sealed class PlatformNotificationPrefsRequest
{
public bool? AllowAll { get; set; }
public bool? Enabled { get; set; }
public bool? AllowMessages { get; set; }
public bool? Messages { get; set; }
public bool? AllowFriendRequests { get; set; }
public bool? FriendRequests { get; set; }
public bool? AllowEventInvites { get; set; }
public bool? EventInvites { get; set; }
public bool? AllowAnnouncements { get; set; }
public bool? Announcements { get; set; }
} }
} }
@@ -34,6 +34,17 @@ public class AvatarEntity
[MaxLength(2048)] [MaxLength(2048)]
public string OutfitSelections { get; set; } = string.Empty; public string OutfitSelections { get; set; } = string.Empty;
/// <summary>2023 client's OutfitSelectionsV2 blob (HHDLNAPEMGP key
/// "OutfitSelectionsV2", reader EKHJKDNHOPB.txt:498-514 in the
/// 2023.03.21 ISIL dump). Opaque — round-tripped verbatim.</summary>
[MaxLength(8192)]
public string OutfitSelectionsV2 { get; set; } = string.Empty;
/// <summary>2023 client's equipped custom avatar items — JSON array
/// of {"CustomAvatarItemId":guid,"BodyPart":byte} (BGNNOMBFMLH,
/// reader MKAIABAJIAJ.txt:215-258). Stored as raw JSON.</summary>
public string CustomAvatarItemsJson { get; set; } = "[]";
/// <summary>v2 face features — opaque JSON written by the face /// <summary>v2 face features — opaque JSON written by the face
/// editor; we never parse it server-side, just round-trip.</summary> /// editor; we never parse it server-side, just round-trip.</summary>
[MaxLength(8192)] [MaxLength(8192)]
@@ -111,6 +111,7 @@ public static class DatabaseBootstrap
await EnsureInventionColumnsAsync(db); await EnsureInventionColumnsAsync(db);
await EnsureGameRewardColumnsAsync(db); await EnsureGameRewardColumnsAsync(db);
await EnsureGiftPackageColumnsAsync(db); await EnsureGiftPackageColumnsAsync(db);
await EnsureAvatarColumnsAsync(db);
await EnsureMarch2023TablesAsync(db); await EnsureMarch2023TablesAsync(db);
// Coach system account at Player.Id=1. The RR-Original room seeder // Coach system account at Player.Id=1. The RR-Original room seeder
@@ -381,6 +382,38 @@ public static class DatabaseBootstrap
catch { } catch { }
} }
/// <summary>Add the 2023-client avatar columns (OutfitSelectionsV2 +
/// CustomAvatarItemsJson on Avatars) — post-Initial, so existing DBs
/// need an idempotent ALTER on both providers.</summary>
private static async Task EnsureAvatarColumnsAsync(DorkNetDbContext db)
{
var provider = db.Database.ProviderName ?? string.Empty;
if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase))
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Avatars"" ADD COLUMN IF NOT EXISTS ""OutfitSelectionsV2"" character varying(8192) NOT NULL DEFAULT '';");
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Avatars"" ADD COLUMN IF NOT EXISTS ""CustomAvatarItemsJson"" text NOT NULL DEFAULT '[]';");
return;
}
try
{
// SQLite has no ADD COLUMN IF NOT EXISTS; ignore the duplicate-
// column error when it already exists.
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Avatars"" ADD COLUMN ""OutfitSelectionsV2"" TEXT NOT NULL DEFAULT '';");
}
catch { }
try
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Avatars"" ADD COLUMN ""CustomAvatarItemsJson"" TEXT NOT NULL DEFAULT '[]';");
}
catch { }
}
private static async Task EnsureInventionColumnsAsync(DorkNetDbContext db) private static async Task EnsureInventionColumnsAsync(DorkNetDbContext db)
{ {
var provider = db.Database.ProviderName ?? string.Empty; var provider = db.Database.ProviderName ?? string.Empty;