fix: presence, profile edits, outfit menu and RecNetResult writes for the 2023 client

Every change below was checked against the 2023-03-21 client disassembly;
file:line evidence is in the code comments and in
docs/recroom-2023-client-api-complete.md.

Presence
- matchmake/none means "online, in no room" (its only caller is
  TryGoToOfflineRoom, which ignores the reply). The server used to keep the
  old room or invent a dorm, so the next heartbeat looked out of sync and
  the client cancelled its load to chase that room.
- The join watchdog no longer clears presence 30 s into a long room load;
  it is a 3 minute backstop that drops the room only.
- One fan-out (PresenceBroadcastService) for every transition: deferred
  joins, matchmake/none, logout, failed joins, visibility changes, SignalR
  connect/disconnect. isOnline is sent on its own; pushes never go to the
  subject, because a self push moves the player.
- Online-status visibility (Public / Friends / Favorites / Appear offline)
  is stored and honoured by GET /player and by the live pushes.

Profile
- PUT account/me/{emoji,personalpronouns,identityflags,bannerimage} were
  404; added with the client's real form keys and persisted (new Players
  columns plus migration). Every account read now returns them, and edits
  push AccountUpdate to everyone.

Outfit menu
- Saved outfits kept OutfitSelectionsV2 and CustomAvatarItems, so loading a
  slot restores what was saved instead of a stale outfit.
- isCreationAllowedForAccount answers the {Success, Error} envelope the
  client parses; minPriceForPublicItem is a bare int; gifts/generate accepts
  form bodies; lockeditems returns the full owned-item shape; colourways no
  longer share their base item's AvatarItemId.

RecNetResult writes
- About 40 writes look status-only but parse the body as {Success, Error}.
  An empty 200 showed "Failed to ..." after the server applied the change,
  and a body without Success threw "Error message cannot be null". Clubs,
  announcements, keepsakes, comments/read, subscriptions, purchase offers
  and privacy settings now answer RecNetResult.
- Login token and device-code grants are real; remoteauth approves the
  device code; createlogintoken returns a bare string.
- New: incentivizedreferrals/referrals, account/bulk/{email,phone},
  GET strings, account/me/confirmphone, PlayMenuCarouselOrdering_1.
  Route inventory and coverage test extended to match.

README now describes the 2023 branch.
This commit is contained in:
Alexa
2026-10-15 14:08:11 +03:00
parent 36e64e99ba
commit 6ee1d6f9b3
45 changed files with 6666 additions and 448 deletions
+53 -47
View File
@@ -170,45 +170,25 @@ public class NotificationService(
}
}
/// <summary>Push fresh account payloads to the affected player's
/// watch. The 2020 client caches Account/SelfAccount objects in memory;
/// without this, username and display-name edits persist server-side
/// but the current session keeps rendering the stale cached names until
/// a full relog rebuilds the account cache.</summary>
/// <summary>Push fresh account payloads after a profile mutation.
/// The owner gets <c>SelfAccountUpdate</c> (private self DTO) and every
/// connected client gets <c>AccountUpdate</c> (public DTO). The 2023
/// client subscribes to both strings (RecNet.Runtime
/// OPMAPIOEIFG.txt:267 "AccountUpdate" → Action&lt;Account&gt;, :279
/// "SelfAccountUpdate" → Action&lt;SelfAccount&gt;) and refreshes its
/// cached account from the payload — the profile-edit PUTs return no
/// account, so this push is the ONLY way the new emoji / banner /
/// pronouns / identity flags reach the editor's own UI and anyone else
/// already looking at the profile. Sending AccountUpdate to the owner
/// alone (the previous behaviour) left every other player rendering the
/// stale cached account until relog.</summary>
public Task AccountChanged(PlayerEntity player)
{
var account = new RecNetAccount
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = string.IsNullOrWhiteSpace(player.DisplayName) ? player.Username : player.DisplayName,
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = player.IsJunior,
HasBirthday = true,
Platforms = 1,
CreatedAt = player.CreatedAt,
};
var selfAccount = new RecNetSelfAccount
{
AccountId = account.AccountId,
RawUsername = account.RawUsername,
Username = account.Username,
DisplayName = account.DisplayName,
ProfileImage = account.ProfileImage,
TreatAsJunior = account.TreatAsJunior,
HasBirthday = account.HasBirthday,
Platforms = account.Platforms,
CreatedAt = account.CreatedAt,
Email = player.Email ?? string.Empty,
Phone = player.Phone ?? string.Empty,
Birthday = player.Birthday ?? new DateTime(2000, 1, 1),
JuniorState = player.IsJunior ? 1 : 0,
ParentAccountId = null,
};
var account = AccountDtos.Public(player);
var selfAccount = AccountDtos.Self(player);
return Task.WhenAll(
NotifyAsync(player.Id, PushNotificationId.SubscriptionUpdateProfile, account),
BroadcastAsync(PushNotificationId.SubscriptionUpdateProfile, account),
NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
}
@@ -271,17 +251,49 @@ public class NotificationService(
/// the runtime-type path can pick the wrong contract under certain
/// option combos and emit an empty <c>{}</c> body — which silently
/// drops the presence update at the watch.</summary>
public async Task PlayerPresenceChanged(IEnumerable<long> friendIds, long playerId, RoomInstanceDto? room)
public async Task PlayerPresenceChanged(
IEnumerable<long> friendIds,
long playerId,
RoomInstanceDto? room,
bool? isOnline = null,
int statusVisibility = 0)
{
var groups = friendIds.Select(NotifyHub.GroupForPlayer).ToArray();
if (groups.Length == 0) return;
var redacted = RoomInstanceDto.Redact(room);
var payload = BuildPresenceUpdatePayload(playerId, room, isOnline, statusVisibility);
logger.LogDebug(
"[notify] presence push player={PlayerId} → {FriendCount} friend(s), payload={Payload}",
playerId, groups.Length, payload);
await hub.Clients.Groups(groups).SendAsync(HubMethod, payload);
}
/// <summary>The PresenceUpdate envelope for one player, as a JSON string.
///
/// <paramref name="isOnline"/> is independent of the room: the 2023
/// presence DTO stores it in its own field (LCEHINCCHOB +0x50,
/// RecNet.Runtime/LCEHINCCHOB.txt:635-653) and DNKHCBFPLJG treats
/// "online with no room" as a real state — a player in the offline dorm
/// after matchmake/none. Deriving it from the room, as this used to, sent
/// such players to their friends as OFFLINE. When omitted it still falls
/// back to "has a room" for callers that only know about room moves.
/// An offline frame never carries a room.</summary>
public static string BuildPresenceUpdatePayload(
long playerId,
RoomInstanceDto? room,
bool? isOnline = null,
int statusVisibility = 0)
{
var online = isOnline ?? room is not null;
var redacted = online ? RoomInstanceDto.Redact(room) : null;
var msg = new System.Text.Json.Nodes.JsonObject
{
["playerId"] = (int)playerId,
// Everyone=0 (was 1=FriendsOnly, which hid you from non-friends).
["statusVisibility"] = 0,
// The player's stored /player/statusvisibility choice (default
// 0 = Everyone), the same value their own heartbeat reports.
["statusVisibility"] = statusVisibility,
["deviceClass"] = 0,
["vrMovementMode"] = 0,
["roomInstance"] = redacted is null
@@ -307,7 +319,7 @@ public class NotificationService(
["EncryptVoiceChat"] = redacted.EncryptVoiceChat,
},
// 2020.12 PlayerPresence.Deserialize requires these.
["isOnline"] = redacted is not null,
["isOnline"] = online,
// 2023.03.21 client build version; must match the joining
// client's own version or it reports a version mismatch. MUST be
// a STRING ("20230317"): the client's presence DTO reads
@@ -325,13 +337,7 @@ public class NotificationService(
["Id"] = WireName(PushNotificationId.SubscriptionUpdatePresence),
["Msg"] = msg,
};
var payload = envelope.ToJsonString();
logger.LogDebug(
"[notify] presence push player={PlayerId} → {FriendCount} friend(s), payload={Payload}",
playerId, groups.Length, payload);
await hub.Clients.Groups(groups).SendAsync(HubMethod, payload);
return envelope.ToJsonString();
}
public Task PlayerCameOnline(IEnumerable<long> friendIds, long playerId, RoomInstanceDto? room) =>