From 757308b44df1a2af7ef1d532f748496506b0285d Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:54:11 +0300 Subject: [PATCH] feat: Update PlayerCheer and Avatar handling; enhance API responses and add new fields for 2023 client compatibility --- .../SystemNotificationTests.cs | 9 +- .../API/Avatar/V2/AvatarV2Controller.cs | 80 ++++++++ .../InfluencerPartnerProgramController.cs | 46 +++-- .../ItemWishlists/ItemWishlistsController.cs | 178 +++++++++--------- .../API/PlayerCheer/PlayerCheerController.cs | 72 +++++-- .../PlatformNotificationsController.cs | 171 +++++++++++------ DorkNet.Server/Data/Entities/AvatarEntity.cs | 11 ++ DorkNet.Server/Startup/DatabaseBootstrap.cs | 33 ++++ 8 files changed, 415 insertions(+), 185 deletions(-) diff --git a/DorkNet.Server.Tests/SystemNotificationTests.cs b/DorkNet.Server.Tests/SystemNotificationTests.cs index 91f6ed4..fcade41 100644 --- a/DorkNet.Server.Tests/SystemNotificationTests.cs +++ b/DorkNet.Server.Tests/SystemNotificationTests.cs @@ -29,11 +29,16 @@ public sealed class SystemNotificationTests : IClassFixture { - ["TargetAccountId"] = target.PlayerId.ToString(), - ["Type"] = "0", + ["PlayerIdTo"] = target.PlayerId.ToString(), + ["CheerCategory"] = "0", + ["Anonymous"] = "false", })); resp.EnsureSuccessStatusCode(); diff --git a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarV2Controller.cs b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarV2Controller.cs index 4da8db9..9a46f11 100644 --- a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarV2Controller.cs +++ b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarV2Controller.cs @@ -78,6 +78,8 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger 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 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 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 }; } + /// GET api/avatar/v2/{accountId} — 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). + [HttpGet("api/avatar/v2/{playerId:long}")] + [AllowAnonymous] + public async Task> GetSettingsFor(long playerId) + { + var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == playerId); + return Ok(ToDto(a)); + } + /// 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 FaceFeatures = a?.FaceFeatures ?? string.Empty, HairColor = a?.HairColor ?? string.Empty, SkinColor = a?.SkinColor ?? string.Empty, + OutfitSelectionsV2 = a?.OutfitSelectionsV2 ?? string.Empty, + CustomAvatarItems = ParseCustomAvatarItems(a?.CustomAvatarItemsJson), }; + + /// 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 []. + private static List ParseCustomAvatarItems(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new(); + try + { + return JsonSerializer.Deserialize>( + 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; + + /// 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. + [JsonPropertyName("OutfitSelectionsV2")] + public string OutfitSelectionsV2 { get; set; } = string.Empty; + + /// 2023 client field — HHDLNAPEMGP reader key + /// "CustomAvatarItems" (EKHJKDNHOPB.txt:594-610), a list of + /// BGNNOMBFMLH items. + [JsonPropertyName("CustomAvatarItems")] + public List CustomAvatarItems { get; set; } = new(); +} + +/// BGNNOMBFMLH — { Guid CustomAvatarItemId, Byte BodyPart } +/// (property types BGNNOMBFMLH.txt:3-39; JSON keys registered by reader +/// MKAIABAJIAJ.txt:215-258). +public class CustomAvatarItemRefDto +{ + [JsonPropertyName("CustomAvatarItemId")] + public Guid CustomAvatarItemId { get; set; } + + [JsonPropertyName("BodyPart")] + public byte BodyPart { get; set; } } diff --git a/DorkNet.Server/Controllers/API/InfluencerPartnerProgram/InfluencerPartnerProgramController.cs b/DorkNet.Server/Controllers/API/InfluencerPartnerProgram/InfluencerPartnerProgramController.cs index a6a98f4..def1b2a 100644 --- a/DorkNet.Server/Controllers/API/InfluencerPartnerProgram/InfluencerPartnerProgramController.cs +++ b/DorkNet.Server/Controllers/API/InfluencerPartnerProgram/InfluencerPartnerProgramController.cs @@ -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 (account ids) - // influencer → Nullable (the account id, or null) - // myinfluencer → Nullable (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>>, 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 + // (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 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>. + // - IBEEFFGHGND_NestedType___c.txt:61-111 + // (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 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"); } diff --git a/DorkNet.Server/Controllers/API/ItemWishlists/ItemWishlistsController.cs b/DorkNet.Server/Controllers/API/ItemWishlists/ItemWishlistsController.cs index cbcadc2..4cfa823 100644 --- a/DorkNet.Server/Controllers/API/ItemWishlists/ItemWishlistsController.cs +++ b/DorkNet.Server/Controllers/API/ItemWishlists/ItemWishlistsController.cs @@ -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; +/// +/// 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<List<BFJNGMGONED>> 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<BFJNGMGONED> 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. +/// [ApiController] -[Route("api/itemWishlists")] [Authorize] public class ItemWishlistsController(DorkNetDbContext db) : ControllerBase { private long Me => this.RequireCurrentPlayerId(); - [HttpGet] - [HttpGet("v1/wishlist/me")] - public async Task Mine() + [HttpGet("api/itemWishlists/v1/wishlist/me")] + public async Task Mine() => await ForPlayer(Me); + + [HttpGet("api/itemWishlists/v1/wishlist/{accountId:long}")] + public async Task 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 ForPlayer([FromQuery] long? playerId) + [HttpPut("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")] + [HttpPost("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")] + public async Task 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 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 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 Remove() + [HttpDelete("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")] + public async Task 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 ReadRequestAsync() + /// BFJNGMGONED wire item. Rows whose ItemKey isn't a + /// purchasable-item integer (legacy free-form keys) are skipped. + 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( - 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 + /// Deterministic per-row Guid — the client only uses + /// WishlistItemId as an opaque identity, but it must stay stable + /// across requests. + 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 input = stackalloc byte[16]; + BitConverter.TryWriteBytes(input, rowId); + BitConverter.TryWriteBytes(input[8..], playerId); + Span hash = stackalloc byte[32]; + SHA256.HashData(input, hash); + return new Guid(hash[..16]); } } diff --git a/DorkNet.Server/Controllers/API/PlayerCheer/PlayerCheerController.cs b/DorkNet.Server/Controllers/API/PlayerCheer/PlayerCheerController.cs index f39b411..3d20c8e 100644 --- a/DorkNet.Server/Controllers/API/PlayerCheer/PlayerCheerController.cs +++ b/DorkNet.Server/Controllers/API/PlayerCheer/PlayerCheerController.cs @@ -25,44 +25,78 @@ public class PlayerCheerController( { private long Me => this.RequireCurrentPlayerId(); - /// POST api/PlayerCheer/v1/create — caller cheers - /// the target player. Type matches the same enum as - /// CheerEntity.Type. Idempotent on (caller, target, type) - /// to match PlayerStateController.CheerPlayer, so the same - /// category click doesn't inflate the count. + /// POST api/PlayerCheer/v1/create — caller cheers the + /// target player. Request fields (verified from RecNet.Runtime + /// KFJKDMGHKHE.EMKBFCLIOGN, KFJKDMGHKHE.txt:696-743, all posted via + /// BestHTTP AddField → form): PlayerIdTo (target account id), + /// CheerCategory (the cheer enum), RoomId (optional room + /// context), Anonymous (bool). The OLD binding read + /// TargetAccountId/Type, 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). [HttpPost("api/PlayerCheer/v1/create")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task 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, ""); } + /// The create response deserializes into PHMHCPEMABG + /// { bool GJLFIFEJDEH, string ABGLLJPIMIO } (KFJKDMGHKHE.txt maps the raw + /// body via Func<PHMHCPEMABG, LDGADANDBIO>). 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). + private IActionResult CheerResult(bool success, string message) => + Ok(new Dictionary + { + ["Success"] = success, + ["success"] = success, + ["Message"] = message, + ["message"] = message, + ["Error"] = success ? string.Empty : message, + ["error"] = success ? string.Empty : message, + }); + /// POST api/PlayerCheer/v1/SetSelectedCheer — pin /// a cheer category as the caller's selected badge. Stored on the /// PlayerSetting key SelectedCheer (string-form int). diff --git a/DorkNet.Server/Controllers/PlatformNotifications/PlatformNotificationsController.cs b/DorkNet.Server/Controllers/PlatformNotifications/PlatformNotificationsController.cs index 4769789..8a77903 100644 --- a/DorkNet.Server/Controllers/PlatformNotifications/PlatformNotificationsController.cs +++ b/DorkNet.Server/Controllers/PlatformNotifications/PlatformNotificationsController.cs @@ -1,32 +1,114 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Text.Json; using DorkNet.Server.Auth; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; namespace DorkNet.Server.Controllers.PlatformNotifications; +/// +/// 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<MKIFDCBPEAD, List<PlatformNotification- +/// CategoryConfigDTO>> (LELAJKMOMIA.txt:1930 region). Each item is +/// {"CategoryId","Importance","Name","Description","IsMuteable"} +/// (reader JNFDPPCIPHG.txt:371-462). +/// [ApiController] [Authorize] public class PlatformNotificationsController(DorkNetDbContext db) : ControllerBase { private long Me => this.RequireCurrentPlayerId(); + /// The category catalogue we expose. CategoryId values are + /// round-tripped between config/categories and MutedCategories, so + /// they only need to be internally consistent. + 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("/platformnotifications/preferences")] public async Task Preferences() { 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) }); } + /// 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. [HttpPut("/preferences")] [HttpPost("/preferences")] [HttpPut("/platformnotifications/preferences")] [HttpPost("/platformnotifications/preferences")] - public async Task SetPreferences([FromBody] PlatformNotificationPrefsRequest? body) + public async Task SetPreferences() { + List? 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(); + 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); if (row is null) { @@ -38,85 +120,52 @@ public class PlatformNotificationsController(DorkNetDbContext db) : ControllerBa db.NotificationPrefs.Add(row); } - if (body is not null) + if (muted is not null) { - row.AllowAll = body.AllowAll ?? body.Enabled ?? row.AllowAll; - row.AllowMessage = body.AllowMessages ?? body.Messages ?? row.AllowMessage; - row.AllowFriendRequest = body.AllowFriendRequests ?? body.FriendRequests ?? row.AllowFriendRequest; - row.AllowEventInvite = body.AllowEventInvites ?? body.EventInvites ?? row.AllowEventInvite; - row.AllowAnnouncements = body.AllowAnnouncements ?? body.Announcements ?? row.AllowAnnouncements; + row.AllowMessage = !muted.Contains(1); + row.AllowFriendRequest = !muted.Contains(2); + row.AllowEventInvite = !muted.Contains(3); + row.AllowAnnouncements = !muted.Contains(4); row.UpdatedAt = DateTime.UtcNow; } await db.SaveChangesAsync(); - return Ok(ToPreferencesWire(row)); + return Ok(new { MutedCategories = MutedCategoriesFor(row) }); } + /// GET config/categories — {"Results":[...],"TotalResults":N} + /// envelope of PlatformNotificationCategoryConfigDTO items. [HttpGet("/config/categories")] [HttpGet("/platformnotifications/config/categories")] [AllowAnonymous] public IActionResult Categories() { - var categories = new[] - { - Category(1, "messages", "Messages"), - Category(2, "friendRequests", "Friend Requests"), - Category(3, "eventInvites", "Event Invites"), - Category(4, "announcements", "Announcements"), - }; + var categories = CategoryCatalog + .Select(c => new + { + c.CategoryId, + Importance = 0, + c.Name, + c.Description, + IsMuteable = true, + }) + .ToArray(); return Ok(new { - Categories = categories, Results = categories, TotalResults = categories.Length, }); } - private static object Category(int id, string key, string name) => new + private static int[] MutedCategoriesFor(NotificationPrefsEntity? row) { - Id = id, - CategoryId = id, - Key = key, - Name = name, - DisplayName = name, - IsEnabled = true, - Enabled = true, - 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; } + if (row is null) return Array.Empty(); + var muted = new List(4); + if (!row.AllowMessage) muted.Add(1); + if (!row.AllowFriendRequest) muted.Add(2); + if (!row.AllowEventInvite) muted.Add(3); + if (!row.AllowAnnouncements) muted.Add(4); + return muted.ToArray(); } } diff --git a/DorkNet.Server/Data/Entities/AvatarEntity.cs b/DorkNet.Server/Data/Entities/AvatarEntity.cs index e634edd..8de82ed 100644 --- a/DorkNet.Server/Data/Entities/AvatarEntity.cs +++ b/DorkNet.Server/Data/Entities/AvatarEntity.cs @@ -34,6 +34,17 @@ public class AvatarEntity [MaxLength(2048)] public string OutfitSelections { get; set; } = string.Empty; + /// 2023 client's OutfitSelectionsV2 blob (HHDLNAPEMGP key + /// "OutfitSelectionsV2", reader EKHJKDNHOPB.txt:498-514 in the + /// 2023.03.21 ISIL dump). Opaque — round-tripped verbatim. + [MaxLength(8192)] + public string OutfitSelectionsV2 { get; set; } = string.Empty; + + /// 2023 client's equipped custom avatar items — JSON array + /// of {"CustomAvatarItemId":guid,"BodyPart":byte} (BGNNOMBFMLH, + /// reader MKAIABAJIAJ.txt:215-258). Stored as raw JSON. + public string CustomAvatarItemsJson { get; set; } = "[]"; + /// v2 face features — opaque JSON written by the face /// editor; we never parse it server-side, just round-trip. [MaxLength(8192)] diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index 2db2a22..f3665b6 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -111,6 +111,7 @@ public static class DatabaseBootstrap await EnsureInventionColumnsAsync(db); await EnsureGameRewardColumnsAsync(db); await EnsureGiftPackageColumnsAsync(db); + await EnsureAvatarColumnsAsync(db); await EnsureMarch2023TablesAsync(db); // Coach system account at Player.Id=1. The RR-Original room seeder @@ -381,6 +382,38 @@ public static class DatabaseBootstrap catch { } } + /// Add the 2023-client avatar columns (OutfitSelectionsV2 + + /// CustomAvatarItemsJson on Avatars) — post-Initial, so existing DBs + /// need an idempotent ALTER on both providers. + 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) { var provider = db.Database.ProviderName ?? string.Empty;