From 3d089f6e325d745278cea611ab81146e68e77076 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 31 May 2026 20:52:11 +0300 Subject: [PATCH] admin: change a player's username from the Players panel Add POST players/{id}/username + a Username field in the player detail's Profile tab. Changes only the unique @handle (not the display name) and rejects a collision outright rather than auto-suffixing, so the admin gets the exact handle they typed. Same 2-24 char letters/digits/_/- rule as the account API; logged to the audit trail as old -> new. --- DorkNet.ClientMod/Mod.cs | 65 +++++++- .../API/Avatar/V4/AvatarItemsController.cs | 143 +++++++++++++----- .../Controllers/Admin/AdminController.cs | 29 ++++ ...ersAlexaDocumentsRecnet-december_cvout.txt | 1 + .../Data/CUsersAlexaDocuments_cv.txt | 7 + .../Data/CUsersAlexaDocuments_lk.txt | 2 + DorkNet.Server/admin-ui/src/pages/Players.tsx | 14 ++ 7 files changed, 219 insertions(+), 42 deletions(-) create mode 100644 DorkNet.Server/Data/CUsersAlexaDocumentsRecnet-december_cvout.txt create mode 100644 DorkNet.Server/Data/CUsersAlexaDocuments_cv.txt create mode 100644 DorkNet.Server/Data/CUsersAlexaDocuments_lk.txt diff --git a/DorkNet.ClientMod/Mod.cs b/DorkNet.ClientMod/Mod.cs index 0839046..0bef2db 100644 --- a/DorkNet.ClientMod/Mod.cs +++ b/DorkNet.ClientMod/Mod.cs @@ -70,6 +70,24 @@ public class Mod : MelonMod // Photon Custom Auth injector parked 2026-05-28; see // attic/AuthValuesInjector.cs.attic for the code + restore notes. public static bool EnableTlsTrustBypass = true; + // Treat the local account as already fully registered so the + // first-dorm-entry registration flow never runs. That flow + // (DormroomSceneManager → PromptForRegistration) shows two + // back-to-back watch dialogs: first "Your username is @ — want to change it?" (which broadcasts the throwaway + // auto-generated handle and makes the account look brand-new), + // then the "…permanently lost without a password…" warning whose + // confirm button never actually dismisses the modal on this + // build, hard-locking the player in the dorm. Both are gated on + // RRUI.Data.NUX.RegistrationModel.IsFullyRegistered(): when it + // returns true the watch jumps clean past the whole block + // (verified in DormroomSceneManager_NestedType_FBDKOJHAJMJ / + // ___c ISIL — `Call RegistrationModel.IsFullyRegistered` → + // `Compare rax,0` → `JumpIfNotEqual` skips the + // ShowConfirmationDialog calls). Forcing it true means new + // players drop straight into their dorm with no brand-new nag + // and no exposed auto-username. See RegistrationPatches. + public static bool TreatAccountAsRegistered = true; } public override void OnInitializeMelon() @@ -217,6 +235,25 @@ public class Mod : MelonMod args: new[] { typeof(string) }, prefix: nameof(ChatPatches.RpcChatEmote_Prefix)); + // Skip the first-dorm "Change Username?" registration flow for + // brand-new accounts. DormroomSceneManager raises two dialogs the + // first time a not-fully-registered account enters its dorm — one + // that prints the auto-generated @username and one warning whose + // confirm button doesn't dismiss the modal on this build, leaving + // the player stuck. Both are gated on + // RRUI.Data.NUX.RegistrationModel.IsFullyRegistered(); forcing it + // true makes the watch skip the entire block, so new players land + // in their dorm without the brand-new nag or the exposed + // auto-username. See Cfg.TreatAccountAsRegistered for the ISIL + // evidence. + if (Cfg.TreatAccountAsRegistered) + { + TryPatchByName("RRUI.Data.NUX.RegistrationModel", + "IsFullyRegistered", + args: Type.EmptyTypes, + postfix: nameof(RegistrationPatches.IsFullyRegistered_Postfix)); + } + RegisterJoinTracePatches(); RegisterDiagnostics(); @@ -461,6 +498,7 @@ public class Mod : MelonMod if (r.TryGetProperty("PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId; if (r.TryGetProperty("PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion; if (r.TryGetProperty("EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean(); + if (r.TryGetProperty("TreatAccountAsRegistered", out v)) Cfg.TreatAccountAsRegistered = v.GetBoolean(); // "InjectAuthValues" key in the template is now ignored — // see attic/AuthValuesInjector.cs.attic. Log.Msg($"[config] loaded: ServerHost={Cfg.ServerHost}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "" : "")}, " + @@ -611,7 +649,7 @@ public class Mod : MelonMod { // Look in all three patch holder classes — small enough that a // linear scan is cheaper than per-class lookups. - foreach (var holder in new[] { typeof(UriPatches), typeof(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches) }) + foreach (var holder in new[] { typeof(UriPatches), typeof(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches) }) { var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static); if (m is not null) return m; @@ -1453,6 +1491,31 @@ internal static class ChatPatches } } +// Registration-prompt suppression. See the registration site in +// OnLateInitializeMelon and Cfg.TreatAccountAsRegistered for the full +// rationale and the IL2CPP evidence. +internal static class RegistrationPatches +{ + private static bool s_logged; + + // Postfix on RRUI.Data.NUX.RegistrationModel.IsFullyRegistered(). + // DormroomSceneManager checks this before raising either first-dorm + // "Change Username?" dialog; returning true makes the watch jump past + // the whole prompt block, so a brand-new account never gets the + // username-reveal prompt or the stuck "permanently lost" warning and + // drops straight into its dorm. Players who do want to set a username + // and password can still do it from the profile section of the watch. + public static void IsFullyRegistered_Postfix(ref bool __result) + { + if (!__result && !s_logged) + { + s_logged = true; + Mod.Log.Msg("[registration-fix] RegistrationModel.IsFullyRegistered returned false; forcing true so the first-dorm 'Change Username?' prompt is skipped"); + } + __result = true; + } +} + // Trace hooks for the watch's room-join flow. Registered by // Mod.RegisterJoinTracePatches — see that method for the verified call // chain. Everything here is log-only; no prefix returns false, so the diff --git a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs index 9e071be..54ab8e1 100644 --- a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs +++ b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs @@ -104,7 +104,7 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : .Select(a => new { a.InventoryJson, a.OutfitSelections }) .FirstOrDefaultAsync(); - var owned = ParseInventory(avatar?.InventoryJson); + var owned = ParseInventoryEntries(avatar?.InventoryJson); var ownedHairDyeSlugs = await db.PlayerInventory .Where(i => i.PlayerId == me && i.Quantity > 0) .Select(i => i.ItemSlug) @@ -123,29 +123,48 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : // we want it to drop the OLDEST (starter seed) items rather than the // one the user just bought. var ordered = owned - .Select((guid, idx) => (Guid: guid, OriginalIndex: idx)) - .OrderByDescending(x => equipped.Contains(x.Guid)) - .ThenBy(x => catalog.SlotByGuid.TryGetValue(x.Guid, out var slot) ? slot : int.MaxValue) + .Select((entry, idx) => (Entry: entry, OriginalIndex: idx)) + .OrderByDescending(x => equipped.Contains(x.Entry.BaseGuid)) + .ThenBy(x => catalog.SlotByGuid.TryGetValue(x.Entry.BaseGuid, out var slot) ? slot : int.MaxValue) .ThenByDescending(x => x.OriginalIndex) - .Select(x => x.Guid); + .Select(x => x.Entry); var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count); var perSlot = new Dictionary(); var result = new List(Math.Min(owned.Count, maxItems)); - foreach (var guid in ordered) + foreach (var entry in ordered) { if (result.Count >= maxOutfitItems) break; // Intersect against the safelist. Items not in the bundled // lookup would crash the watch's wardrobe parser, so they're - // silently dropped here. - if (!catalog.Items.TryGetValue(guid, out var item)) continue; + // silently dropped here. The catalog is keyed by the BARE GUID, + // so colored variants (stored as "{guid},{swatch},{mask},") must + // look up by BaseGuid — keying by the full comma string would + // miss and silently drop every owned colored item (the contest + // hat "I bought it but it never shows in my wardrobe" bug). + if (!catalog.Items.TryGetValue(entry.BaseGuid, out var item)) continue; - var slot = catalog.SlotByGuid.TryGetValue(guid, out var value) ? value : -1; + var slot = catalog.SlotByGuid.TryGetValue(entry.BaseGuid, out var value) ? value : -1; perSlot.TryGetValue(slot, out var count); - if (count >= maxPerSlot && !equipped.Contains(guid)) continue; + if (count >= maxPerSlot && !equipped.Contains(entry.BaseGuid)) continue; perSlot[slot] = count + 1; - result.Add(item); + // Preserve the bought color: a bare entry (FullDesc == BaseGuid) + // emits the catalog DTO as-is ("{guid},,,"); a colored entry + // overrides AvatarItemDesc with the stored swatch/mask desc so + // the watch renders the variant the player paid for instead of + // falling back to the default swatch. + result.Add(string.Equals(entry.FullDesc, entry.BaseGuid, StringComparison.OrdinalIgnoreCase) + ? item + : new UnlockedAvatarItemDto + { + AvatarItemType = item.AvatarItemType, + AvatarItemDesc = ToWatchDesc(entry.FullDesc), + PlatformMask = item.PlatformMask, + FriendlyName = item.FriendlyName, + Tooltip = item.Tooltip, + Rarity = item.Rarity, + }); if (result.Count >= maxItems) break; } @@ -248,47 +267,89 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : } } - private static List ParseInventory(string? json) + /// An owned wardrobe entry: the bare AvatarItem GUID used as + /// the catalog / slot / equipped key, plus the full RecNet desc exactly + /// as stored in InventoryJson. For a plain item the two are identical; + /// for a colored variant the desc is "{guid},{swatch},{mask}," so the + /// swatch the player paid for survives the round-trip to the wardrobe. + private readonly record struct OwnedEntry(string BaseGuid, string FullDesc); + + /// Parse InventoryJson into normalized owned entries. Accepts + /// both the plain JSON string array of descs (the shape + /// GrantWardrobeAsync / PurchaseAsync write) and the legacy + /// [{itemId,...}] object shape. Strips a leading "wardrobe-" slug + /// prefix, derives the bare GUID (substring before the first comma) for + /// catalog lookups, and keeps the full desc so a colored variant keeps + /// its swatch. Dedupes by full desc, so multiple colors of the same + /// base item remain distinct wardrobe tiles while exact duplicates + /// collapse. + private static List ParseInventoryEntries(string? json) { - if (string.IsNullOrWhiteSpace(json)) return new(); + if (string.IsNullOrWhiteSpace(json) || json == "[]") return new(); + + var raw = new List(); try { var strings = JsonSerializer.Deserialize>(json); - if (strings is not null) return strings; + if (strings is not null) raw.AddRange(strings); } catch { } - try + if (raw.Count == 0) { - using var doc = JsonDocument.Parse(json); - if (doc.RootElement.ValueKind != JsonValueKind.Array) return new(); - - var result = new List(); - foreach (var element in doc.RootElement.EnumerateArray()) + try { - string? value = null; - if (element.ValueKind == JsonValueKind.String) - value = element.GetString(); - else if (element.ValueKind == JsonValueKind.Object && - element.TryGetProperty("itemId", out var itemId) && - itemId.ValueKind == JsonValueKind.String) - value = itemId.GetString(); - - if (string.IsNullOrWhiteSpace(value)) continue; - const string wardrobePrefix = "wardrobe-"; - if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal)) - value = value[wardrobePrefix.Length..]; - var comma = value.IndexOf(','); - if (comma >= 0) - value = value[..comma]; - result.Add(value); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Array) return new(); + foreach (var element in doc.RootElement.EnumerateArray()) + { + if (element.ValueKind == JsonValueKind.String) + raw.Add(element.GetString() ?? string.Empty); + else if (element.ValueKind == JsonValueKind.Object && + element.TryGetProperty("itemId", out var itemId) && + itemId.ValueKind == JsonValueKind.String) + raw.Add(itemId.GetString() ?? string.Empty); + } } - - return result - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); + catch { return new(); } } - catch { return new(); } + + const string wardrobePrefix = "wardrobe-"; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new List(); + foreach (var entry in raw) + { + var value = entry; + if (string.IsNullOrWhiteSpace(value)) continue; + if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal)) + value = value[wardrobePrefix.Length..]; + var comma = value.IndexOf(','); + var baseGuid = comma >= 0 ? value[..comma] : value; + if (string.IsNullOrWhiteSpace(baseGuid)) continue; + if (!seen.Add(value)) continue; + result.Add(new OwnedEntry(baseGuid, value)); + } + return result; + } + + /// Bare-GUID view of the owned set, for callers that only need + /// ownership membership (e.g. the locked-items diff), not the swatch. + private static List ParseInventory(string? json) => + ParseInventoryEntries(json) + .Select(e => e.BaseGuid) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + /// Ensure a stored desc carries the ≥4 comma-separated parts + /// (guid + 3 customizations) that the watch's + /// AvatarItem.FromRecNetString requires — anything shorter throws + /// inside the wardrobe parser. Stored colored descs are already + /// "{guid},{swatch},{mask}," (3 commas); this pads any shorter legacy + /// value. + private static string ToWatchDesc(string storedDesc) + { + var commas = storedDesc.Count(c => c == ','); + return commas >= 3 ? storedDesc : storedDesc + new string(',', 3 - commas); } private static HashSet ParseEquippedItemGuids(string? outfitSelections) diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index e1d6c24..c2f1e60 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -1953,6 +1953,35 @@ public class AdminController( return Ok(new { p.Id, p.DisplayName }); } + public sealed record UsernameRequest(string Username); + + /// Force-set a player's username (the unique @handle). + /// Unlike the normal account flow this changes ONLY the username, not + /// the display name, and rejects a collision outright rather than + /// auto-suffixing — the admin types the exact handle they want. + /// Username rules mirror the account API: 2–24 chars, letters / + /// digits / underscore / hyphen. + [HttpPost("players/{id:long}/username")] + public async Task SetUsername(long id, [FromBody] UsernameRequest body) + { + var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id); + if (p is null) return NotFound(); + + var name = (body.Username ?? string.Empty).Trim(); + if (name.Length is < 2 or > 24 + || !name.All(ch => char.IsLetterOrDigit(ch) || ch is '_' or '-')) + return BadRequest(new { Error = "Username must be 2–24 chars: letters, numbers, _ or -." }); + + if (await db.Players.AnyAsync(x => x.Username == name && x.Id != id)) + return BadRequest(new { Error = "That username is already taken." }); + + var previous = p.Username; + p.Username = name; + await LogAsync("set_username", "player", id, $"{previous} -> {name}"); + await db.SaveChangesAsync(); + return Ok(new { p.Id, p.Username }); + } + private sealed class InventoryEntry { [System.Text.Json.Serialization.JsonPropertyName("itemId")] diff --git a/DorkNet.Server/Data/CUsersAlexaDocumentsRecnet-december_cvout.txt b/DorkNet.Server/Data/CUsersAlexaDocumentsRecnet-december_cvout.txt new file mode 100644 index 0000000..ab4719a --- /dev/null +++ b/DorkNet.Server/Data/CUsersAlexaDocumentsRecnet-december_cvout.txt @@ -0,0 +1 @@ +matches 239 | unmatched 319 | ambiguous 22 diff --git a/DorkNet.Server/Data/CUsersAlexaDocuments_cv.txt b/DorkNet.Server/Data/CUsersAlexaDocuments_cv.txt new file mode 100644 index 0000000..86d6822 --- /dev/null +++ b/DorkNet.Server/Data/CUsersAlexaDocuments_cv.txt @@ -0,0 +1,7 @@ +Traceback (most recent call last): + File "", line 1, in + import json; d=json.load(open('color_variants.json')); [print(repr(v.get('baseName')),'outfitType=',v.get('outfitType'),'colors=',len(v.get('colors',[]))) for v in d['variants']] + ~^^^^^^^^^^^^ +KeyError: 'variants' +total items 530 +Counter({None: 530}) diff --git a/DorkNet.Server/Data/CUsersAlexaDocuments_lk.txt b/DorkNet.Server/Data/CUsersAlexaDocuments_lk.txt new file mode 100644 index 0000000..0f876f7 --- /dev/null +++ b/DorkNet.Server/Data/CUsersAlexaDocuments_lk.txt @@ -0,0 +1,2 @@ +total 530 +outfitType None = 530 diff --git a/DorkNet.Server/admin-ui/src/pages/Players.tsx b/DorkNet.Server/admin-ui/src/pages/Players.tsx index 947d164..0d50a98 100644 --- a/DorkNet.Server/admin-ui/src/pages/Players.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Players.tsx @@ -457,6 +457,7 @@ function GrantsTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => v function ProfileTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => void }) { const toast = useToast(); const [displayName, setDisplayName] = useState(data.displayName); + const [username, setUsername] = useState(data.username); const [flags, setFlags] = useState({ isVerified: data.isVerified, isDeveloper: data.isDeveloper, @@ -476,6 +477,19 @@ function ProfileTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => return (
+
+

Username

+

The unique @handle. 2–24 chars: letters, numbers, _ or -. Changing it doesn't touch the display name.

+
+ setUsername(e.target.value)} className="input flex-1 font-mono text-xs" /> + +
+
+

Display name

Force-override the player's display name.