diff --git a/DorkNet.Server.Tests/GameClientEndpointContractTests.cs b/DorkNet.Server.Tests/GameClientEndpointContractTests.cs index c08eba1..3cde1df 100644 --- a/DorkNet.Server.Tests/GameClientEndpointContractTests.cs +++ b/DorkNet.Server.Tests/GameClientEndpointContractTests.cs @@ -18,7 +18,9 @@ public sealed class GameClientEndpointContractTests : [Fact] public async Task Game_client_contract_probe_reaches_every_controller_route() { - var routes = EndpointContractDiscovery.Discover(); + var routes = EndpointContractDiscovery.Discover() + .Where(r => !DestroysTheProbeSession(r)) + .ToList(); Assert.NotEmpty(routes); using var client = _factory.CreateClient(new() @@ -48,6 +50,26 @@ public sealed class GameClientEndpointContractTests : + string.Join(Environment.NewLine, failures)); } + /// Routes this probe must not call, because they destroy the very + /// session it authenticates with. + /// + /// The probe walks every route in order using one test player. Account + /// deletion is a genuine, correctly-guarded purge — but once it runs, that + /// player row is gone, so every later probe fails on a foreign-key + /// violation or a 401. Excluding these keeps the failure list meaningful + /// instead of one real event cascading into dozens of phantom failures. + /// + /// This is deliberately narrow: only self-destruct routes belong here, not + /// anything merely inconvenient. + private static bool DestroysTheProbeSession(EndpointContract route) + { + if (!string.Equals(route.Method, "DELETE", StringComparison.OrdinalIgnoreCase)) + return false; + + var template = route.Template.Trim('/'); + return template is "account/me" or "account/v1/me" or "account/v2/me"; + } + private static async Task ProbeAsync( HttpClient client, EndpointContract route, diff --git a/DorkNet.Server/Controllers/API/Comments/CommentsController.cs b/DorkNet.Server/Controllers/API/Comments/CommentsController.cs index 145e37c..54da8c8 100644 --- a/DorkNet.Server/Controllers/API/Comments/CommentsController.cs +++ b/DorkNet.Server/Controllers/API/Comments/CommentsController.cs @@ -1,59 +1,470 @@ +using System.Globalization; using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; namespace DorkNet.Server.Controllers.API.Comments; /// -/// Room-comment unread counts. Client contract (RecNet.Runtime -/// OPELMBNJHNO.DJLNMANKMAA(IReadOnlyCollection<long>)). +/// Room comments ("feedback"/"idea" notes players leave in a room, shown by +/// the watch's RoomCommentsScreen / RoomCommentsPagedGrid). /// -/// The response is a JSON ARRAY, not an object. The method's return -/// type is Dictionary<long,uint>, but that dictionary is built -/// client-side: OPELMBNJHNO.txt:889 constructs a -/// Func<List<UnreadRoomComments>, Dictionary<long,uint>> -/// projection that runs over the deserialised payload. So the wire type is -/// List<RecNet.UnreadRoomComments> — each element an object with a -/// room id (Int64) and a count (UInt32), per the two properties on -/// RecNet/UnreadRoomComments.txt. +/// Every route here is issued by RecNet.Runtime's room-comment service +/// OPELMBNJHNO against service host 12 = RoomComments +/// ( maps that to the roomcomments +/// subdomain). The five literals, with the BNDIAONDFFF ctor verb +/// ordinal moved into rdx immediately before the builder call +/// (BestHTTP.HTTPMethods: 0=GET, 2=POST, 3=PUT, 4=DELETE): /// -/// This previously returned an object map keyed by room id. Json.NET cannot -/// read a JSON object into a List<T>, so every call threw and the -/// client logged "Failed to get unread room comment counts" -/// (OPELMBNJHNO.txt:937) and fell back to an empty dictionary — the watch's -/// room-comment unread badge never appeared. +/// +/// OPELMBNJHNO.txt:485 "comments/get/{0}" — rdx=0 (GET), host [rdx+12] +/// OPELMBNJHNO.txt:811 "comments/unreadcounts" — dynamic GET/POST +/// OPELMBNJHNO.txt:1216 "comments/create/{0}" — rdx=2 (POST), host [r12+12] +/// OPELMBNJHNO.txt:1627 "comments/read/{0}/{1}" — rdx=3 (PUT), host [rbx+12] +/// OPELMBNJHNO.txt:1935 "comments/delete/{0}" — rdx=4 (DELETE), r8=12 +/// /// -/// The element's exact property names are attribute-driven and are not -/// recoverable from the ISIL (see the note on key aliasing below), so each -/// element carries the plausible spellings side by side. Json.NET matches -/// case-insensitively and ignores unknown members, so the extra aliases are -/// inert whichever name the DTO actually declares. -/// -/// Counts are all zero until per-player comment read-state is tracked; the -/// shape is what matters for the client not to throw. +/// STORAGE. There is no RoomCommentEntity and adding entities +/// needs a migration, so comments live in the +/// side-table exactly like the PlayerEvent extras +/// (PlayerEventsController.EventExtras): one row per comment with +/// PlayerId = the author, Key = roomcomment:{roomId}:{commentId} +/// and Value = the JSON payload. Per-player read state is a second, +/// disjoint namespace — roomcommentread:{roomId} holding the last-read +/// comment id — which is what finally lets +/// report real numbers instead of zeroes. /// [ApiController] [Authorize] -public class CommentsController : ControllerBase +public class CommentsController(DorkNetDbContext db) : ControllerBase { + private long Me => this.RequireCurrentPlayerId(); + + /// Key prefix for a stored comment: roomcomment:{roomId}:{commentId}. + private const string CommentPrefix = "roomcomment:"; + + /// Key prefix for per-player read state: roomcommentread:{roomId}, + /// value = last-read comment id. Deliberately a different prefix from + /// (not roomcomment:read:…) so the + /// suffix match used by can never hit a read-state row. + private const string ReadPrefix = "roomcommentread:"; + + /// The RecNet.RoomComment members that are not already + /// implied by the storage row's key/owner. Position is three separate + /// nullable floats on the wire, not a Vector3: the client's + /// Nullable<Vector3> property is [IgnoreDataMember] and is + /// rebuilt after deserialisation from three internal + /// Nullable<float> fields (RecNet/RoomComment.txt:495 + /// FKDDCNLJOLF reads the HasValue byte at [rbx+0x6C], i.e. the + /// first of the three at 0x68/0x70/0x78). + /// + /// Public (not private) so System.Text.Json's reflection-based + /// (de)serialiser can see the type and its accessors. + public sealed class CommentPayload + { + public long? SubRoomId { get; set; } + public string Message { get; set; } = string.Empty; + /// RoomComment.EKHGDABMJOJ: Feedback=0, Idea=1, BugReport=2 + /// (RecNet/RoomComment.cs enum member order). + public int Style { get; set; } + public float? X { get; set; } + public float? Y { get; set; } + public float? Z { get; set; } + public DateTime CreatedAt { get; set; } + } + + // ── Reads ──────────────────────────────────────────────────────────── + + /// Comment list for a room. + /// + /// OPELMBNJHNO.GMBPJHCBPDP(Int64 roomId, Int32 count, Int64 minId) + /// returns FGLDKEJLAKB<List<RecNet.RoomComment>> + /// (OPELMBNJHNO.txt:341), so the body is a bare JSON array — not a + /// paged {Results,TotalResults} container. Verb is GET (rdx=0 at + /// instr 044) and both parameters go on the query string, because + /// BNDIAONDFFF.AFGEDDANEKP only promotes fields to a form body for + /// non-GET verbs: literals "count" (:506) and "minId" (:518). + /// + /// minId is a lower bound on the comment id (the only caller, + /// FeedbackManager.txt:948, passes count=100 and a carried-forward id), so + /// it is applied inclusively and the newest count matches are + /// returned newest-first — which is the order the paged grid renders. + /// + /// Failure toast if this 404s or the body will not parse: + /// "Failed to get room comments for room: {0}" (:537). + [HttpGet("/comments/get/{roomId:long}")] + public async Task Get(long roomId, [FromQuery] int count = 100, [FromQuery] long minId = 0) + { + if (count <= 0) count = 100; + if (count > 500) count = 500; + + var rows = await CommentRowsForRoomAsync(roomId); + + var wire = rows + .Where(r => r.CommentId >= minId) + .OrderByDescending(r => r.CommentId) + .Take(count) + .Select(r => ToWire(r.CommentId, roomId, r.AuthorPlayerId, r.Payload, r.Unread)) + .ToList(); + + return Ok(wire); + } + + /// Room-comment unread counts. Client contract (RecNet.Runtime + /// OPELMBNJHNO.DJLNMANKMAA(IReadOnlyCollection<long>)). + /// + /// The response is a JSON ARRAY, not an object. The method's return + /// type is Dictionary<long,uint>, but that dictionary is built + /// client-side: OPELMBNJHNO.txt:889 constructs a + /// Func<List<UnreadRoomComments>, Dictionary<long,uint>> + /// projection that runs over the deserialised payload. So the wire type is + /// List<RecNet.UnreadRoomComments> — each element an object with a + /// room id (Int64) and a count (UInt32), per the two properties on + /// RecNet/UnreadRoomComments.txt. + /// + /// This previously returned an object map keyed by room id. Json.NET cannot + /// read a JSON object into a List<T>, so every call threw and the + /// client logged "Failed to get unread room comment counts" + /// (OPELMBNJHNO.txt:937) and fell back to an empty dictionary — the watch's + /// room-comment unread badge never appeared. + /// + /// The element's exact property names are attribute-driven and are not + /// recoverable from the ISIL (see the note on key aliasing below), so each + /// element carries the plausible spellings side by side. Json.NET matches + /// case-insensitively and ignores unknown members, so the extra aliases are + /// inert whichever name the DTO actually declares. + /// + /// Counts are real now that persists per-player read + /// state: a room's count is the number of comments authored by somebody + /// else whose id is above the caller's last-read id for that room. + /// [HttpPost("/comments/unreadcounts")] [HttpGet("/comments/unreadcounts")] [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] public async Task UnreadCounts() { var roomIds = await ReadRoomIdsAsync(); + if (roomIds.Count == 0) return Ok(new List>()); + + var me = Me; + var lastRead = await LastReadAsync(me); + + // Key + owner only: the payload column is irrelevant to counting. + var keys = await db.PlayerSettings + .Where(s => s.Key.StartsWith(CommentPrefix)) + .Select(s => new { s.Key, s.PlayerId }) + .ToListAsync(); + + var counts = new Dictionary(); + foreach (var id in roomIds) counts[id] = 0u; + + foreach (var k in keys) + { + if (!TrySplitCommentKey(k.Key, out var roomId, out var commentId)) continue; + if (!counts.ContainsKey(roomId)) continue; + if (k.PlayerId == me) continue; // your own notes are never unread + if (commentId <= lastRead.GetValueOrDefault(roomId)) continue; + counts[roomId]++; + } + var result = roomIds .Select(id => new Dictionary { ["RoomId"] = id, - ["UnreadCount"] = 0u, - ["Count"] = 0u, + ["UnreadCount"] = counts[id], + ["Count"] = counts[id], }) .ToList(); return Ok(result); } + // ── Writes ─────────────────────────────────────────────────────────── + + /// Posts a new comment into a room. + /// + /// OPELMBNJHNO.JHABIKEMJHK(Int64 roomId, Int64 subRoomId, String message, + /// RoomComment/EKHGDABMJOJ style, Nullable<Vector3> position) + /// (OPELMBNJHNO.txt:940) returns FGLDKEJLAKB<RecNet.RoomComment> — + /// a single comment object, not a list and not a status envelope. + /// + /// Verb is POST (rdx=2 at instr 069), so every AFGEDDANEKP field is + /// an application/x-www-form-urlencoded body field, never a query + /// param. Field literals, in emission order: "message" (:1234), + /// "subRoomId" (:1249, boxed Int64), "style" (:1261, boxed UInt32), + /// then — only when the Vector3? has a value (the + /// Compare [rdi+12], r12 / JumpIfEqual at :1268) — "positionX" + /// (:1292), "positionY" (:1314), "positionZ" (:1336), each formatted with + /// Single.ToString(CultureInfo.InvariantCulture) (:1285/1289), which + /// is why the three floats are parsed invariantly here rather than left to + /// model binding. + /// + /// Failure toast: "Failed to Create Comment" (:1359). + [HttpPost("/comments/create/{roomId:long}")] + public async Task Create( + long roomId, + [FromForm] string? message, + [FromForm] long? subRoomId, + [FromForm] int style = 0, + [FromForm] string? positionX = null, + [FromForm] string? positionY = null, + [FromForm] string? positionZ = null) + { + if (!await db.Rooms.AnyAsync(r => r.Id == roomId)) return NotFound(); + + message = (message ?? string.Empty).Trim(); + if (message.Length == 0) return BadRequest(); + // PlayerSettingEntity.Value is MaxLength(1024) and also carries the + // rest of the payload; keep well clear of the column width. + if (message.Length > 700) message = message[..700]; + + var payload = new CommentPayload + { + SubRoomId = subRoomId is > 0 ? subRoomId : null, + Message = message, + Style = style, + X = ParseFloat(positionX), + Y = ParseFloat(positionY), + Z = ParseFloat(positionZ), + CreatedAt = DateTime.UtcNow, + }; + // The client only ever sends all three or none; drop a partial set so + // the client's Vector3 reassembly (RoomComment.FKDDCNLJOLF) sees a + // consistent null. + if (payload.X is null || payload.Y is null || payload.Z is null) + payload.X = payload.Y = payload.Z = null; + + var me = Me; + var commentId = await NextCommentIdAsync(); + + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = me, + Key = $"{CommentPrefix}{roomId}:{commentId}", + Value = JsonSerializer.Serialize(payload), + }); + await db.SaveChangesAsync(); + + // Authoring a comment implies you have read up to it. + await SetLastReadAsync(me, roomId, commentId); + await db.SaveChangesAsync(); + + return Ok(ToWire(commentId, roomId, me, payload, unread: false)); + } + + /// Records the newest comment the caller has read in a room, which + /// is what clears the watch's unread badge (and what makes + /// return non-zero numbers for everyone else). + /// + /// OPELMBNJHNO.MCOIDIBFHIP(Int64 roomId, Int64 commentId) + /// (OPELMBNJHNO.txt:1375) returns the status-only LDGADANDBIO, not a + /// DTO — the body is never parsed, only the status code. Verb is PUT + /// (rdx=3 at instr 059, OPELMBNJHNO.txt:1637); both ids are path segments + /// formatted into "comments/read/{0}/{1}" (:1627) and no field is added, so + /// the request carries no body at all. + /// + /// Failure toast: "Failed to update latest read comment" (:1656). + [HttpPut("/comments/read/{roomId:long}/{commentId:long}")] + public async Task MarkRead(long roomId, long commentId) + { + await SetLastReadAsync(Me, roomId, commentId); + await db.SaveChangesAsync(); + return Ok(); + } + + /// Deletes a comment. + /// + /// OPELMBNJHNO.NDLOEEHIDED(Int64 commentId) (OPELMBNJHNO.txt:1781) + /// returns the status-only LDGADANDBIO. Verb is DELETE + /// (rdx=4 at instr 046) and the host ordinal is the only immediate on the + /// builder call (r8=12, RoomComments) — see OPELMBNJHNO.txt:1944-1946. + /// No fields are attached: the comment id is the whole request. + /// + /// Because the route carries no room id, the storage key is matched by its + /// :{commentId} suffix; read-state rows live under a different + /// prefix precisely so they cannot collide here. Only the author, the room + /// creator, or an accepted co-owner/moderator of the room may delete — + /// anyone else gets 403 and the client raises its "Failed to delete + /// comment" toast (:1962). + [HttpDelete("/comments/delete/{commentId:long}")] + public async Task Delete(long commentId) + { + var suffix = ":" + commentId.ToString(CultureInfo.InvariantCulture); + var row = await db.PlayerSettings + .Where(s => s.Key.StartsWith(CommentPrefix) && s.Key.EndsWith(suffix)) + .FirstOrDefaultAsync(); + if (row is null) return NotFound(); + if (!TrySplitCommentKey(row.Key, out var roomId, out _)) return NotFound(); + + var me = Me; + if (row.PlayerId != me && !await CanModerateRoomAsync(roomId, me)) return Forbid(); + + db.PlayerSettings.Remove(row); + await db.SaveChangesAsync(); + return Ok(); + } + + // ── Wire shape ─────────────────────────────────────────────────────── + + /// One RecNet.RoomComment. Member list and CLR types are + /// exact (RecNet/RoomComment.cs, cross-checked against the 2023.06.21 + /// dump.cs:1241576): long comment id, long room id, + /// long? sub-room id, int? account id, DateTime + /// created-at, string message, the EKHGDABMJOJ style enum, a + /// settable bool, three internal float? position components, + /// and one computed get-only bool that is never on the wire. + /// + /// KEY NAMES. The 2023 DTOs' JSON names live in DataMember attribute blobs + /// in global-metadata.dat and are not recoverable from Cpp2IL output, so + /// the names below are taken from the client's own request field + /// literals for the same values — "message", "subRoomId", "style", + /// "positionX", "positionY", "positionZ" (OPELMBNJHNO.txt:1234-1336) — + /// which mirror the server DTO they were serialised from. Json.NET matches + /// case-insensitively and ignores unknown members, so the aliases for the + /// two members with no request-side counterpart are inert. + /// + /// The settable bool is the unread flag: its only mutator + /// RoomComment.MCCJIBCJEJC clears the byte at [rcx+0x64] and + /// fires the change event (RoomComment.txt:471-493), and the computed + /// get-only bool FOOFBPEMFCB is AccountId == localAccountId || + /// !flag (:442-466, comparing against + /// OPMAPIOEIFG.GGAEMNHEHLO). Its declared name is unknown, hence the + /// three non-inverting spellings. + private static Dictionary ToWire( + long commentId, long roomId, long authorPlayerId, CommentPayload p, bool unread) => new(StringComparer.Ordinal) + { + ["CommentId"] = commentId, + ["RoomId"] = roomId, + ["SubRoomId"] = p.SubRoomId, + ["AccountId"] = (int)authorPlayerId, + ["CreatedAt"] = p.CreatedAt, + ["Message"] = p.Message, + ["Style"] = p.Style, + ["PositionX"] = p.X, + ["PositionY"] = p.Y, + ["PositionZ"] = p.Z, + ["IsUnread"] = unread, + ["Unread"] = unread, + ["IsNew"] = unread, + }; + + // ── Storage helpers ────────────────────────────────────────────────── + + private sealed record StoredComment(long CommentId, long AuthorPlayerId, CommentPayload Payload, bool Unread); + + /// Every stored comment for a room, with the caller's unread flag + /// resolved against their read-state row. + private async Task> CommentRowsForRoomAsync(long roomId) + { + var me = Me; + var prefix = $"{CommentPrefix}{roomId}:"; + var rows = await db.PlayerSettings + .Where(s => s.Key.StartsWith(prefix)) + .Select(s => new { s.Key, s.PlayerId, s.Value }) + .ToListAsync(); + + var lastRead = await LastReadAsync(me); + var seen = lastRead.GetValueOrDefault(roomId); + + var list = new List(rows.Count); + foreach (var r in rows) + { + if (!long.TryParse(r.Key[prefix.Length..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var commentId)) + continue; + CommentPayload? payload; + try { payload = JsonSerializer.Deserialize(r.Value); } + catch (JsonException) { continue; } + if (payload is null) continue; + list.Add(new StoredComment(commentId, r.PlayerId, payload, r.PlayerId != me && commentId > seen)); + } + return list; + } + + /// Comment ids are handed out from the wall clock in milliseconds + /// so they stay globally unique and monotonically increasing across + /// restarts — the client's minId filter and the unread comparison + /// both rely on "higher id == newer". + private async Task NextCommentIdAsync() + { + var id = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + for (var attempt = 0; attempt < 64; attempt++) + { + var suffix = ":" + id.ToString(CultureInfo.InvariantCulture); + var taken = await db.PlayerSettings + .AnyAsync(s => s.Key.StartsWith(CommentPrefix) && s.Key.EndsWith(suffix)); + if (!taken) break; + id++; + } + return id; + } + + /// Last-read comment id per room for one player. + private async Task> LastReadAsync(long playerId) + { + var rows = await db.PlayerSettings + .Where(s => s.PlayerId == playerId && s.Key.StartsWith(ReadPrefix)) + .Select(s => new { s.Key, s.Value }) + .ToListAsync(); + + var map = new Dictionary(); + foreach (var r in rows) + { + if (!long.TryParse(r.Key[ReadPrefix.Length..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var roomId)) continue; + if (!long.TryParse(r.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var commentId)) continue; + if (!map.TryGetValue(roomId, out var cur) || commentId > cur) map[roomId] = commentId; + } + return map; + } + + /// Read state only ever moves forward — the watch re-sends older + /// ids as the grid scrolls back up. + private async Task SetLastReadAsync(long playerId, long roomId, long commentId) + { + var key = $"{ReadPrefix}{roomId}"; + var row = await db.PlayerSettings.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key); + if (row is null) + { + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = playerId, + Key = key, + Value = commentId.ToString(CultureInfo.InvariantCulture), + }); + return; + } + if (long.TryParse(row.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var cur) && cur >= commentId) return; + row.Value = commentId.ToString(CultureInfo.InvariantCulture); + } + + /// Room creator, or an accepted co-owner (Role 0) / moderator + /// (Role 1) — the same grant model + /// documents. + private async Task CanModerateRoomAsync(long roomId, long playerId) + { + if (await db.Rooms.AnyAsync(r => r.Id == roomId && r.CreatorPlayerId == playerId)) return true; + return await db.RoomRoles.AnyAsync(r => + r.RoomId == roomId && r.PlayerId == playerId && r.Accepted && (r.Role == 0 || r.Role == 1)); + } + + /// Splits roomcomment:{roomId}:{commentId}. + private static bool TrySplitCommentKey(string key, out long roomId, out long commentId) + { + roomId = 0; + commentId = 0; + if (!key.StartsWith(CommentPrefix, StringComparison.Ordinal)) return false; + var rest = key[CommentPrefix.Length..]; + var sep = rest.IndexOf(':'); + if (sep <= 0) return false; + return long.TryParse(rest[..sep], NumberStyles.Integer, CultureInfo.InvariantCulture, out roomId) + && long.TryParse(rest[(sep + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out commentId); + } + + private static float? ParseFloat(string? s) => + float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null; + private async Task> ReadRoomIdsAsync() { var ids = new HashSet(); diff --git a/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs b/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs index a3f4fff..23343f6 100644 --- a/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs +++ b/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Globalization; using System.Security.Cryptography; using System.Text.Json; using DorkNet.Server.Auth; @@ -109,6 +110,109 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas public IActionResult Ps5RecRoomPlusEnabledForAllPlayers() => Content("true", "application/json"); + /// The 2023-03-21 client POSTs this — the request is built at + /// RecNet.Runtime/GAAPALMODOL.txt:104-106, where the route literal + /// "api/AppIntegrity/v1/iosproducts" goes into r9 and the + /// verb register rdx=2 = BestHTTP.HTTPMethods.Post (host + /// r8=1). The payload is a RAW JSON body — the serialized list is + /// handed to BNDIAONDFFF.FJLLPHFOOJJ (:138), not the + /// form-parameter helper — holding + /// List<GAAPALMODOL/KOLPHINEHDD>, i.e. a top-level array of + /// {Name:String, Price:Single, ProductId:String} (getters at + /// GAAPALMODOL_NestedType_KOLPHINEHDD.txt:3/25/45; formatter key table, + /// which also accepts camel/lower spellings, at KAONMCEONPF.txt:255-306). + /// The issuing method's return type is + /// FGLDKEJLAKB<PHMHCPEMABG> (:3) and PHMHCPEMABG is + /// {Boolean, String} (PHMHCPEMABG.txt:3/23) keyed + /// Success/Message (GBPDOLJBABB.txt:191-218). + /// + /// + /// registers the same + /// path GET-only, so the client's POST hit a 405 and the StoreKit + /// price report was dropped. Registering only the POST verb here leaves + /// that GET (and its consumers) untouched — the two never collide + /// because the method constraints are disjoint. + /// + /// The reported catalogue is what Apple's StoreKit resolved on the + /// device, so it is inherently per-account/per-storefront: it is + /// persisted as the reporting player's appintegrity:iosproducts + /// setting (same PlayerSettings convention as the ban-appeal handler + /// above), replacing any earlier report. Only a malformed body answers + /// Success=false; the client shows nothing on success and PHMHCPEMABG's + /// Message is left empty. + [HttpPost("api/AppIntegrity/v1/iosproducts")] + [Authorize] + public async Task IosProducts() + { + var pid = this.RequireCurrentPlayerId(); + + List? products; + try + { + products = await JsonSerializer.DeserializeAsync>( + Request.Body, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (JsonException) + { + return IosProductsResult(false, "malformed_product_list"); + } + + if (products is null) return IosProductsResult(false, "malformed_product_list"); + + // "{productId}={price}" pairs rather than the raw JSON: the + // localized Name adds nothing a server-side reader needs and + // PlayerSettingEntity.Value caps at 1024 chars, so entries are + // dropped (never truncated mid-pair) once the budget is spent. + var parts = new List(); + var length = 0; + foreach (var p in products) + { + var productId = p.ProductId?.Trim(); + if (string.IsNullOrEmpty(productId)) continue; + var pair = $"{productId}={p.Price.ToString(CultureInfo.InvariantCulture)}"; + var cost = parts.Count == 0 ? pair.Length : pair.Length + 1; + if (length + cost > 1024) break; + parts.Add(pair); + length += cost; + } + + const string key = "appintegrity:iosproducts"; + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == pid && s.Key == key); + if (row is null) + { + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = pid, + Key = key, + Value = string.Join(';', parts), + }); + } + else + { + row.Value = string.Join(';', parts); + } + await db.SaveChangesAsync(); + + return IosProductsResult(true, string.Empty); + } + + /// PHMHCPEMABG accepts PascalCase, camelCase and all-lowercase + /// key spellings (GBPDOLJBABB.txt:191-218), so either casing alone would + /// satisfy it; both are emitted for the same reason the CampusCard + /// payload above does. Dictionary keys go out verbatim — the global + /// serializer sets PropertyNamingPolicy = null + /// (Startup/ServiceCollectionExtensions.cs:381). + private IActionResult IosProductsResult(bool success, string message) + => Ok(new Dictionary + { + ["Success"] = success, + ["Message"] = message, + ["success"] = success, + ["message"] = message, + }); + [HttpPost("api/clubreporting/v1/report")] [Authorize] [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] @@ -192,6 +296,18 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas public long PlatformAccountSubscribedPlayerId { get; set; } } + /// One entry of the client's raw JSON array — + /// GAAPALMODOL/KOLPHINEHDD, whose three properties are String, Single, + /// String in that field order (offsets 0x10/0x18/0x20 in + /// GAAPALMODOL_NestedType_KOLPHINEHDD.txt) and whose formatter writes + /// the keys Name / Price / ProductId (KAONMCEONPF.txt:321-360). + private sealed class IosProduct + { + public string? Name { get; set; } + public float Price { get; set; } + public string? ProductId { get; set; } + } + private sealed class ClubReportRequest { public long ClubId { get; set; } diff --git a/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs b/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs index e357091..2d2c93d 100644 --- a/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs +++ b/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs @@ -590,6 +590,236 @@ public class CustomAvatarItemsController( return Ok(new { Success = true, Balance = newBalance, Item = ToWire(item) }); } + /// POST api/customAvatarItems/v1/{id}/report — file a moderation + /// report against another player's custom shirt. No report route was + /// registered anywhere on the server, so the report dialog's "Submit" + /// button 404'd and the client toasted "Unable to report custom avatar + /// item {0}". + /// + /// Verb ordinal dl=2 = POST, on the URL + /// String.Format("{0}/v1/{1}/report") over "api/customAvatarItems" + /// (RecNet.Runtime/MPBLNLMCEDL.txt:4261-4275; the failure literal is + /// :4301). The body is RAW JSON — JsonConvert.SerializeObject of a + /// JEHCPEEKMML handed straight to BNDIAONDFFF.FJLLPHFOOJJ (:4252-4256, + /// :4282), NOT form-urlencoded — so the body is read here instead of + /// model-bound, exactly like PUT {id} above. + /// + /// The issuing method IALGIAGBJMA (:4079) returns LDGADANDBIO — status + /// only. Its continuation chain (0x181F2F300 → 0x1836E6740 → + /// BFDGEKKNAPB.PEFKBAEAOPD, :4285-4294) is byte-for-byte the one the + /// DELETE {id} path uses (:2206-2215), and that path is satisfied by a + /// bare 200, so an empty 200 is the whole response contract here too. + /// + /// JEHCPEEKMML has exactly three members, pinned by field offset from the + /// accessor bodies (JEHCPEEKMML.txt:6/86/108) and by the arg-store + /// sequence that fills it (:4239-4247): +0x10 the reason enum, +0x18 the + /// free-text detail, +0x20 a Nullable<Int32>. Their JSON KEY names + /// live in attribute metadata Cpp2IL doesn't render — obfuscated in the + /// 2023.06 dump too (EFPKFBKGLCD, dump.cs:1256458-1256469) — so each is + /// matched against an alias list, with a positional fallback in + /// declaration order for anything unrecognised (Newtonsoft writes members + /// in declaration order and the DTO has exactly these three). + /// + /// The reason enum IS readable: LGEPGEHKNAG.KFCMACCLDEJ in the 2023.06 + /// dump (dump.cs:1250039-1250044) — CoC_Discriminatory=0, CoC_Sexual=1, + /// CoC_Trolling=2, Misleading=3, Other=4, InappropriateClothing=5. That is + /// a DIFFERENT numbering from 's + /// RoomieReportCategory convention (Harassment=1 … Other=5), so the + /// ordinal is stored verbatim and the readable name is written into the + /// message — otherwise a moderator reading the shared queue cannot tell a + /// CoC_Sexual=1 clothing report from a Harassment=1 player report. + /// + /// has no custom-avatar-item column and adding + /// one is a migration, so the reported item's guid is carried in the + /// stored text — the same thing the sibling screen-share report handler + /// does with its image/instance evidence. That marker prefix is also what + /// makes the duplicate check below work. + [HttpPost("{id:guid}/report")] + [Authorize] + public async Task Report(Guid id) + { + var pid = this.RequireCurrentPlayerId(); + var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id); + if (item is null) return NotFound(); + // The report button only renders on someone else's item, so this is an + // impossible UI state; reject rather than pollute the queue. + if (item.CreatorPlayerId == pid) return BadRequest(new { error = "cannot_report_own_item" }); + + var req = await ReadReportRequestAsync(); + // The dialog refuses to submit without a category ("Cannot report + // custom clothing without a report category." — + // Assembly-CSharp/RRUI/Data/CustomAvatarItemReportModel.txt:1941), so + // a missing reason means a non-stock caller; bucket it as Other=4. + var reason = req.Reason ?? 4; + + var context = new List { $"reason={ReportReasonName(reason)}({reason})" }; + // The only in-game caller passes null for the Nullable + // (CustomAvatarItemReportModel.txt:2008/2011 push rbx=0 for both it and + // the trailing bool), so its meaning is unproven — keep whatever a + // client does send rather than silently dropping evidence. + if (req.Context is { } ctx) context.Add($"ctx={ctx}"); + + var details = (req.Details ?? string.Empty).Trim(); + var message = $"{ReportMarker(id)}{string.Join(' ', context)}] {details}".Trim(); + if (message.Length > 1000) message = message[..1000]; + + // One open report per (reporter, item): the dialog is re-openable and + // the client retries on a failed submit, so without this a single + // annoyed player can flood the moderation queue. + var marker = ReportMarker(id); + var duplicate = await db.Reports.AnyAsync(r => + r.ReporterPlayerId == pid && r.ResolvedAt == null && r.Message.StartsWith(marker)); + if (duplicate) + { + logger.LogInformation( + "[customAvatarItems] duplicate report of {PublicId} by {Pid} ignored", id, pid); + return Ok(); + } + + db.Reports.Add(new ReportEntity + { + ReporterPlayerId = pid, + TargetPlayerId = item.CreatorPlayerId, + Category = reason, + Message = message, + }); + await db.SaveChangesAsync(); + logger.LogInformation( + "[customAvatarItems] reported {PublicId} (creator {Creator}) by {Pid} reason={Reason}", + id, item.CreatorPlayerId, pid, ReportReasonName(reason)); + return Ok(); + } + + /// Prefix every stored custom-item report with the item guid so + /// the row is attributable without a dedicated column, and so the + /// duplicate check can find prior reports with a plain LIKE. + private static string ReportMarker(Guid id) => $"[customAvatarItem {id:D} "; + + /// LGEPGEHKNAG.KFCMACCLDEJ, verbatim from the 2023.06 dump + /// (dump.cs:1250039-1250044). Index IS the wire ordinal. + private static readonly string[] ReportReasonNames = + { + "CoC_Discriminatory", "CoC_Sexual", "CoC_Trolling", + "Misleading", "Other", "InappropriateClothing", + }; + + private static string ReportReasonName(int reason) => + reason >= 0 && reason < ReportReasonNames.Length ? ReportReasonNames[reason] : "Unknown"; + + /// Read the report body. The stock client sends raw JSON, but + /// form encodings are accepted too so an admin/tooling caller can post the + /// same three fields flat. + private async Task ReadReportRequestAsync() + { + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + string? Field(params string[] keys) + { + foreach (var key in keys) + if (form[key].FirstOrDefault() is { } v) return v; + return null; + } + + return new ReportRequest + { + Reason = ParseReportReason(Field("reason", "Reason", "reportReason", "ReportReason", + "category", "Category", "reportCategory", "ReportCategory", "type", "Type")), + Details = Field("details", "Details", "description", "Description", + "message", "Message", "comment", "Comment"), + Context = int.TryParse( + Field("contextId", "ContextId", "instanceId", "InstanceId", + "roomInstanceId", "RoomInstanceId", "roomId", "RoomId", + "accountId", "AccountId", "playerId", "PlayerId"), + out var ctx) + ? ctx + : null, + }; + } + + using var reader = new StreamReader(Request.Body); + return ParseReportJson(await reader.ReadToEndAsync()); + } + + /// Lenient JSON → . Alias-matched + /// first; anything left over is assigned positionally in JEHCPEEKMML's + /// declaration order (reason, detail, nullable int) so an unguessed key + /// spelling still lands in the right slot. + private static ReportRequest ParseReportJson(string json) + { + var req = new ReportRequest(); + if (string.IsNullOrWhiteSpace(json)) return req; + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return req; + + var unmatched = new List(); + foreach (var prop in doc.RootElement.EnumerateObject()) + { + switch (prop.Name.ToLowerInvariant()) + { + case "reason" or "reportreason" or "category" or "reportcategory" or "type" or "reporttype": + req.Reason ??= ParseReportReason(prop.Value); + break; + case "details" or "detail" or "description" or "reportdescription" + or "message" or "comment" or "text": + req.Details ??= AsString(prop.Value); + break; + case "contextid" or "instanceid" or "roominstanceid" or "roomid" + or "accountid" or "playerid" or "reportedplayerid": + req.Context ??= AsInt(prop.Value); + break; + default: + unmatched.Add(prop.Value); + break; + } + } + + foreach (var value in unmatched) + { + if (req.Reason is null && ParseReportReason(value) is { } reason) + { + req.Reason = reason; + continue; + } + + if (req.Details is null && value.ValueKind == JsonValueKind.String) + { + req.Details = value.GetString(); + continue; + } + + req.Context ??= AsInt(value); + } + } + catch (JsonException) + { + } + + return req; + } + + /// Accept the reason as an ordinal or as one of the enum's own + /// names — Newtonsoft writes an int by default, but a StringEnumConverter + /// on the DTO (an attribute the dump doesn't render) would write the name. + private static int? ParseReportReason(JsonElement value) => value.ValueKind switch + { + JsonValueKind.Number => value.TryGetInt32(out var n) ? n : null, + JsonValueKind.String => ParseReportReason(value.GetString()), + _ => null, + }; + + private static int? ParseReportReason(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + if (int.TryParse(value, out var ordinal)) return ordinal; + var index = Array.FindIndex( + ReportReasonNames, + n => string.Equals(n, value, StringComparison.OrdinalIgnoreCase)); + return index >= 0 ? index : null; + } + private async Task EnsureOwnershipAsync(long playerId, long itemId) { if (await db.CustomAvatarItemOwnership.AnyAsync(o => @@ -971,6 +1201,18 @@ public class CustomAvatarItemsController( public bool Deleted { get; set; } } + /// The three members of the client's report DTO JEHCPEEKMML, + /// in its declaration order: the reason enum (+0x10), the free-text + /// detail (+0x18) and an unidentified Nullable<Int32> (+0x20) that + /// the stock report dialog always leaves null. All nullable so + /// "not sent" is distinguishable from "sent empty". + public sealed class ReportRequest + { + public int? Reason { get; set; } + public string? Details { get; set; } + public int? Context { get; set; } + } + public sealed class DesignRequest { public Guid? Id { get; set; } diff --git a/DorkNet.Server/Controllers/API/Images/ImagesController.cs b/DorkNet.Server/Controllers/API/Images/ImagesController.cs index f963f81..221d0ff 100644 --- a/DorkNet.Server/Controllers/API/Images/ImagesController.cs +++ b/DorkNet.Server/Controllers/API/Images/ImagesController.cs @@ -322,6 +322,41 @@ public class ImagesController( return Ok(BuildImageInfo(photo, photo.UploaderPlayerId)); } + /// GET api/images/v6/{id} — single photo by numeric id. + /// The 2023-03-21 client builds the path with + /// String.Format("{0}v6/{1}", "api/images/", id) + /// (KLJOGJHBONK.txt:2694-2698) and passes verb 0 = GET into the + /// request-builder ctor (:2702 Move rdx, 0 → :2708 Call + /// 0x1830036A0); no query fields are attached. + /// + /// The issuing method KLJOGJHBONK.HLANOFILAEO(System.Int64) returns + /// FGLDKEJLAKB<IReadOnlyList<Int32>>, but that is a + /// CLIENT-SIDE projection: a + /// Func<LGLCPNPJCEC, IReadOnlyList<Int32>> + /// (:2756) reduces the wire object to its tagged-player ids. So the + /// body must be ONE LGLCPNPJCEC object — an array breaks the reader — + /// with the 12 keys its generated serializer registers + /// (IBILPLGNAJE.txt:819-1078: Id, ImageName, PlayerId, RoomId, + /// PlayerEventId, Accessibility, AccessibilityLocked, Type, CreatedAt, + /// TaggedPlayerIds, CheerCount, CommentCount), all of which + /// already emits. + /// + /// Without this route the request fell through to + /// api/images/{*path}, which rejects "123" as a non-image filename → + /// 404 on every photo-detail open. Visibility matches the by-name lookup: + /// public photos to anyone, private ones only to their uploader. + [HttpGet("api/images/v6/{id:long}")] + [AllowAnonymous] + public async Task ImageByIdV6(long id) + { + var pid = this.CurrentPlayerId(); + var photo = await db.Photos.FirstOrDefaultAsync(p => + p.Id == id && p.DeletedAt == null && + (p.IsPublic || (pid != null && p.UploaderPlayerId == pid.Value))); + if (photo is null) return NotFound(); + return Ok(BuildImageInfo(photo, photo.UploaderPlayerId)); + } + /// GET api/images/v2/named — the watch's /// Images.DownloadNamedImageMappings endpoint. Returns a /// list of NamedImageDTO entries; an empty list is a valid @@ -522,6 +557,51 @@ public class ImagesController( return Ok(new { deleted = id }); } + /// POST api/images/v1/{id}/report — file a moderation + /// report against a photo from the in-game photo viewer. + /// KLJOGJHBONK.IDAJEMBKAGF(System.Int64) formats the path as + /// String.Format("{0}v1/{1}/report", "api/images/", id) + /// (KLJOGJHBONK.txt:4010-4014) and moves verb 2 = POST into the + /// builder ctor (:4021 Move rdx, 2, host 1 at :4020). + /// + /// The request carries NO fields and NO body — no category, no free text — + /// and the reply is dispatched through BNDIAONDFFF.KDOPJCNKOOK + /// (:4031), which never deserialises it: the call is + /// fire-and-forget, so the client cannot tell a 404 from a 200 and every + /// report was being dropped silently. Everything actionable therefore has + /// to be derived server-side from the photo id, which is why the reported + /// photo's uploader becomes the report target and the photo's room the + /// context. Category 5 = "Other", matching the screenshare reporter's + /// default for reports that arrive without one. + [HttpPost("api/images/v1/{id:long}/report")] + [Authorize] + public async Task ReportPhoto(long id) + { + var reporter = this.RequireCurrentPlayerId(); + var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id && p.DeletedAt == null); + if (photo is null) return NotFound(); + + // ReportEntity has no TargetPhotoId column, so the photo id + blob name + // ride in the message text the same way club reports carry "[club {id}]" + // — a moderator needs the actual image to act on the report. + var message = $"[photo {photo.Id} {photo.BlobName}] reported from the in-game photo viewer"; + db.Reports.Add(new ReportEntity + { + ReporterPlayerId = reporter, + TargetPlayerId = photo.UploaderPlayerId, + RoomId = photo.RoomId, + Category = 5, + Message = message[..Math.Min(1000, message.Length)], + }); + await db.SaveChangesAsync(); + + logger.LogInformation( + "[images] photo {PhotoId} reported by {Reporter} (uploader {Uploader}, room {RoomId})", + photo.Id, reporter, photo.UploaderPlayerId, photo.RoomId); + + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + // ── Legacy v1 ops (real persistence, was Ack-only stubs) ───────── public sealed class CheerImageRequest @@ -530,23 +610,48 @@ public class ImagesController( public long PhotoId { get; set; } } - public sealed class BulkCheeredRequest - { - public List? SavedImageIds { get; set; } - public List? ImageIds { get; set; } - } + /// Field names the cheer-state batch accepts. The 2023-03-21 + /// client sends the singular "id" + /// (KLJOGJHBONK.txt:3763 Move rdx, "id"); the older spellings are + /// kept so the 2020.12 watch and our own tooling keep working. + private static readonly string[] CheerBulkIdKeys = + { "id", "ids", "SavedImageId", "SavedImageIds", "ImageId", "ImageIds" }; + /// GET — and POST once the id list reaches 100 — for + /// api/images/v{4,5}/cheered/bulk: the caller's cheer state for a + /// batch of photos (the hearts on the photo feed). + /// + /// VERB: the client picks it at runtime. + /// KLJOGJHBONK.JCLGICHPPGB(IEnumerable<Int64>) calls + /// ALHIJCJOLCB.JIECAFGCODK(count, 100) and moves the result straight + /// into the request-builder's verb register + /// (KLJOGJHBONK.txt:3747 Call ALHIJCJOLCB.JIECAFGCODK → + /// :3755 Move rdx, rdi → :3753 Move r9, + /// "api/images/v5/cheered/bulk") — GET (0) for short lists, POST (2) for + /// long ones. Registering POST only meant the common case fell into the + /// api/images/{*path} byte catch-all and 404'd, so cheer state never + /// loaded. + /// + /// REQUEST: one field, "id", added via + /// BNDIAONDFFF.AFGEDDANEKP("id", ids) (KLJOGJHBONK.txt:3763). + /// The builder emits fields as query values on GET and form values on POST — + /// never as a JSON body — so the ids are read straight off the request + /// instead of through [FromBody], which would 415 the form POST + /// before the action ran. + /// + /// RESPONSE: List<LAKGLIDCEDE>, i.e. + /// [{"SavedImageId":Int64,"IsCheered":Boolean}] — key literals in the + /// generated serializer at KJEIGGBIIHP.txt:215,242. One entry per + /// requested id so the client's cache has no holes. + [HttpGet("api/images/v4/cheered/bulk")] + [HttpGet("api/images/v5/cheered/bulk")] [HttpPost("api/images/v4/cheered/bulk")] [HttpPost("api/images/v5/cheered/bulk")] [Authorize] - public async Task CheeredBulk([FromBody] BulkCheeredRequest? body) + public async Task CheeredBulk() { var pid = this.RequireCurrentPlayerId(); - var ids = (body?.SavedImageIds ?? body?.ImageIds ?? new()) - .Where(id => id > 0) - .Distinct() - .Take(200) - .ToList(); + var ids = await ReadCheerBulkIdsAsync(); if (ids.Count == 0) return Ok(Array.Empty()); var cheered = await db.Cheers @@ -561,6 +666,82 @@ public class ImagesController( })); } + /// Collect the cheer-state batch ids from wherever this client + /// put them: query values on the GET path, form values on the POST path + /// (both produced by BNDIAONDFFF's field list), or a JSON body for + /// non-game callers. Repeated fields and comma-joined values are both + /// accepted because the builder's query assembler emits one + /// &id= per element while our own admin tooling sends a single + /// comma-separated value. + private async Task> ReadCheerBulkIdsAsync() + { + var ids = new List(); + + void Collect(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return; + foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + if (long.TryParse(part, out var id) && id > 0) ids.Add(id); + } + + foreach (var key in CheerBulkIdKeys) + foreach (var value in Request.Query[key]) + Collect(value); + + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var key in CheerBulkIdKeys) + foreach (var value in form[key]) + Collect(value); + } + else if (Request.ContentLength is > 0) + { + try + { + Request.EnableBuffering(); + Request.Body.Position = 0; + using var doc = await JsonDocument.ParseAsync(Request.Body, cancellationToken: HttpContext.RequestAborted); + Request.Body.Position = 0; + CollectJsonIds(doc.RootElement, Collect); + } + catch (JsonException) + { + // Not JSON — the query/form pass above is authoritative. + } + } + + return ids.Distinct().Take(500).ToList(); + } + + /// Pull ids out of a JSON body that is either a bare array of + /// numbers or an object keyed by one of . + private static void CollectJsonIds(JsonElement root, Action collect) + { + switch (root.ValueKind) + { + case JsonValueKind.Array: + foreach (var item in root.EnumerateArray()) CollectJsonIds(item, collect); + break; + case JsonValueKind.Object: + foreach (var key in CheerBulkIdKeys) + { + foreach (var prop in root.EnumerateObject()) + { + if (!string.Equals(prop.Name, key, StringComparison.OrdinalIgnoreCase)) continue; + CollectJsonIds(prop.Value, collect); + } + } + break; + case JsonValueKind.Number: + collect(root.GetRawText()); + break; + case JsonValueKind.String: + collect(root.GetString()); + break; + } + } + /// POST api/images/v1/cheer — cheer a photo. /// Was previously Ack-only; now persists a CheerEntity row /// keyed on TargetPhotoId + bumps Photos.CheerCount. diff --git a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs index f885950..bfc06a2 100644 --- a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs +++ b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs @@ -1144,6 +1144,222 @@ public class InventionsController( return Ok(new { Success = true, Message = string.Empty }); } + // ── Rec Room Studio remote run ─────────────────────────────────────── + + /// One uploaded blob as the client describes it: three strings. + /// Wire keys are literal in the generated serializer — "Filename", "Hash", + /// "OwnershipProof" (FLELPHJDLNG.txt:255/274/290, with camelCase and + /// all-lowercase aliases registered alongside each). The backing type is + /// CFPEPOJAMHH/IFIBBJDIHJH, three System.String accessors + /// (CFPEPOJAMHH_NestedType_IFIBBJDIHJH.txt:3/25/47). Same triple the room + /// save commit sends on rooms/{id}/subrooms/{id}/data; declared here + /// so this controller binds its own body. + public sealed class StudioBlobRef + { + public string? Filename { get; set; } + public string? Hash { get; set; } + public string? OwnershipProof { get; set; } + } + + /// Body of remote-run/push-to-studio — RecNet type + /// FNAGBPCAGJD. Field order/types come from the request builder + /// (EHIOLHBGODG.txt:045-070 writes +16 String, +24 Int64, +32 Int64, + /// +40 String, +48/+56 the two blob refs, +64 a Nullable<Int32>) + /// and the property accessors on FNAGBPCAGJD.txt:3-141. The JSON key for + /// each is literal in the generated serializer OFMNNCMPEPA.txt:535-698: + /// SessionId, RoomId, SubRoomId, UnityAssetId, RoomData, SubRoomData, + /// SavedByAccountId (camel/lower aliases registered too). + public sealed class PushToStudioRequest + { + public string? SessionId { get; set; } + public long RoomId { get; set; } + public long SubRoomId { get; set; } + public string? UnityAssetId { get; set; } + public StudioBlobRef? RoomData { get; set; } + public StudioBlobRef? SubRoomData { get; set; } + public int? SavedByAccountId { get; set; } + } + + /// POST remote-run/push-to-studio — hands the room the + /// player just uploaded to the paired Rec Room Studio session, WITHOUT + /// committing it as the room's live save (that is the separate + /// rooms/{id}/subrooms/{id}/data path; the client's two flows are + /// sibling methods OGPDOMCNIFM.txt:271 vs :449). + /// + /// Verb. POST. The request builder at EHIOLHBGODG.txt:411-417 + /// moves the route literal "remote-run/push-to-studio" into r9 and + /// 2 (BestHTTP HTTPMethods.Post) into rdx before + /// BNDIAONDFFF..ctor(verb, host, route). No cmov — one verb only. + /// The body is RAW JSON, not form fields: :433 hands the serialized DTO to + /// BNDIAONDFFF.FJLLPHFOOJJ (RawJsonForm, application/json), + /// so this binds via — the + /// explicit [ModelBinder] is required or [ApiController] re-infers + /// [FromBody] and 415s a form-shaped caller. + /// + /// Response. FGLDKEJLAKB<CEELGOLBHIL> + /// (EHIOLHBGODG.txt:217) — a single RemoteRunDetails object, EIGHT keys, + /// all literal in its generated reader PPHLKNGCGOE.txt:599-786: + /// SessionId (String), RoomId and SubRoomId + /// (Nullable<Int64> — CEELGOLBHIL.txt:25/51), then + /// UnityAssetId, RoomDataFilename, RoomDataHash, SubRoomDataFilename, + /// SubRoomDataHash (String). Note the response FLATTENS the two blob + /// refs the request nests, and drops OwnershipProof. A failed/malformed + /// reply surfaces the client's "Failed to push to Rec Room Studio" toast + /// (EHIOLHBGODG.txt:445). + /// + /// What it persists. The pushed blobs were already stored by + /// /upload (every FileType there inserts a + /// ), so the push is a registration step: + /// it stamps the sub-room save blob with the room + sub-room it belongs to + /// — that is what makes it appear in the per-sub-room + /// datahistory/"Restore to old version" list, which the FileType=6 + /// room-metadata upload path (RoomId=0, SubRoomId=null) cannot do on its + /// own — writes the session id into + /// (the column that exists for + /// exactly this and is echoed in room details), records the pushed Unity + /// asset id on the sub-room's , and keeps the + /// whole push under remoterun:{SessionId} in + /// so the details survive for the paired + /// Studio session to resolve. + /// is deliberately NOT flipped: it changes in-room MakerPen UI and the + /// client has no unlink call, so a push must not one-way-toggle it. + /// + /// Not implemented: live relay of the details to a second + /// logged-in session. The client's receive side + /// (EHIOLHBGODG.MCPBJLHFOBC(CEELGOLBHIL) → the + /// Action<CEELGOLBHIL> that MBOJJFBIAGE.txt:366/978 subscribes) + /// has no call site anywhere in the IsilDump, so the notification id that + /// carries it cannot be established from the binary — inventing one would + /// be a guess. The HTTP round-trip is complete and the push is durable + /// either way. + [HttpPost("remote-run/push-to-studio")] + [HttpPost("roomserver/remote-run/push-to-studio")] + [Authorize] + public async Task PushToStudio( + [ModelBinder(typeof(Binding.FormOrJsonModelBinder))] PushToStudioRequest req) + { + var pid = CurrentPlayerId; + + var sessionId = Clamp((req.SessionId ?? string.Empty).Trim(), 100); + if (sessionId.Length == 0) + return BadRequest(new { Error = "missing SessionId" }); + + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId); + if (room is null) return NotFound(); + + // Same gate the room-save upload uses (StorageController.UploadRoomSave): + // creator, admin, or an accepted CoOwner (Role 0). + var canPush = room.CreatorPlayerId == pid + || await db.Players.AnyAsync(p => p.Id == pid && p.IsAdmin) + || await db.RoomRoles.AnyAsync(r => + r.RoomId == room.Id && r.PlayerId == pid && r.Accepted && r.Role == 0); + if (!canPush) return Forbid(); + + var subRoomId = req.SubRoomId; + var scene = await db.RoomScenes + .FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId); + + var subRoomDataFilename = Clamp((req.SubRoomData?.Filename ?? string.Empty).Trim(), 128); + if (subRoomDataFilename.Length == 0) + return BadRequest(new { Error = "missing SubRoomData.Filename" }); + + // The push names blobs the client uploaded moments ago; if the row is + // absent the bytes are not on this server and Studio could never fetch + // them. Fail loudly rather than answering with a filename that 404s. + var subRoomBlob = await db.RoomDataBlobs + .FirstOrDefaultAsync(b => b.BlobName == subRoomDataFilename); + if (subRoomBlob is null) + return BadRequest(new { Error = "unknown SubRoomData.Filename" }); + if (subRoomBlob.RoomId == 0) subRoomBlob.RoomId = room.Id; + subRoomBlob.SubRoomId ??= subRoomId; + + var roomDataFilename = Clamp((req.RoomData?.Filename ?? string.Empty).Trim(), 128); + if (roomDataFilename.Length > 0) + { + // FileType=6 (RoomMetadata) uploads land with RoomId=0 — the push + // is the first point at which the owning room is known. + var roomBlob = await db.RoomDataBlobs + .FirstOrDefaultAsync(b => b.BlobName == roomDataFilename); + if (roomBlob is null) + return BadRequest(new { Error = "unknown RoomData.Filename" }); + if (roomBlob.RoomId == 0) roomBlob.RoomId = room.Id; + } + + var unityAssetId = Clamp((req.UnityAssetId ?? string.Empty).Trim(), 64); + if (unityAssetId.Length == 0) + { + unityAssetId = scene?.StudioUnityAssetId ?? string.Empty; + } + else if (scene is not null && scene.StudioUnityAssetId != unityAssetId) + { + scene.StudioUnityAssetId = unityAssetId; + scene.DataModifiedAt = DateTime.UtcNow; + } + + var roomDataHash = Clamp((req.RoomData?.Hash ?? string.Empty).Trim(), 128); + var subRoomDataHash = Clamp((req.SubRoomData?.Hash ?? string.Empty).Trim(), 128); + + room.StudioSessionId = sessionId; + + var record = System.Text.Json.JsonSerializer.Serialize(new + { + RoomId = room.Id, + SubRoomId = subRoomId, + UnityAssetId = unityAssetId, + RoomDataFilename = roomDataFilename, + RoomDataHash = roomDataHash, + SubRoomDataFilename = subRoomDataFilename, + SubRoomDataHash = subRoomDataHash, + SavedByAccountId = req.SavedByAccountId ?? (int)pid, + PushedAt = DateTime.UtcNow, + }); + if (record.Length > 1024) + { + // PlayerSettingEntity.Value is varchar(1024); truncating JSON would + // store an unparseable fragment, so drop the optional hashes first. + record = System.Text.Json.JsonSerializer.Serialize(new + { + RoomId = room.Id, + SubRoomId = subRoomId, + UnityAssetId = unityAssetId, + RoomDataFilename = roomDataFilename, + SubRoomDataFilename = subRoomDataFilename, + SavedByAccountId = req.SavedByAccountId ?? (int)pid, + PushedAt = DateTime.UtcNow, + }); + } + + var key = $"remoterun:{sessionId}"; + var setting = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == pid && s.Key == key); + if (setting is null) + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = pid, Key = key, Value = record, + }); + else + setting.Value = record; + + await db.SaveChangesAsync(); + + return Ok(new + { + SessionId = sessionId, + RoomId = (long?)room.Id, + SubRoomId = (long?)subRoomId, + UnityAssetId = unityAssetId, + RoomDataFilename = roomDataFilename, + RoomDataHash = roomDataHash, + SubRoomDataFilename = subRoomDataFilename, + SubRoomDataHash = subRoomDataHash, + }); + } + + /// Trim a wire string to a column's length. Every string that + /// reaches a MaxLength column here comes straight off the request. + private static string Clamp(string value, int max) + => value.Length <= max ? value : value[..max]; + // ── Wire serializers ───────────────────────────────────────────────── private static int ClampInventionPermission(int v) => v switch diff --git a/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs b/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs index b68edd1..6248f2b 100644 --- a/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs +++ b/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs @@ -19,27 +19,75 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con { private long Me => this.RequireCurrentPlayerId(); + /// Marker category for collection rows; never surfaced as a keepsake instance. + private const string CollectionCategory = "collection"; + + /// + /// Mirrors KeepsakeGlobalConfigDTO.SocialXpBoostEnabled below. The collect handler has + /// to agree with what globalconfig advertises, so the flag lives in one place. + /// + private const bool SocialXpBoostEnabled = false; + + private const int KeepsakeRoomLimit = 64; + [HttpGet] public async Task Mine() { await EnsureLoginKeepsakeAsync(Me); var rows = await db.Keepsakes - .Where(k => k.PlayerId == Me) + .Where(k => k.PlayerId == Me && k.Category != CollectionCategory) .OrderByDescending(k => k.EarnedAt) .ToListAsync(); return Ok(rows.Select(ToWire)); } + // KeepsakeCategoryConfigDTO = {KeepsakeCategoryId(enum), VisualId, LimitPerRoom, XpValue, + // IconOutlineImageName, IconFilledImageName} - dump.cs:1234842-1234878; the accepted wire key + // names are enumerated by its Utf8Json formatter (IsilDump/RecNet.Runtime/HIKOJENMDKE.txt:66-160 + // - "KeepsakeCategoryId"/"VisualId"/"LimitPerRoom"/"XpValue" plus camel/lower aliases). + // KeepsakeCategoryId is the PIHCLHIKEPH enum (dump.cs:1199524: Explore=0, GreenPowerCore=1, + // Present=2, PurplePowerCore=3, UnnamedKeepsakeNumber1..5 = 4..8), and the client folds this + // list into a Dictionary (NCCLEJPIABA.DHMFBMMAGCL, + // NCCLEJPIABA.txt:4204) that every placed instance is looked up in. The previous three-entry + // list keyed by our internal account/event/room buckets covered ids 0-2 only, so six of the + // nine placeable categories had no config - and, since collect prices the reward from XpValue, + // no reward either. + private static readonly (int Id, string VisualId)[] CategoryConfigs = + [ + (0, "Explore"), + (1, "GreenPowerCore"), + (2, "Present"), + (3, "PurplePowerCore"), + (4, "UnnamedKeepsakeNumber1"), + (5, "UnnamedKeepsakeNumber2"), + (6, "UnnamedKeepsakeNumber3"), + (7, "UnnamedKeepsakeNumber4"), + (8, "UnnamedKeepsakeNumber5"), + ]; + + /// + /// XP a single keepsake collection is worth. This is the one number both + /// api/keepsakes/categories (as XpValue) and + /// api/keepsakes/{id}/collect (as TotalXp) are derived from, so the toast the + /// client renders after a collect matches the config it already downloaded. + /// + private const int KeepsakeXpValue = 25; + [HttpGet("categories")] [AllowAnonymous] public IActionResult Categories() { - var results = new[] - { - new { KeepsakeCategoryId = 0, VisualId = "account", LimitPerRoom = 0, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty }, - new { KeepsakeCategoryId = 1, VisualId = "event", LimitPerRoom = 0, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty }, - new { KeepsakeCategoryId = 2, VisualId = "room", LimitPerRoom = 64, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty }, - }; + var results = CategoryConfigs + .Select(c => new + { + KeepsakeCategoryId = c.Id, + c.VisualId, + LimitPerRoom = KeepsakeRoomLimit, + XpValue = KeepsakeXpValue, + IconOutlineImageName = string.Empty, + IconFilledImageName = string.Empty, + }) + .ToArray(); return Ok(new { Results = results, @@ -81,10 +129,26 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con // Key names follow the same RecNet naming the room endpoint below already uses; the literal // JSON names live in Newtonsoft attribute metadata the dumps do not render, so they are // unverified - Json.NET matching is case-insensitive and ignores unknown members. + // + // CollectionRecords is what the CALLER has picked up, which is a different set from what the + // caller PLACED: it is fed by the collect handler below (Category=CollectionCategory rows). + var eventInstanceIds = rows + .Select(r => TryParseInstanceKey(r.EventKey, out _, out _, out _, out var id) ? id : StableKeepsakeInstanceId(r.Id)) + .ToHashSet(); + var collected = (await LoadMyCollectionsAsync()) + .Where(c => eventInstanceIds.Contains(c.InstanceId)) + .GroupBy(c => c.RoomId) + .Select(group => (object)new + { + RoomId = group.Key, + KeepsakeInstanceIds = group.Select(c => c.InstanceId).Distinct().Select(id => id.ToString("D")).ToList(), + }) + .ToList(); + return Ok(new { Instances = GroupInstanceIdsByRoom(rows), - CollectionRecords = GroupInstanceIdsByRoom(rows.Where(r => r.PlayerId == Me)), + CollectionRecords = collected, }); } @@ -105,21 +169,136 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con .OrderByDescending(k => k.EarnedAt) .ToListAsync(); var instances = rows.Select(row => ToRoomInstanceWire(row, roomId)).ToList(); + + // KeepsakeCollectionRecordDTO = {Int32 AccountId, Guid KeepsakeInstanceId, DateTime + // CollectedAt} - dump.cs:1234642-1234666, wire keys confirmed by its Utf8Json formatter + // (IsilDump/RecNet.Runtime/CNNAFLNKDCL.txt:48,75,99). Scoped to the caller: the DTO carries + // AccountId so the client can filter, but returning only the caller's rows is correct under + // either reading and keeps other players' pickups out of the payload. + var collectPrefix = $"collect:{roomIdText}:"; + var me = Me; + var collectionRecords = await db.Keepsakes + .Where(k => k.PlayerId == me + && k.Category == CollectionCategory + && EF.Functions.Like(k.EventKey, collectPrefix + "%")) + .ToListAsync(); + return Ok(new { Instances = instances, - CollectionRecords = Array.Empty(), + CollectionRecords = collectionRecords + .Where(k => TryParseCollectionKey(k.EventKey, out _, out _)) + .Select(k => + { + TryParseCollectionKey(k.EventKey, out _, out var instanceId); + return (object)new + { + AccountId = k.PlayerId is > 0 and <= int.MaxValue ? (int)k.PlayerId : 0, + KeepsakeInstanceId = instanceId.ToString("D"), + CollectedAt = k.EarnedAt, + }; + }) + .ToList(), KeepsakeProgressionEventIds = Array.Empty(), }); } + /// + /// Removes a placed keepsake instance. The 2023 client issues + /// DELETE api/keepsakes/{guid} - NCCLEJPIABA.JBKENCNIEPA(System.Guid) builds + /// String.Format("{0}/{1}", "api/keepsakes", guid) and passes verb 4 (DELETE) to the + /// BNDIAONDFFF ctor (NCCLEJPIABA.txt:1544, 1687-1698 - Move rdx, 4). Its return type is + /// the non-generic LDGADANDBIO promise, so no response body is deserialised; a bare 200 + /// is enough. Client-side the tool refuses unless the caller is at least a room co-owner + /// ("Must be at least a room co-owner to modify keepsakes.", + /// RecRoom.Keepsakes.Runtime/PDFJLLECNBE.txt:3363), which we re-check server-side. + /// + [HttpDelete("{keepsakeInstanceId:guid}")] + public async Task Delete(Guid keepsakeInstanceId) + { + var instance = await FindInstanceAsync(keepsakeInstanceId); + if (instance is null) return NotFound(); + + var roomId = TryParseInstanceKey(instance.EventKey, out var parsedRoomId, out _, out _, out _) + ? parsedRoomId + : 0; + if (!await CanModifyInstanceAsync(instance, roomId, Me)) return Forbid(); + + // Drop the collection rows for this instance too, or they linger as records pointing at a + // keepsake nobody can see and keep suppressing a future re-placement's collect. + var suffix = keepsakeInstanceId.ToString("N"); + var orphanedCollections = await db.Keepsakes + .Where(k => k.Category == CollectionCategory && EF.Functions.Like(k.EventKey, $"%:{suffix}")) + .ToListAsync(); + + db.Keepsakes.Remove(instance); + db.Keepsakes.RemoveRange(orphanedCollections + .Where(k => TryParseCollectionKey(k.EventKey, out _, out var id) && id == keepsakeInstanceId)); + await db.SaveChangesAsync(); + return Ok(); + } + + /// + /// Records that the caller picked up a placed keepsake and reports the XP it was worth. The + /// 2023 client issues POST api/keepsakes/{guid}/collect - + /// NCCLEJPIABA.HFGKFAHFELM(System.Guid) formats "{0}/{1}/collect" over + /// "api/keepsakes" and passes verb 2 (POST) (NCCLEJPIABA.txt:1738, 1963-1974 - + /// Move rdx, 2), with no request body. The response type is + /// FGLDKEJLAKB<DHNBKMHDANK>; DHNBKMHDANK is a two-Int32 object whose Utf8Json + /// formatter writes "TotalXp" and "SocialBoostXp" (RecNet.Runtime/PKCMBJFBHBO.txt:42,69,100,121 + /// - reader also accepts totalXp/socialBoostXp). The consumer computes + /// TotalXp - SocialBoostXp for the base figure and shows the boost separately + /// (PDFJLLECNBE_NestedType_LKIPMJFEAFK.txt:82-99), so TotalXp is the whole award, boost + /// included - not a running account total. + /// + [HttpPost("{keepsakeInstanceId:guid}/collect")] + public async Task Collect(Guid keepsakeInstanceId) + { + var instance = await FindInstanceAsync(keepsakeInstanceId); + if (instance is null) return NotFound(); + + var parsed = TryParseInstanceKey(instance.EventKey, out var roomId, out _, out var categoryConfigId, out _); + if (!parsed) + { + roomId = 0; + categoryConfigId = CategoryId(instance.Category); + } + + var me = Me; + var collectKey = BuildCollectionKey(roomId, keepsakeInstanceId); + var alreadyCollected = await db.Keepsakes + .AnyAsync(k => k.PlayerId == me && k.Category == CollectionCategory && k.EventKey == collectKey); + + // Re-collecting is a no-op award rather than an error: the client only ever renders the two + // numbers, and paying out twice for one instance would be a fabricated reward. + if (alreadyCollected) return Ok(new { TotalXp = 0, SocialBoostXp = 0 }); + + db.Keepsakes.Add(new KeepsakeEntity + { + PlayerId = me, + Category = CollectionCategory, + EventKey = collectKey, + EarnedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + + return Ok(new + { + TotalXp = XpValueFor(categoryConfigId), + // SocialBoostXp is the boost slice *of* TotalXp - the client renders + // TotalXp - SocialBoostXp as the base figure - and globalconfig above advertises + // SocialXpBoostEnabled=false, so there is no slice and the whole award is base XP. + SocialBoostXp = 0, + }); + } + [HttpGet("globalconfig")] [AllowAnonymous] public IActionResult GlobalConfig() => Ok(new { KeepsakeFeatureEnabled = true, - KeepsakeRoomLimit = 64, - SocialXpBoostEnabled = false, + KeepsakeRoomLimit, + SocialXpBoostEnabled, }); [HttpPost] @@ -353,6 +532,100 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con return true; } + // A collection record is stored as its own keepsake row so no schema change is needed: + // Category = "collection", EventKey = "collect:{roomId}:{instanceGuid:N}" + // The room id is baked in so the room endpoint can filter by prefix and the event endpoint can + // group by room without re-reading the instance rows. "collect:" shares no prefix with the + // "room:"/"event:" instance keys, so collection rows never leak into an instance query, and + // Mine() filters the category out of the legacy keepsake list. + private static string BuildCollectionKey(long roomId, Guid instanceId) + => $"collect:{roomId.ToString(CultureInfo.InvariantCulture)}:{instanceId:N}"; + + private static bool TryParseCollectionKey(string? eventKey, out long roomId, out Guid instanceId) + { + roomId = 0; + instanceId = Guid.Empty; + var parts = (eventKey ?? string.Empty).Split(':'); + if (parts.Length != 3 || !string.Equals(parts[0], "collect", StringComparison.OrdinalIgnoreCase)) return false; + if (!long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out roomId)) return false; + return Guid.TryParseExact(parts[2], "N", out instanceId); + } + + private async Task> LoadMyCollectionsAsync() + { + var me = Me; + var rows = await db.Keepsakes + .Where(k => k.PlayerId == me && k.Category == CollectionCategory) + .Select(k => k.EventKey) + .ToListAsync(); + var results = new List<(long RoomId, Guid InstanceId)>(rows.Count); + foreach (var key in rows) + { + if (TryParseCollectionKey(key, out var roomId, out var instanceId)) + results.Add((roomId, instanceId)); + } + + return results; + } + + /// + /// Resolves the guid the client holds (from api/keepsakes/rooms/{roomId}, or the guid + /// this server handed back from POST api/keepsakes) to the row that encodes it. + /// + private async Task FindInstanceAsync(Guid keepsakeInstanceId) + { + var suffix = keepsakeInstanceId.ToString("N"); + var candidates = await db.Keepsakes + .Where(k => (k.Category == "room" || k.Category == "event") + && EF.Functions.Like(k.EventKey, $"%:{suffix}")) + .ToListAsync(); + var match = candidates.FirstOrDefault(k => + TryParseInstanceKey(k.EventKey, out _, out _, out _, out var id) && id == keepsakeInstanceId); + if (match is not null) return match; + + // Rows written before the instance id was encoded into EventKey are reported to the client + // with the MD5-derived StableKeepsakeInstanceId, which no LIKE can match. Resolve those by + // id only - the projection keeps it to one column over the short-key rows. + var legacyIds = await db.Keepsakes + .Where(k => (k.Category == "room" || k.Category == "event") + && !EF.Functions.Like(k.EventKey, "%:%:%:%")) + .Select(k => k.Id) + .ToListAsync(); + var legacyId = legacyIds.FirstOrDefault(id => StableKeepsakeInstanceId(id) == keepsakeInstanceId); + return legacyId == 0 ? null : await db.Keepsakes.FirstOrDefaultAsync(k => k.Id == legacyId); + } + + /// + /// Placement and removal are gated client-side on room co-ownership + /// ("Must be at least a room co-owner to modify keepsakes.", PDFJLLECNBE.txt:3363); the placer + /// and server admins are allowed through as well. + /// + private async Task CanModifyInstanceAsync(KeepsakeEntity instance, long roomId, long playerId) + { + if (instance.PlayerId == playerId) return true; + + if (roomId > 0) + { + var creatorId = await db.Rooms.AsNoTracking() + .Where(r => r.Id == roomId) + .Select(r => (long?)r.CreatorPlayerId) + .FirstOrDefaultAsync(); + if (creatorId == playerId) return true; + + var isCoOwner = await db.RoomRoles.AnyAsync(r => + r.RoomId == roomId && r.PlayerId == playerId && r.Role == 0 && r.Accepted); + if (isCoOwner) return true; + } + + return await db.Players + .Where(p => p.Id == playerId) + .Select(p => p.IsAdmin) + .FirstOrDefaultAsync(); + } + + private static int XpValueFor(int keepsakeCategoryConfigId) + => CategoryConfigs.Any(c => c.Id == keepsakeCategoryConfigId) ? KeepsakeXpValue : 0; + private static int CategoryId(string? category) => string.Equals(category, "event", StringComparison.OrdinalIgnoreCase) ? 1 : string.Equals(category, "room", StringComparison.OrdinalIgnoreCase) ? 2 : diff --git a/DorkNet.Server/Controllers/API/Photos/PhotosController.cs b/DorkNet.Server/Controllers/API/Photos/PhotosController.cs index e854b25..d42e86b 100644 --- a/DorkNet.Server/Controllers/API/Photos/PhotosController.cs +++ b/DorkNet.Server/Controllers/API/Photos/PhotosController.cs @@ -1,3 +1,5 @@ +using System.Text; +using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -240,6 +242,318 @@ public class PhotosController( return Ok(new { deleted = id }); } + // ── 2023 home feed (Discovery host) ─────────────────────────────── + + /// The only FeedItemType (client enum + /// DAAEHNDDCCH) the 2023 feed list can render: an image. + /// Two independent confirmations in the binary — the list spawner + /// builds one RRUI.Data.ImageFeedItemModel per returned item + /// (Assembly-CSharp/RRUI/Data/FeedListModelController_NestedType_FeedListSpawnerImpl.txt:294 + /// "044 Call ImageFeedItemModel.Set"), and the share deep-link + /// handler maps the path segment "image" + an Int64 id onto an + /// injected feed item constructed with the enum literal 0 + /// (Assembly-CSharp/RecRoom/Sharing/LinkManager.txt:5646 + /// Move rdx,"image" … :5665 Move rdx,0 … :5668 Call + /// EKLOCIPFHII..ctor). FeedItemId is therefore an image id, which on + /// DorkNet is — the same id space + /// api/images/v5/bulk resolves. + private const int FeedItemTypeImage = 0; + + /// Ranking revision stamped on the envelope and on every + /// item. The client never interprets it: it copies the Int16 off the + /// response into the list model and replays it in the impression + /// telemetry it later posts back (FeedListModel_NestedType_OLMJPGEMAKM.txt + /// "031 Move rax,[rdi+40] / 032 Move [rcx+280],rax" then + /// "040 Call FeedListModel.LMDNOKJCBGE"). Bump it when the ordering + /// below changes so the telemetry can tell the cohorts apart. + private const short FeedAlgoVersion = 1; + + /// POST /feed/query — the 2023 client's personalised home + /// feed. Host is Discovery (host enum 19) and the verb is POST: + /// RecNet.Runtime/KGLCPCEGCFF.txt:166-178 loads + /// r9 = "feed/query", r8 = 19, rdx = 2 (BestHTTP POST) into the + /// shared BNDIAONDFFF request ctor. The body is a RAW JSON document + /// (the issuing method takes an already-serialised + /// System.String and hands it to BNDIAONDFFF.FJLLPHFOOJJ), + /// NOT form fields — so it is read straight off Request.Body. + /// + /// Request keys (reader BAEBDMPPKKE.txt:331-414, each registered in + /// PascalCase/camelCase/lowercase): Take:Int32, + /// ContinuationToken:String?, InjectedFeedItems:[{FeedItemType, + /// FeedItemId}], FeedInstanceId:String?. + /// + /// Response is the generic result envelope AECMPGPHAII<T> + /// {Success, Error, Value} (keys at AEGADMDIKNE.txt:243-286) wrapping + /// BDIFPHKLFKD {Items, FeedInstanceId, FeedContext, + /// FeedAlgorithmVersion} (keys at EHKEOCKBCDH.txt:331-414). Anything + /// else — including a 404 — rejects the promise and the home tab + /// renders its failure state. + /// + /// FeedContext is the continuation cursor, not a display string: the + /// RefreshPage continuation reads field +32 (FeedContext) as the + /// third element of its (count, items, nextToken) tuple and feeds it + /// back as the next request's ContinuationToken + /// (FeedListModel_NestedType_OLMJPGEMAKM.txt "062 Move r9, + /// [rdi+32]"). An empty FeedContext therefore ends pagination. + /// + /// Anonymous-safe like the other feed reads; when the caller is + /// authenticated the ordering is personalised (friends/subscriptions + /// first) and blocked/ignored uploaders are filtered out. + [HttpPost("/feed/query")] + [AllowAnonymous] + public async Task FeedQuery() + { + var take = 20; + string? continuationToken = null; + string? requestedInstanceId = null; + var requestedInjected = new List(); + + try + { + using var doc = await JsonDocument.ParseAsync(Request.Body); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + var root = doc.RootElement; + if (TryFeedProp(root, "Take", out var takeEl) && + takeEl.ValueKind == JsonValueKind.Number && takeEl.TryGetInt32(out var t)) + take = t; + if (TryFeedProp(root, "ContinuationToken", out var ctEl) && + ctEl.ValueKind == JsonValueKind.String) + continuationToken = ctEl.GetString(); + if (TryFeedProp(root, "FeedInstanceId", out var fiEl) && + fiEl.ValueKind == JsonValueKind.String) + requestedInstanceId = fiEl.GetString(); + if (TryFeedProp(root, "InjectedFeedItems", out var injEl) && + injEl.ValueKind == JsonValueKind.Array) + { + foreach (var entry in injEl.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) continue; + // Non-image injected items can't be rendered by the + // 2023 list, so echoing them back would produce a + // blank card. Drop them. + var type = TryFeedProp(entry, "FeedItemType", out var tyEl) && + tyEl.ValueKind == JsonValueKind.Number && + tyEl.TryGetInt32(out var ty) + ? ty : FeedItemTypeImage; + if (type != FeedItemTypeImage) continue; + if (TryFeedProp(entry, "FeedItemId", out var idEl) && + idEl.ValueKind == JsonValueKind.Number && + idEl.TryGetInt64(out var id) && id > 0) + requestedInjected.Add(id); + } + } + } + } + catch (JsonException) + { + // Unparseable/empty body — treat as a default first-page pull + // rather than 400, so a malformed retry still shows content. + } + + take = Math.Clamp(take, 1, 50); + var me = this.CurrentPlayerId(); + + // Uploaders the caller can't see: anyone they blocked or ignored, + // plus anyone who blocked them. + var hidden = new List(); + var affinity = new List(); + if (me is long meId) + { + hidden = await db.Relationships + .Where(r => (r.RequesterId == meId && + (r.Status == RelationshipStatus.Blocked || r.Ignored)) || + (r.TargetId == meId && r.Status == RelationshipStatus.Blocked)) + .Select(r => r.RequesterId == meId ? r.TargetId : r.RequesterId) + .Distinct() + .ToListAsync(); + + var friends = await db.Relationships + .Where(r => r.Status == RelationshipStatus.Friend && + (r.RequesterId == meId || r.TargetId == meId)) + .Select(r => r.RequesterId == meId ? r.TargetId : r.RequesterId) + .ToListAsync(); + var follows = await db.Subscriptions + .Where(s => s.SubscriberPlayerId == meId) + .Select(s => s.TargetPlayerId) + .ToListAsync(); + affinity = friends.Concat(follows) + .Where(id => id > 0 && !hidden.Contains(id)) + .Distinct() + .ToList(); + } + + var resumed = TryDecodeFeedCursor(continuationToken, + out var phase, out var cursorTicks, out var cursorId, out var carriedInjected); + + // Injected items are pinned to the head of the FIRST page only, in + // the order the client asked for them (they come from a share + // deep-link the player just followed). Their ids ride along in the + // cursor so later pages don't repeat them organically. + var injectedIds = resumed ? carriedInjected : requestedInjected.Distinct().ToArray(); + var itemIds = new List(); + if (!resumed && injectedIds.Length > 0) + { + var pinned = await VisibleFeedPhotos(hidden) + .Where(p => injectedIds.Contains(p.Id)) + .Select(p => p.Id) + .ToListAsync(); + itemIds.AddRange(injectedIds.Where(pinned.Contains)); + } + + DateTime? cutoffAt = resumed && cursorTicks > 0 && cursorTicks <= DateTime.MaxValue.Ticks + ? new DateTime(cursorTicks) + : (DateTime?)null; + var cutoffId = resumed ? cursorId : 0L; + if (phase < 0) phase = 0; + + // Phase 0 = photos from people the caller follows or is friends + // with; phase 1 = everyone else. Both are newest-first, and the + // cursor is (CreatedAt, Id) so a photo posted mid-scroll can never + // shift a row onto a page the client already consumed. + while (itemIds.Count < take && phase <= 1) + { + if (phase == 0 && affinity.Count == 0) + { + phase = 1; + cutoffAt = null; + cutoffId = 0; + continue; + } + + var need = take - itemIds.Count; + var q = VisibleFeedPhotos(hidden); + if (injectedIds.Length > 0) + q = q.Where(p => !injectedIds.Contains(p.Id)); + q = phase == 0 + ? q.Where(p => affinity.Contains(p.UploaderPlayerId)) + : q.Where(p => !affinity.Contains(p.UploaderPlayerId)); + if (cutoffAt is DateTime at) + { + var atId = cutoffId; + q = q.Where(p => p.CreatedAt < at || (p.CreatedAt == at && p.Id < atId)); + } + + var batch = await q + .OrderByDescending(p => p.CreatedAt) + .ThenByDescending(p => p.Id) + .Take(need) + .Select(p => new { p.Id, p.CreatedAt }) + .ToListAsync(); + itemIds.AddRange(batch.Select(b => b.Id)); + + if (batch.Count < need) + { + phase++; + cutoffAt = null; + cutoffId = 0; + } + else + { + cutoffAt = batch[^1].CreatedAt; + cutoffId = batch[^1].Id; + } + } + + // Empty FeedContext = end of the feed; the client stops paging. + var nextContext = phase > 1 + ? string.Empty + : EncodeFeedCursor(phase, cutoffAt?.Ticks ?? 0, cutoffId, injectedIds); + + return Ok(new + { + Success = true, + Error = (string?)null, + Value = new + { + Items = itemIds.Select(id => new + { + FeedItemType = FeedItemTypeImage, + FeedItemId = id, + FeedAlgorithmVersion = FeedAlgoVersion, + }).ToList(), + // The client sends the instance id it generated for this + // scroll session and stores whatever comes back; echoing it + // keeps its impression telemetry stitched to one session. + FeedInstanceId = string.IsNullOrWhiteSpace(requestedInstanceId) + ? Guid.NewGuid().ToString("N") + : requestedInstanceId, + FeedContext = nextContext, + FeedAlgorithmVersion = FeedAlgoVersion, + }, + }); + } + + /// Photos eligible for the public home feed: live, public, + /// and not from an uploader the caller blocked/ignored. + private IQueryable VisibleFeedPhotos(List hidden) + { + var q = db.Photos.Where(p => p.IsPublic && p.DeletedAt == null); + if (hidden.Count > 0) + q = q.Where(p => !hidden.Contains(p.UploaderPlayerId)); + return q; + } + + /// Case-insensitive property lookup — the 2023 generated + /// writers emit PascalCase but their readers register all three + /// casings, so tolerate whatever the client sends. + private static bool TryFeedProp(JsonElement obj, string name, out JsonElement value) + { + foreach (var prop in obj.EnumerateObject()) + { + if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase)) + { + value = prop.Value; + return true; + } + } + value = default; + return false; + } + + /// Opaque base64url cursor handed to the client as + /// FeedContext. Carries the phase, the (CreatedAt, Id) keyset + /// position within that phase, and the pinned injected ids so they + /// stay suppressed on later pages. + private static string EncodeFeedCursor(int phase, long ticks, long lastId, IReadOnlyList injected) + { + var raw = $"1|{phase}|{ticks}|{lastId}|{string.Join(',', injected)}"; + return Convert.ToBase64String(Encoding.UTF8.GetBytes(raw)) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + private static bool TryDecodeFeedCursor(string? token, out int phase, + out long ticks, out long lastId, out long[] injected) + { + phase = 0; + ticks = 0; + lastId = 0; + injected = Array.Empty(); + if (string.IsNullOrWhiteSpace(token)) return false; + try + { + var b64 = token.Replace('-', '+').Replace('_', '/'); + b64 = b64.PadRight(b64.Length + ((4 - (b64.Length % 4)) % 4), '='); + var parts = Encoding.UTF8.GetString(Convert.FromBase64String(b64)).Split('|'); + if (parts.Length != 5 || parts[0] != "1") return false; + if (!int.TryParse(parts[1], out phase)) return false; + if (!long.TryParse(parts[2], out ticks)) return false; + if (!long.TryParse(parts[3], out lastId)) return false; + injected = parts[4] + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => long.TryParse(s, out var v) ? v : 0L) + .Where(v => v > 0) + .ToArray(); + return true; + } + catch (FormatException) + { + // Garbage/stale cursor — restart the feed from the top instead + // of failing the whole pull. + return false; + } + } + // ── Helpers ────────────────────────────────────────────────────── /// Trim/dedupe tagged ids and drop the uploader's own id. diff --git a/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs b/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs index d51bffe..313abd1 100644 --- a/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs +++ b/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs @@ -33,7 +33,8 @@ namespace DorkNet.Server.Controllers.API.PlayerEvents; [Authorize] public class PlayerEventsController( DorkNetDbContext db, - NotificationService notifications) : ControllerBase + NotificationService notifications, + PlayerPresenceService presence) : ControllerBase { private long Me => this.RequireCurrentPlayerId(); @@ -351,6 +352,101 @@ public class PlayerEventsController( return Ok(rows.Select(ResponseWire).ToList()); } + // ── Event instance browser ─────────────────────────────────────────── + + /// GET event/{eventId}/instances — the "which instance of + /// this event are my friends in" browser behind the event card's join + /// button. Served from the MATCHMAKING-HOST root (no api/ prefix), + /// which is why it lives on an absolute route here even though the feature + /// is a player-event one. + /// + /// Binary evidence: Matchmaking+<GetEventInstanceBrowser>b__0 + /// (Matchmaking_NestedType_CBMHJMNIHNN.txt:14) formats the literal + /// "event/{0}/instances" at :296 with [rdi+16] — the + /// PlayerEventId off the HPIOAGDJHDH the lambda closes over — then news up + /// BNDIAONDFFF with rdx = 0 at :124, i.e. + /// BestHTTP.HTTPMethods.Get. The declared return type is + /// FGLDKEJLAKB<List<PNDCMIMEJLD>>, so the body is a BARE + /// JSON ARRAY. PNDCMIMEJLD's getters (PNDCMIMEJLD.txt:3-197) are, in + /// order, Int64/Int64/Int64/Boolean/DateTime/List<Int32>/String/Int32/ + /// Boolean/String = RoomInstanceId, RoomId, SubRoomId, IsFull, CreatedAt, + /// PlayerIds, SubroomName, PlayerCount, HasModPresent, HashedInstanceId — + /// byte-for-byte the SimpleRoomInstance the room/{id}/instances + /// browser already returns (Matchmaking.txt:14237 issues the room + /// variant through the identical BNDIAONDFFF/verb-0 sequence). The last + /// four are filled in locally on the watch and are not read back off the + /// wire, so we emit the same six camelCase keys + /// (Controllers/Match/MatchController.cs:545-553); the 2023 Utf8Json + /// formatters carry a camelCase variant per key, so both client + /// generations parse it. + /// + /// The event itself only supplies the room to browse: an event is pinned to + /// one RoomId, plus the SubRoomId the host picked in the v2 create/edit body + /// (persisted in ). Sub-room hops allocate their + /// own instance ids and photon rooms, so when the event names a sub-room we + /// only surface instances sitting in it. + [AllowAnonymous] + [HttpGet("/event/{eventId:long}/instances")] + public async Task EventInstances(long eventId) + { + var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId); + if (evt is null) return NotFound(); + + var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; + var subRoomId = extras.SubRoomId is long sub && sub > 0 ? sub : (long?)null; + + // Same sourcing as the room browser: presence rows are the live truth + // (one instance per distinct PhotonRoomId), and the caller's OWN private + // instances are merged in so the host can still see the instance they + // created before anyone has joined it. Another player's private + // PhotonRoomId is never listed. + var pid = this.CurrentPlayerId() ?? 0; + var privates = pid == 0 + ? new List() + : await db.PrivateInstances + .Where(p => p.RoomId == evt.RoomId && p.OwnerPlayerId == pid) + .ToListAsync(); + + var byKey = new Dictionary(StringComparer.Ordinal); + foreach (var (room, playerIds) in presence.EnumerateActiveInstances(evt.RoomId)) + { + if (string.IsNullOrEmpty(room.PhotonRoomId)) continue; + if (subRoomId is long want && room.SubRoomId != want) continue; + byKey[room.PhotonRoomId] = new + { + roomInstanceId = room.RoomInstanceId, + roomId = room.RoomId, + subRoomId = room.SubRoomId, + // MaxCapacity is the per-instance cap the matchmaker handed + // out, so this is a real answer rather than a constant false. + isFull = room.MaxCapacity > 0 && playerIds.Count >= room.MaxCapacity, + // Presence rows are a TTL cache with no creation timestamp — + // there is no stored "instance opened at" for a live instance, + // so report the observation time. Private-instance rows below + // do carry a real CreatedAt and use it. + createdAt = DateTime.UtcNow, + playerIds = playerIds.Select(p => (int)p).ToArray(), + }; + } + + foreach (var p in privates) + { + if (string.IsNullOrEmpty(p.PhotonRoomId) || byKey.ContainsKey(p.PhotonRoomId)) continue; + if (subRoomId is long want && p.SubRoomId != want) continue; + byKey[p.PhotonRoomId] = new + { + roomInstanceId = p.Id, + roomId = p.RoomId, + subRoomId = p.SubRoomId, + isFull = false, + createdAt = DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc), + playerIds = Array.Empty(), + }; + } + + return Ok(byKey.Values.ToList()); + } + // ── Wire shape ─────────────────────────────────────────────────────── /// The PlayerEvent fields the 2023 client carries that diff --git a/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs b/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs index 8ad0c62..eacce1d 100644 --- a/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs +++ b/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs @@ -1,15 +1,24 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using DorkNet.Server.Auth; using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.ProgressionEvents; [ApiController] -public class ProgressionEventsController(DorkNetDbContext db, ServerSettingsService serverSettings) : ControllerBase +public class ProgressionEventsController( + DorkNetDbContext db, + ServerSettingsService serverSettings, + IConfiguration config) : ControllerBase { + /// CurrencyType enum value the rest of the server uses for + /// RecCenter tokens (mirrors ProgressionController.RecCenterTokens). + private const int RecCenterTokens = 2; + [HttpGet("api/progressionEvents")] [Authorize] public async Task Progress() @@ -39,30 +48,29 @@ public class ProgressionEventsController(DorkNetDbContext db, ServerSettingsServ var weekly = await serverSettings.GetWeeklyChallengesAsync(); var (start, end) = CurrentWeekWindow(); + // The boost fields must agree with api/progressionEvents/{id}/xpboosts: + // ProgressionEventsPurchasableXpBoostModel.Set() takes the guid the + // event advertises here and looks it up in the xpboosts list, and + // ProgressionEventsModel.GetPurchasableXpBoostId() gates the whole + // boost sheet on PurchasableXpBoostId being non-null. Deriving both + // from one catalogue keeps them from drifting. + var boosts = BoostCatalogue(activeId); + var headline = boosts.Count > 0 ? boosts[0] : null; return Ok(new { ProgressionEventId = activeId, Name = "Weekly Progression", - Rewards = new[] - { - new - { - ProgressionEventRewardId = activeId * 1000L, - GiftDropId = weekly.Reward.GiftDropId != 0 ? weekly.Reward.GiftDropId : activeId, - ImageName = string.Empty, - Xp = weekly.Reward.Xp, - RewardIndex = 0, - IsBonus = false, - }, - }, + Rewards = BuildRewards(weekly.Reward, activeId), KeepsakeRoomLists = Array.Empty(), StartTime = start, EndTime = end, CollectionEndTime = end, - UsesBoost = false, + UsesBoost = headline is not null, BoostDailyGameplayMinutesLimit = 0, - BoostXpMultiplier = 1.0, - PurchasableXpBoostId = (Guid?)null, + // ProgressionEventDTO.BoostXpMultiplier is a float at +0x50 + // (dump.cs:1198957) — emit a real number, not an integer token. + BoostXpMultiplier = (double)(headline?.XpMultiplier ?? 1), + PurchasableXpBoostId = headline?.Id, ActiveExperiment = string.Empty, ChallengesIconImageName = string.Empty, RewardsPipImageName = string.Empty, @@ -88,24 +96,349 @@ public class ProgressionEventsController(DorkNetDbContext db, ServerSettingsServ { var me = this.RequireCurrentPlayerId(); - // Count this player's completed progression-event objectives so the - // record reflects real progress rather than a flat zero. - var completed = await db.ObjectiveProgress + // Count the reward-claim sentinels this player owns FOR THIS EVENT + // (written by Collect below). The previous query counted every + // `progressionEvent:` row, which also matches the xpBoost purchase + // sentinels StorefrontsBuyController writes — a boost purchase + // silently advanced the claimed-reward pip. + var claimPrefix = RewardClaimPrefix(progressionEventId); + var collected = await db.ObjectiveProgress .CountAsync(o => o.PlayerId == me && o.IsCompleted - && o.Key.StartsWith("progressionEvent:")); + && o.Key.StartsWith(claimPrefix)); return Ok(new { ProgressionEventId = progressionEventId, Xp = 0, - ClaimedRewardIndex = completed > 0 ? completed - 1 : -1, + ClaimedRewardIndex = collected > 0 ? collected - 1 : -1, PurchasedXpBoostCount = 0, DailyBoostGameplayMinutes = 0, XpBoostExpiresAt = (DateTime?)null, }); } + // ── Reward collection ──────────────────────────────────────────────── + + /// + /// POST api/progressionEvents/collect/{eventId}/{rewardIndex} — + /// claim one reward chest off the progression track. + /// + /// Binary evidence: RecNet.Runtime/CDBFONFHJDO.txt:1208 + /// (GKANPKEGBGE(Int64 eventId, Int32 rewardIndex)) formats + /// "{0}/collect/{1}/{2}" against "api/progressionEvents" + /// (ISIL 041/043) and moves 2 = HTTPMethods.Post into the + /// verb argument at ISIL 055. No request body is passed — the two route + /// segments are the whole request. + /// + /// The return type is FGLDKEJLAKB<FFFIMAGLKEG/FHMABOHAEED>, + /// and FHMABOHAEED is the client's GiftPackage DTO — the very + /// same object GiftManager.RunConsumeGift / + /// GiftManager.DequeueGift take + /// (Assembly-CSharp/GiftManager.txt:3226,3395), whose wire keys are + /// Id / FromPlayerId / ConsumableItemDesc / AvatarItemType / + /// AvatarItemDesc / EquipmentPrefabName / EquipmentModificationGuid / + /// CurrencyType / Currency / Xp / GiftContext / GiftRarity / Message / + /// Platform / PlatformsToSpawnOn / BalanceType + /// (RecNet.Runtime/FKANCKLCCDI.txt:1051-1390). + /// + /// That means the client will POST the returned Id back to + /// api/avatar/v2/gifts/consume, so this MUST persist a real + /// rather than synthesise a wire blob — + /// an unpersisted Id 404s on consume and the player loses the reward. + /// The payload itself comes from the admin-configured weekly reward + /// (), i.e. exactly what + /// advertises on the track, so the chest hands over + /// what the UI promised. XP / tokens / level ride on the package and are + /// granted by the existing consume path, not here — granting in both + /// places would double-pay. + /// + /// Idempotent: the claim sentinel is an + /// whose Key embeds the created gift id, so a retried or duplicated POST + /// returns the original package instead of minting a second one. + /// No GiftPackageReceived push is sent (unlike + /// avatar/v2/gifts/generate): the RRUI reward flow already spawns + /// the box from this response, and a push would spawn a second one. + /// + [HttpPost("api/progressionEvents/collect/{eventId:long}/{rewardIndex:int}")] + [Authorize] + public async Task Collect(long eventId, int rewardIndex) + { + var me = this.RequireCurrentPlayerId(); + + // Only the live event's track is claimable — the client only ever + // renders the active event, and letting an arbitrary id through + // would let a replayed request re-roll last week's chest. + var activeId = CurrentProgressionEventId(); + if (eventId != activeId) return NotFound(); + + var weekly = await serverSettings.GetWeeklyChallengesAsync(); + var rewards = BuildRewards(weekly.Reward, activeId); + if (rewardIndex < 0 || rewardIndex >= rewards.Count) return NotFound(); + + // Already claimed? The sentinel key carries the gift id so we can + // hand back the same package (the client may retry after a dropped + // response, and it needs a consumable Id either way). + var sentinelPrefix = $"{RewardClaimPrefix(eventId)}{rewardIndex}:gift:"; + var sentinel = await db.ObjectiveProgress + .FirstOrDefaultAsync(o => o.PlayerId == me && o.Key.StartsWith(sentinelPrefix)); + if (sentinel is not null) + { + if (long.TryParse(sentinel.Key[sentinelPrefix.Length..], out var priorId)) + { + var prior = await db.GiftPackages + .FirstOrDefaultAsync(g => g.Id == priorId && g.RecipientPlayerId == me); + if (prior is not null) + return Ok(global::DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(prior)); + } + // Sentinel exists but its package was purged — drop the stale + // marker and fall through to re-issue, otherwise the player is + // permanently locked out of a reward they never received. + db.ObjectiveProgress.Remove(sentinel); + await db.SaveChangesAsync(); + } + + var reward = weekly.Reward; + var gift = new GiftPackageEntity + { + RecipientPlayerId = me, + FromPlayerId = null, + // The weekly reward config carries a raw avatar desc, which is + // always an outfit item (AvatarItemType 0); hair dyes are + // configured through ConsumableItemDesc instead. Leave the type + // null when there is no avatar payload so the client doesn't + // render an empty outfit tile. + AvatarItemType = string.IsNullOrWhiteSpace(reward.AvatarItemDesc) ? (int?)null : 0, + AvatarItemDescOrHairDyeDesc = reward.AvatarItemDesc, + ConsumableItemDesc = reward.ConsumableItemDesc, + EquipmentPrefabName = reward.EquipmentPrefabName, + EquipmentModificationGuid = reward.EquipmentModificationGuid, + CurrencyType = reward.Tokens > 0 ? RecCenterTokens : 0, + Currency = reward.Tokens, + Xp = reward.Xp, + Level = reward.Level, + GiftContext = reward.GiftContext, + GiftRarity = reward.GiftRarity, + Message = $"Progression event reward {rewardIndex + 1}", + Platform = -1, + PackageVariant = "Standard", + PackageMaterial = string.Empty, + IsValid = true, + SupportsCurrentPlatform = true, + }; + db.GiftPackages.Add(gift); + await db.SaveChangesAsync(); + + db.ObjectiveProgress.Add(new ObjectiveProgressEntity + { + PlayerId = me, + Key = $"{sentinelPrefix}{gift.Id}", + IsCompleted = true, + ClearedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + + return Ok(global::DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift)); + } + + // ── Purchasable XP boosts ──────────────────────────────────────────── + + /// + /// GET api/progressionEvents/{eventId}/xpboosts — the boost + /// offers the client's purchase sheet renders. + /// + /// Binary evidence: RecNet.Runtime/CDBFONFHJDO.txt:1465 + /// (HKMBEFABIBD(Int64 eventId)) formats + /// "{0}/{1}/xpboosts" against "api/progressionEvents" + /// (ISIL 026/028) and moves 0 = HTTPMethods.Get into the + /// verb argument at ISIL 034. The declared return type is + /// List<ProgressionEventPurchasableXpBoostDTO>, i.e. a bare + /// JSON array — no paged container. + /// + /// Element keys and their order come from the generated Utf8Json + /// formatter RecNet.Runtime/OFGIPBHIAGF.txt:605-698: + /// ProgressionEventPurchasableXpBoostId (Guid), Cost (Int32), + /// XpMultiplier (Int32 — get_XpMultiplierFromBoost reads the DTO's + /// +0x24 int field, NOT a float), XpCap (Int32), LookbackDurationTicks + /// (Int64), CooldownDurationTicks (Int64). + /// + /// The boost id must be STABLE across calls: the client caches it from + /// here, passes it to previewEarnedXp, and posts it as + /// purchasableXpBoostId to + /// api/storefronts/v1/buyProgressionEventXpBoost (which keys its + /// purchase sentinel on it). See . + /// + [HttpGet("api/progressionEvents/{eventId:long}/xpboosts")] + [AllowAnonymous] + public IActionResult XpBoosts(long eventId) => + Ok(BoostCatalogue(eventId).Select(b => new + { + ProgressionEventPurchasableXpBoostId = b.Id, + b.Cost, + b.XpMultiplier, + b.XpCap, + b.LookbackDurationTicks, + b.CooldownDurationTicks, + })); + + /// + /// GET api/progressionEvents/{eventId}/xpboosts/{boostId}/previewEarnedXp + /// — how much XP buying this boost right now would hand the caller. + /// + /// Binary evidence: RecNet.Runtime/CDBFONFHJDO.txt:1612 + /// (GBNFGMOGLNG(Int64 eventId, Guid boostId)) formats + /// "{0}/{1}/xpboosts/{2}/previewEarnedXp" against + /// "api/progressionEvents" (ISIL 036/038) and moves 0 = + /// HTTPMethods.Get into the verb argument at ISIL 049. The return + /// type is FGLDKEJLAKB<System.Int32> — a BARE integer body, + /// not an object; the value lands in + /// ProgressionEventsPurchasableXpBoostModel.get_XpEarnedFromBoost + /// and is echoed back as the expectedXp form field of + /// buyProgressionEventXpBoost, which grants exactly that much. + /// + /// Cooldown is real state: buyProgressionEventXpBoost stamps + /// progressionEvent:{eventId}:xpBoost:{guid:N} in ObjectiveProgress + /// on every purchase, so a boost still inside its + /// CooldownDurationTicks window previews as 0 and the client's + /// get_XPBoostIsAvailableForPurchase keeps the button disabled. + /// + /// Fidelity note: live Rec Room multiplied the XP the player actually + /// earned during LookbackDurationTicks. DorkNet has no timestamped + /// XP ledger ( mutates + /// PlayerEntity.XP in place), so a boost here pays its configured + /// XpCap — the cap is the payout, and XpMultiplier is the + /// figure the sheet renders as "Nx". + /// + [HttpGet("api/progressionEvents/{eventId:long}/xpboosts/{boostId:guid}/previewEarnedXp")] + [Authorize] + public async Task PreviewEarnedXp(long eventId, Guid boostId) + { + var me = this.RequireCurrentPlayerId(); + + var offer = BoostCatalogue(eventId).FirstOrDefault(b => b.Id == boostId); + if (offer is null) return Ok(0); + + // Same key StorefrontsBuyController.BuyProgressionEventXpBoost writes. + var purchaseKey = $"progressionEvent:{eventId}:xpBoost:{boostId:N}"; + var lastPurchase = await db.ObjectiveProgress + .Where(o => o.PlayerId == me && o.Key == purchaseKey) + .Select(o => o.ClearedAt) + .FirstOrDefaultAsync(); + + if (offer.CooldownDurationTicks > 0 + && lastPurchase is DateTime at + && DateTime.UtcNow - at < TimeSpan.FromTicks(offer.CooldownDurationTicks)) + { + return Ok(0); + } + + return Ok(offer.XpCap); + } + + // ── Shared shape helpers ───────────────────────────────────────────── + + /// One pip on the progression track. Field types are taken from + /// ProgressionEventRewardDTO (dump.cs:1199338): ProgressionEventRewardId + /// is Int64 at +0x10 but GiftDropId is Int32 at +0x18 (the audit doc + /// says Int64 — the binary disagrees), then string ImageName, Int32 Xp, + /// Int32 RewardIndex, bool IsBonus. + public sealed record EventReward( + long ProgressionEventRewardId, + int GiftDropId, + string ImageName, + int Xp, + int RewardIndex, + bool IsBonus); + + /// The progression track's reward list. One source of truth for + /// (which renders the pips) and + /// (which bounds-checks the claimed index), so the two can't drift into a + /// state where the client can see a chest the server refuses to open. + private static IReadOnlyList BuildRewards(WeeklyChallengeReward reward, long activeId) => + [ + new EventReward( + ProgressionEventRewardId: activeId * 1000L, + // yyyyMMdd fits Int32 comfortably, so the event id doubles as a + // stable placeholder drop id when no reward drop is configured. + GiftDropId: reward.GiftDropId != 0 ? reward.GiftDropId : (int)activeId, + ImageName: string.Empty, + Xp: reward.Xp, + RewardIndex: 0, + IsBonus: false), + ]; + + private static string RewardClaimPrefix(long eventId) => $"progressionEvent:{eventId}:reward:"; + + // ── XP-boost catalogue ─────────────────────────────────────────────── + + public sealed record XpBoostOffer( + Guid Id, + int Cost, + int XpMultiplier, + int XpCap, + long LookbackDurationTicks, + long CooldownDurationTicks); + + /// The boosts this deployment sells for a given event. + /// + /// Configured under ProgressionEvents:XpBoosts as an array of + /// {Cost, XpMultiplier, XpCap, LookbackMinutes, CooldownMinutes}; + /// with nothing configured we fall back to a single built-in offer (same + /// pattern as + /// and the built-in NUX checklist). Set + /// ProgressionEvents:EnableXpBoosts to false to sell none — the + /// event then reports UsesBoost=false and the sheet stays shut. + private IReadOnlyList BoostCatalogue(long eventId) + { + if (!config.GetValue("ProgressionEvents:EnableXpBoosts", true)) + return []; + + var configured = config.GetSection("ProgressionEvents:XpBoosts").GetChildren().ToList(); + if (configured.Count == 0) + { + return + [ + new XpBoostOffer( + Id: OfferId(eventId, 0), + Cost: 250, + XpMultiplier: 2, + XpCap: 500, + LookbackDurationTicks: TimeSpan.FromHours(1).Ticks, + CooldownDurationTicks: TimeSpan.FromHours(24).Ticks), + ]; + } + + var offers = new List(configured.Count); + for (var i = 0; i < configured.Count; i++) + { + var section = configured[i]; + var cap = Math.Max(0, section.GetValue("XpCap", 500)); + offers.Add(new XpBoostOffer( + Id: OfferId(eventId, i), + Cost: Math.Max(0, section.GetValue("Cost", 250)), + XpMultiplier: Math.Max(1, section.GetValue("XpMultiplier", 2)), + XpCap: cap, + LookbackDurationTicks: TimeSpan.FromMinutes( + Math.Max(0, section.GetValue("LookbackMinutes", 60))).Ticks, + CooldownDurationTicks: TimeSpan.FromMinutes( + Math.Max(0, section.GetValue("CooldownMinutes", 1440))).Ticks)); + } + return offers; + } + + /// Deterministic per-(event, slot) boost id. The client caches + /// the guid from and replays it to + /// and to + /// storefronts/v1/buyProgressionEventXpBoost, whose purchase / + /// cooldown sentinel is keyed on it — a freshly generated guid per request + /// would make every preview miss its own catalogue entry and reset the + /// cooldown on every call. Derived rather than stored because the boost + /// catalogue itself lives in configuration, not the database. + private static Guid OfferId(long eventId, int index) => + new(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes($"dorknet:progressionEventXpBoost:{eventId}:{index}")) + .AsSpan(0, 16)); + private static long CurrentProgressionEventId() { var (start, _) = CurrentWeekWindow(); diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index d4395e4..f3e2662 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -2291,6 +2291,109 @@ public class RoomsController( public async Task CreatedByOther(long otherPlayerId) => Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList()); + /// Per-player showcase curation, stored in the existing generic + /// player-settings table as a CSV of room ids. Colon-namespaced to match + /// the other server-owned keys (settings:partyinvite). + private const string ShowcaseSettingKey = "rooms:showcase"; + private const int ShowcaseMaxRooms = 50; + + /// GET showcase/{accountId} — the profile "Showcase Rooms" + /// carousel (RoomListModel.JMGLGEKHHAK.PlayerShowcaseRooms = 7, + /// dump.cs:2042498, driven by + /// AccountModelController.RoomsShowcaseLinkImpl at dump.cs:2113041). + /// Distinct from PlayerCreatedRooms = 5, which is the + /// rooms/createdby/{id} list above. + /// + /// Verb. NLDBPDCNNCF.EJPNHLIJNPM formats the literal + /// "showcase/{0}" (NLDBPDCNNCF.txt ISIL 021 at :4484) and moves the + /// HTTPMethods ordinal 0 (= GET) into rdx immediately before the + /// dispatch call (ISIL 031/032 at :4494-4495). No cmov, so GET is the only + /// verb. The path arg is boxed as System.Int32 (ISIL 017) — the + /// account id. + /// + /// Response shape. The issuing method's return type is the + /// contract: Task<List<System.Int64>> + /// (NLDBPDCNNCF.txt:4422) — a BARE JSON array of room ids, not Room + /// objects and not a {Results,TotalResults} container. The Rooms the + /// UI renders are a purely client-side projection: the cache entry + /// <GetRoomsShowcase>b__0 is typed + /// Task<IReadOnlyList<Int64>> + /// (IBEOONPEELF_NestedType_HHDHJPIJBJB.txt:14) and the outer + /// IBEOONPEELF.EJPNHLIJNPM resolves those ids through the room cache + /// to FGLDKEJLAKB<IReadOnlyList<NEMINAEBALC>> + /// (IBEOONPEELF.txt:8952) before RoomListModel consumes it + /// (RoomListModel.txt ISIL 742 at :5819). + /// + /// Backing data. showcase/{0} is the only showcase + /// literal in the binary — the 2023 client can read the list but never + /// writes it (curation lived on the website), so the curated order is kept + /// in the existing per-player settings store under + /// and is writable through + /// PUT /settings/v1/{accountId} with no new schema. Curated ids are + /// re-validated against Rooms on every read so a room that has since been + /// archived or privatised drops off the profile, and the curated order is + /// preserved. A player who never curated one falls back to the rooms they + /// actually created, most prominent first — the showcase the carousel is + /// there to display. + /// + /// Unauthenticated on purpose: this is another player's profile + /// surface, and the sibling rooms/createdby/{id} is open too. The + /// owner viewing their own profile additionally sees their non-public + /// picks; everyone else sees public rooms only. + [HttpGet("showcase/{accountId:long}")] + // roomserver/ twin: EJPNHLIJNPM lives on NLDBPDCNNCF, whose requests all + // arrive with the roomserver/ prefix in this deployment + // (docs/recroom-2023-room-save.md:83-93) — same reason every other + // NLDBPDCNNCF route in this file is registered twice. + [HttpGet("roomserver/showcase/{accountId:long}")] + public async Task RoomsShowcase(long accountId) + { + var viewerIsOwner = CurrentPlayerId == accountId; + + var curatedCsv = await db.PlayerSettings.AsNoTracking() + .Where(s => s.PlayerId == accountId && s.Key == ShowcaseSettingKey) + .Select(s => s.Value) + .FirstOrDefaultAsync(); + + var curatedIds = (curatedCsv ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(v => long.TryParse(v, out var id) ? id : 0L) + .Where(id => id > 0) + .Distinct() + .Take(ShowcaseMaxRooms) + .ToList(); + + if (curatedIds.Count > 0) + { + var visible = await db.Rooms.AsNoTracking() + .Where(r => curatedIds.Contains(r.Id) + && r.State == 0 + && !r.IsDormRoom + && !r.HiddenFromBrowse + && (r.Accessibility == 1 || viewerIsOwner)) + .Select(r => r.Id) + .ToListAsync(); + var visibleSet = visible.ToHashSet(); + // Curated order is the display order — filter, don't re-sort. + var ordered = curatedIds.Where(visibleSet.Contains).ToList(); + if (ordered.Count > 0) return Ok(ordered); + } + + var fallback = await db.Rooms.AsNoTracking() + .Where(r => r.CreatorPlayerId == accountId + && r.State == 0 + && !r.IsDormRoom + && !r.HiddenFromBrowse + && (r.Accessibility == 1 || viewerIsOwner)) + .OrderByDescending(r => r.HotScore) + .ThenByDescending(r => r.VisitCount) + .ThenByDescending(r => r.UpdatedAt) + .Select(r => r.Id) + .Take(ShowcaseMaxRooms) + .ToListAsync(); + return Ok(fallback); + } + [HttpGet("api/rooms/v1/visitedby/me")] [HttpGet("api/rooms/v2/visitedby/me")] [HttpGet("api/rooms/v3/visitedby/me")] diff --git a/DorkNet.Server/Controllers/API/Subscriptions/SubscriptionsController.cs b/DorkNet.Server/Controllers/API/Subscriptions/SubscriptionsController.cs new file mode 100644 index 0000000..127d22b --- /dev/null +++ b/DorkNet.Server/Controllers/API/Subscriptions/SubscriptionsController.cs @@ -0,0 +1,194 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Binding; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Subscriptions; + +/// +/// The creator-subscription mutation pair the 2023-03-21 client hits from a +/// profile card's Subscribe button. Both live at the bare two-segment route +/// subscription/{accountId} on the Clubs host (enum value 13); the +/// route literal is "subscription/{0}" formatted with a boxed +/// System.Int32 account id at +/// RecNet.Runtime/IKMMOCKDKAF.txt:6551 (POST, verb rdx=2) and +/// :6862 (DELETE, verb rdx=4). Callers are the account-model wrappers +/// OBEEEEAMLJI.BDJCLAGIHBN/LPFOPPHGDHG(Int32 accountId, …) +/// (Assembly-CSharp/OBEEEEAMLJI.txt:644,793) behind +/// AGUI/StackedUI/SubscribeConditionalButton, so the path segment is +/// always a player account id — never a club id. +/// +/// The GET side of this surface already exists but lives with the club reads +/// (ClubsController: subscription/details/{id}, +/// subscription/subscriberCount/{id}, subscription/mine/member) +/// and PlayerSubscriptionsController +/// (subscription/top/creators/today). Only the two writes were missing, +/// which made every subscribe/unsubscribe 404 and surface the client's +/// "Failed to subscribe to {0}" / "Failed to unsubscribe from {0}" +/// toast (format literals at IKMMOCKDKAF.txt:6663 and the DELETE's +/// matching continuation). +/// +/// Neither response body is parsed: the client's continuation is a bare +/// Func<IPCJLCNIBEG<Int64>, …> whose only use is the +/// success/failure branch, after which it invalidates its subscription cache +/// and re-reads subscription/mine/member + subscription/details. +/// Any 2xx is therefore sufficient — but the reads it follows up with must +/// reflect the write, which is why these handlers touch BOTH subscription +/// tables (see ). +/// +[ApiController] +[Authorize] +public class SubscriptionsController( + DorkNetDbContext db, + ClubService clubs, + ILogger logger) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + + /// + /// Body of the subscribe POST. The client appends exactly one + /// form-urlencoded field via BNDIAONDFFF.AFGEDDANEKP("roomId", …) + /// (IKMMOCKDKAF.txt:6726 — Move rdx, "roomId"), boxed from an + /// ObscuredLong holding the room the player is standing in. The + /// preceding compare against -1 (:6713, + /// Compare rax, 18446744073709551615 → jump past the append) means + /// the field is OMITTED entirely when the player is not in a room, so it + /// must be optional. Unsubscribe sends no body at all. + /// + public sealed class SubscribeRequest + { + public long RoomId { get; set; } = -1; + } + + /// + /// POST /subscription/{accountId} — subscribe to a creator. + /// + /// Writes two rows, because the 2023 client's post-subscribe refresh reads + /// two differently-sourced endpoints and both have to agree: + /// * (player→player) is what + /// subscription/details/{id}, subscription/subscriberCount/{id}, + /// subscription/top/creators/today and the PlayerState reputation + /// aggregate all count. This is the canonical row. + /// * is what + /// subscription/mine/member and + /// announcements/v2/subscription/mine/unread read. A creator's + /// "creator club" is the club they own (same resolution + /// subscription/details uses for its ClubId field — oldest + /// id wins), so when the target owns one we mirror the subscription + /// there too. Without the mirror the subscribe succeeds but the + /// creator never appears in the client's own subscribed list and the + /// button flips straight back to "Subscribe" on the next cache refresh. + /// Creators with no club get only the canonical row, which is all their + /// profile card reads. + /// + /// Idempotent: re-subscribing is a 200 no-op rather than a unique-index + /// violation (both tables carry a unique pair index). + /// + [HttpPost("/subscription/{accountId:long}")] + public async Task Subscribe( + long accountId, + [ModelBinder(typeof(FormOrJsonModelBinder))] SubscribeRequest req) + { + var me = Me; + if (accountId == me) return BadRequest(new { Error = "cannot_subscribe_to_self" }); + + var targetExists = await db.Players.AnyAsync(p => p.Id == accountId); + if (!targetExists) return NotFound(new { Error = "account_not_found" }); + + var changed = false; + + var existing = await db.Subscriptions.FirstOrDefaultAsync( + s => s.SubscriberPlayerId == me && s.TargetPlayerId == accountId); + if (existing is null) + { + db.Subscriptions.Add(new SubscriptionEntity + { + SubscriberPlayerId = me, + TargetPlayerId = accountId, + }); + changed = true; + } + + var creatorClubId = await CreatorClubIdAsync(accountId); + if (creatorClubId != 0) + { + var existingClub = await db.ClubSubscriptions.FirstOrDefaultAsync( + s => s.PlayerId == me && s.ClubId == creatorClubId); + if (existingClub is null) + { + db.ClubSubscriptions.Add(new ClubSubscriptionEntity + { + ClubId = creatorClubId, + PlayerId = me, + }); + changed = true; + } + } + + if (changed) await db.SaveChangesAsync(); + + // roomId is pure attribution — "which room was this creator discovered + // in". There is no column for it on SubscriptionEntity and this pass + // adds no schema, so it is recorded in the log rather than silently + // dropped; the subscription itself is fully persisted above. + if (req.RoomId > 0) + { + logger.LogInformation( + "Player {SubscriberId} subscribed to creator {AccountId} from room {RoomId}.", + me, accountId, req.RoomId); + } + + return Ok(); + } + + /// + /// DELETE /subscription/{accountId} — unsubscribe. Same route + /// literal, verb register rdx=4 at + /// IKMMOCKDKAF.txt:6864; the request carries no parameters + /// (AFGEDDANEKP is never called on the builder before the send), + /// so there is nothing to bind. + /// + /// Removes both rows written by so the canonical + /// counts and the subscription/mine/member list drop the creator + /// together. Unsubscribing when not subscribed is a 200 no-op — the client + /// treats any non-2xx as the "Failed to unsubscribe from {0}" toast, and a + /// double-tap of an already-removed subscription is not a failure the + /// player should see. + /// + [HttpDelete("/subscription/{accountId:long}")] + public async Task Unsubscribe(long accountId) + { + var me = Me; + + var row = await db.Subscriptions.FirstOrDefaultAsync( + s => s.SubscriberPlayerId == me && s.TargetPlayerId == accountId); + if (row is not null) db.Subscriptions.Remove(row); + + var creatorClubId = await CreatorClubIdAsync(accountId); + if (creatorClubId != 0) + { + var clubRow = await db.ClubSubscriptions.FirstOrDefaultAsync( + s => s.PlayerId == me && s.ClubId == creatorClubId); + if (clubRow is not null) db.ClubSubscriptions.Remove(clubRow); + } + + await db.SaveChangesAsync(); + return Ok(); + } + + /// + /// The club id subscription/details/{accountId} reports as the + /// creator's ClubId: the oldest live club the account owns, or 0 + /// when they own none. Kept byte-identical to that handler's resolution so + /// subscribing writes the row the details card points the player at. + /// + private async Task CreatorClubIdAsync(long accountId) + { + var created = await clubs.CreatedByAsync(accountId); + return created.Count > 0 ? created.Min(c => c.Id) : 0L; + } +} diff --git a/DorkNet.Server/Controllers/Accounts/AccountsController.cs b/DorkNet.Server/Controllers/Accounts/AccountsController.cs index 3d71509..96970fc 100644 --- a/DorkNet.Server/Controllers/Accounts/AccountsController.cs +++ b/DorkNet.Server/Controllers/Accounts/AccountsController.cs @@ -26,6 +26,7 @@ public class AccountsController( DorkNetDbContext db, NotificationService notifications, ServerSettingsService settings, + PlayerPresenceService presence, ILogger logger) : ControllerBase { [HttpGet("/account/v1/savedlogins")] @@ -62,6 +63,102 @@ public class AccountsController( return Ok(BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName)); } + /// + /// DELETE /account/me — the 2023 client's "delete local account" + /// (RecNet.Runtime OPMAPIOEIFG.ADFMLPGEBOF()). Binary evidence: + /// the request object is built at OPMAPIOEIFG.txt:8767-8777 with the + /// verb ordinal in rdx = 4 (BestHTTP.HTTPMethods.Delete) and the + /// path literal "account/me" at :8771; the failure branch at + /// :8790 carries the string "Failed to delete local account". + /// The issuing method returns LDGADANDBIO, the fire-and-forget + /// promise handle — the response BODY is never parsed, only the HTTP + /// status decides success — so the RecNetResult we emit is purely for + /// human/curl consumption. Registering only [HttpGet] on this + /// path (the previous state) produced a 405 and the in-game + /// "delete my account" button silently failed. + /// + /// Erasure runs through the same + /// cascade the admin SPA uses, inside one transaction: personal rows + /// are deleted, authored durable content is reassigned to the system + /// account, then the Players row goes and the live session is kicked + /// so the bearer token stops being useful immediately. + /// + [HttpDelete("/account/me")] + [HttpDelete("/account/v1/me")] + [HttpDelete("/account/v2/me")] + public async Task DeleteMe() + { + var player = await GetCurrentPlayerAsync(); + if (player is null) return Unauthorized(); + + // The system account (id 1) owns every reassigned artefact — losing + // it would orphan the whole DB — and admin accounts must be demoted + // through the SPA first so a compromised session can't nuke the + // server's only operator. Both refusals are surfaced as a non-2xx so + // the client's promise rejects and the player sees the failure + // rather than a phantom success. + if (player.Id == 1) + { + logger.LogWarning("[accounts] DELETE account/me refused — system account"); + return BadRequest(new RecNetResult + { + Success = false, + Error = "The system account cannot be deleted.", + }); + } + if (player.IsAdmin) + { + logger.LogWarning( + "[accounts] DELETE account/me refused — {Id} ({Username}) is an admin", + player.Id, player.Username); + return BadRequest(new RecNetResult + { + Success = false, + Error = "Admin accounts cannot self-delete. Demote the account first.", + }); + } + + var playerId = player.Id; + var username = player.Username; + + // Authored content is reassigned rather than destroyed. Preference + // order: the system account (id 1), else the lowest-id surviving + // account, else 0 (single-account server — nothing left to own it). + var systemPlayerId = await db.Players.AnyAsync(p => p.Id == 1) + ? 1L + : await db.Players.Where(p => p.Id != playerId) + .OrderBy(p => p.Id) + .Select(p => p.Id) + .FirstOrDefaultAsync(); + + // GetCurrentPlayerAsync goes through PlayerService.GetByIdAsync, + // which Include()s the Avatar and leaves it tracked on this same + // scoped DbContext. PurgeAsync deletes that avatar row with + // ExecuteDelete, so leaving it tracked would make the Players.Remove + // cascade emit a second DELETE for an already-gone row and throw + // DbUpdateConcurrencyException. Drop the graph, then re-read the + // Players row on its own. + db.ChangeTracker.Clear(); + var row = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId); + if (row is null) return Ok(new RecNetResult { Success = true, Error = string.Empty }); + + await using (var tx = await db.Database.BeginTransactionAsync()) + { + var summary = await PlayerAccountPurge.PurgeAsync(db, playerId, systemPlayerId); + db.Players.Remove(row); + await db.SaveChangesAsync(); + await tx.CommitAsync(); + logger.LogInformation( + "[accounts] DELETE account/me — removed {Id} ({Username}); {Summary}", + playerId, username, summary); + } + + await notifications.KickPlayerAsync(playerId, "This account has been deleted."); + presence.Clear(playerId); + + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + /// /// Resolves the player tied to the bearer token on the request. Returns /// null when no/invalid bearer is supplied — the caller decides whether @@ -692,8 +789,103 @@ public class AccountsController( return Ok(new { accountId = (int)accountId, bio = p?.Bio ?? string.Empty }); } + /// + /// GET /platformid/{accountId} — the 2023 client's + /// OPMAPIOEIFG.DPLOCBLKCEB(Int32 accountId). Binary evidence: + /// path literal "platformid/{0}" at OPMAPIOEIFG.txt:11056 with + /// the verb ordinal rdx = 0 (GET) at :11066, and the failure + /// string "Failed to GetPlatformId for accountId {0}" at :11102. + /// + /// RESPONSE SHAPE: the issuing method's return type is + /// FGLDKEJLAKB<System.String> — a BARE JSON string, not an + /// object. Emitting {"PlatformId":0} makes the strict reader hit + /// '{' where it wants '"' and throw. The legacy + /// /account/v1/{id}/platformid spelling is kept as an alias and + /// now returns the real stored value instead of a hardcoded 0. + /// + /// The client short-circuits and never issues the request when + /// accountId <= 0 (:11052-11053), so we only ever see real ids. + /// Unknown accounts get an empty string — "no platform identity" is the + /// honest answer and keeps the promise resolved rather than rejected. + /// + [HttpGet("/platformid/{accountId:long}")] [HttpGet("/account/v1/{accountId:long}/platformid")] - public IActionResult GetPlatformId(long accountId) => Ok(new { PlatformId = 0L }); + public async Task GetPlatformId(long accountId) + { + var platformId = await db.Players + .Where(p => p.Id == accountId) + .Select(p => p.LastPlatformId) + .FirstOrDefaultAsync(); + return Content( + System.Text.Json.JsonSerializer.Serialize(platformId ?? string.Empty), + "application/json"); + } + + /// + /// GET /accounts/{accountId}/receives/{category} — the 2023 + /// client's platform-notification opt-in probe + /// (LELAJKMOMIA.GCIPPNCOKAJ(Int32 accountId)). Binary evidence: + /// path literal at LELAJKMOMIA.txt:695, verb ordinal rdx = 0 + /// (GET) at :705, service-host ordinal 15 (platformnotifications) at + /// :709. The {1} segment is a CCOKJMJOIJF enum value + /// boxed and String.Formated at :690-698, i.e. the enum MEMBER + /// NAME — the constant at this call site is value 8 (:693) but the + /// member names appear nowhere as literals in the binary, so the + /// segment MUST stay an unconstrained opaque string. + /// + /// RESPONSE SHAPE: the continuation is System.Action<Boolean> + /// over FGLDKEJLAKB<Boolean> (:721/:733) — a BARE JSON + /// boolean, no envelope. A 404 here rejects the promise inside AGUI's + /// AccountSpecificUIBehaviour and blocks the notification-gated action. + /// + /// Answered from the real row that + /// backs PUT /preferences: AllowAll gates everything, then + /// the per-category flag. Categories are resolved by the same ids the + /// platformnotifications config/categories catalogue publishes + /// (1 Messages, 2 Friend Requests, 3 Event Invites, 4 Announcements) and + /// by normalised name, so both the numeric and member-name spellings + /// land on the right column. A category we don't model (such as the + /// value-8 one this call site uses) falls back to AllowAll — the + /// player has no mute for it, which is the truthful answer, not a stub. + /// + [HttpGet("/accounts/{accountId:long}/receives/{category}")] + [HttpGet("/platformnotifications/accounts/{accountId:long}/receives/{category}")] + public async Task Receives(long accountId, string category) + { + var exists = await db.Players.AnyAsync(p => p.Id == accountId); + if (!exists) return Content("false", "application/json"); + + var prefs = await db.NotificationPrefs + .FirstOrDefaultAsync(p => p.PlayerId == accountId); + // No row at all = never opted out of anything. + if (prefs is null) return Content("true", "application/json"); + + var receives = prefs.AllowAll && CategoryAllowed(prefs, category); + return Content(receives ? "true" : "false", "application/json"); + } + + /// Maps the opaque {category} segment onto the columns + /// of . Accepts the numeric + /// CategoryId published by config/categories as well as the enum + /// member name in any casing/punctuation; anything we don't model is + /// only gated by AllowAll. + private static bool CategoryAllowed(NotificationPrefsEntity prefs, string? category) + { + if (string.IsNullOrWhiteSpace(category)) return true; + var key = new string(category.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant(); + return key switch + { + "1" or "message" or "messages" or "directmessage" or "directmessages" + => prefs.AllowMessage, + "2" or "friendrequest" or "friendrequests" + => prefs.AllowFriendRequest, + "3" or "eventinvite" or "eventinvites" or "invite" or "invites" + => prefs.AllowEventInvite, + "4" or "announcement" or "announcements" or "news" + => prefs.AllowAnnouncements, + _ => true, + }; + } [HttpGet("/accountprivacysettings/{accountId:long}")] [HttpGet("/account/v1/privacysettings/{accountId:long}")] diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index c0ae192..59502d2 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -3325,132 +3325,12 @@ public class AdminController( // ── Helpers ────────────────────────────────────────────────────────── - private async Task DeletePlayerDataAsync(long playerId, long systemPlayerId) - { - var now = DateTime.UtcNow; - var deleted = 0; - var reassigned = 0; - - deleted += await db.Avatars.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.PlayerSettings.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.Relationships.Where(x => x.RequesterId == playerId || x.TargetId == playerId).ExecuteDeleteAsync(); - deleted += await db.CurrencyBalances.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.PlayerInventory.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.PlayerDevices.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.NotificationPrefs.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.CohortAssignments.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.PushTokens.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.PlatformIgnores.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.Cards.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.ObjectiveProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.GameRewardSelections.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.CouponRedemptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.LeaderboardStats.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.PlayerElo.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.RoyalePlayerProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.RoyaleMatchPlayers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - - deleted += await db.Messages.Where(x => x.SenderPlayerId == playerId || x.RecipientPlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.ChatMessages.Where(x => x.SenderPlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.ChatThreadMembers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - reassigned += await db.ChatThreads - .Where(x => x.CreatorPlayerId == playerId) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId)); - - deleted += await db.Cheers.Where(x => x.FromPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.Reports.Where(x => x.ReporterPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.BugReports.Where(x => x.ReporterPlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.Subscriptions.Where(x => x.SubscriberPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync(); - - deleted += await db.ClubMemberships.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.ClubSubscriptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.ClubAnnouncementReads.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - reassigned += await db.Clubs - .Where(x => x.CreatorPlayerId == playerId) - .ExecuteUpdateAsync(s => s - .SetProperty(x => x.CreatorPlayerId, systemPlayerId) - .SetProperty(x => x.UpdatedAt, now)); - reassigned += await db.ClubAnnouncements - .Where(x => x.AuthorPlayerId == playerId) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.AuthorPlayerId, systemPlayerId)); - - deleted += await db.RoomBookmarks.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.RoomVisits.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.RoomRoles.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.RoomBans.Where(x => x.BannedPlayerId == playerId).ExecuteDeleteAsync(); - reassigned += await db.RoomBans - .Where(x => x.BannedByPlayerId == playerId) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.BannedByPlayerId, systemPlayerId)); - reassigned += await db.RoomRoles - .Where(x => x.GrantedByPlayerId == playerId) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.GrantedByPlayerId, systemPlayerId)); - reassigned += await db.Rooms - .Where(x => x.CreatorPlayerId == playerId) - .ExecuteUpdateAsync(s => s - .SetProperty(x => x.CreatorPlayerId, systemPlayerId) - .SetProperty(x => x.UpdatedAt, now)); - reassigned += await db.RoomDataBlobs - .Where(x => x.UploadedByPlayerId == playerId) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.UploadedByPlayerId, systemPlayerId)); - reassigned += await db.RoomKeys - .Where(x => x.CreatorPlayerId == playerId) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId)); - deleted += await db.RoomKeyPurchases.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - - deleted += await db.PlaylistInteractions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - reassigned += await db.Playlists - .Where(x => x.CreatorPlayerId == playerId) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId)); - - deleted += await db.PlayerEventResponses - .Where(x => x.PlayerId == playerId - || db.PlayerEvents.Where(e => e.CreatorPlayerId == playerId).Select(e => e.Id).Contains(x.EventId)) - .ExecuteDeleteAsync(); - deleted += await db.PlayerEvents.Where(x => x.CreatorPlayerId == playerId).ExecuteDeleteAsync(); - - deleted += await db.PrivateInstanceInvitees.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - var privateInstanceIds = await db.PrivateInstances - .Where(x => x.OwnerPlayerId == playerId) - .Select(x => x.Id) - .ToListAsync(); - if (privateInstanceIds.Count > 0) - { - deleted += await db.PrivateInstanceInvitees - .Where(x => privateInstanceIds.Contains(x.PrivateInstanceId)) - .ExecuteDeleteAsync(); - deleted += await db.PrivateInstances - .Where(x => privateInstanceIds.Contains(x.Id)) - .ExecuteDeleteAsync(); - } - - deleted += await db.GiftPackages - .Where(x => x.RecipientPlayerId == playerId) - .ExecuteDeleteAsync(); - if (playerId <= int.MaxValue) - { - reassigned += await db.GiftPackages - .Where(x => x.FromPlayerId == (int)playerId) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.FromPlayerId, (int?)null)); - } - - reassigned += await db.Inventions - .Where(x => x.CreatorPlayerId == playerId) - .ExecuteUpdateAsync(s => s - .SetProperty(x => x.CreatorPlayerId, systemPlayerId) - .SetProperty(x => x.UpdatedAt, now)); - reassigned += await db.Photos - .Where(x => x.UploaderPlayerId == playerId) - .ExecuteUpdateAsync(s => s - .SetProperty(x => x.UploaderPlayerId, systemPlayerId) - .SetProperty(x => x.DeletedAt, now) - .SetProperty(x => x.IsPublic, false)); - - reassigned += await db.SignupCodes - .Where(x => x.RedeemedByPlayerId == playerId) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.RedeemedByPlayerId, (long?)null)); - - return $"deleted_rows={deleted} reassigned_rows={reassigned}"; - } + /// Delegates to — + /// the cascade is shared with the 2023 client's self-service + /// DELETE account/me so both paths erase exactly the same set + /// of rows. + private Task DeletePlayerDataAsync(long playerId, long systemPlayerId) + => PlayerAccountPurge.PurgeAsync(db, playerId, systemPlayerId); private Task LogAsync(string action, string targetType, long targetId, string reason) { diff --git a/DorkNet.Server/Controllers/Chat/ChatController.cs b/DorkNet.Server/Controllers/Chat/ChatController.cs index 32e8c79..34deafc 100644 --- a/DorkNet.Server/Controllers/Chat/ChatController.cs +++ b/DorkNet.Server/Controllers/Chat/ChatController.cs @@ -30,11 +30,23 @@ public class ChatController( DorkNetDbContext db, NotificationService notifications) : ControllerBase { + // MGGNHLHPHOF on the 2023 wire: Success=0, InvalidArguments=1, + // ThreadNotFound=2, MembershipNotFound=3, PlayerAlreadyOnThread=4, + // CannotMessagePlayer=5, InvalidCharacters=6, RecentlyLeftThread=7, + // ThreadTooLarge=8. private const int ChatSuccess = 0; private const int ChatInvalidArguments = 1; + private const int ChatThreadNotFound = 2; private const int ChatMembershipNotFound = 3; private const int ChatPlayerAlreadyOnThread = 4; + /// Bit markers on ClubMembershipEntity.Permissions: + /// 128 = pending invite/request, 256 = banned. Both mean "not on the + /// roster" — same test ClubService.MembershipTypeFromPerms + /// applies. + private const int ClubPendingFlag = 128; + private const int ClubBannedFlag = 256; + private long Me => this.RequireCurrentPlayerId(); /// List the caller's chat threads, most-recently-active @@ -64,10 +76,34 @@ public class ChatController( .Distinct() .ToListAsync(); + // 3. Club chats for every club I'm actually on the roster of — + // club threads carry no ChatThreadMembers rows (the roster + // IS the membership), so without this the "Club chats" + // filter on the thread list is always empty. Only clubs + // whose channel has been used are listed, so browsing the + // inbox never materialises empty threads. + var myClubKeys = (await db.ClubMemberships + .Where(m => m.PlayerId == Me && + (m.Permissions & ClubPendingFlag) == 0 && + (m.Permissions & ClubBannedFlag) == 0) + .Select(m => m.ClubId) + .ToListAsync()) + .Select(ClubKey) + .ToList(); + var activeClubKeys = myClubKeys.Count == 0 + ? new List() + : await db.ChatMessages + .Where(c => myClubKeys.Contains(c.ThreadKey)) + .Select(c => c.ThreadKey) + .Distinct() + .ToListAsync(); + var keys = sentOrReceived .Concat(memberKeys) + .Concat(activeClubKeys) .Distinct() .Where(k => !k.StartsWith("group:") || memberKeys.Contains(k)) + .Where(k => ClubIdFromKey(k) == 0 || activeClubKeys.Contains(k)) .ToList(); if (keys.Count == 0) return Ok(Array.Empty()); @@ -83,6 +119,7 @@ public class ChatController( .Where(m => m.PlayerId == Me && keys.Contains(m.ThreadKey)) .ToDictionaryAsync(m => m.ThreadKey, m => m); var participants = await LoadParticipantsAsync(keys); + var favorites = await LoadFavoritesAsync(keys); return Ok(latest .OrderByDescending(c => c.SentAt) @@ -95,7 +132,8 @@ public class ChatController( meta, member, playerIds ?? new List(), - latestMessage: c); + latestMessage: c, + isFavorited: favorites.Contains(c.ThreadKey)); }) .ToList()); } @@ -119,11 +157,83 @@ public class ChatController( .FirstOrDefaultAsync(m => m.ThreadKey == key && m.PlayerId == Me); var participants = await LoadParticipantsAsync(new[] { key }); participants.TryGetValue(key, out var playerIds); + var favorites = await LoadFavoritesAsync(new[] { key }); return Ok(ToWireThread( meta, member, playerIds ?? new List(), - messages: rows.OrderBy(c => c.SentAt).ToList())); + messages: rows.OrderBy(c => c.SentAt).ToList(), + isFavorited: favorites.Contains(key))); + } + + /// GET thread/club/{clubId} — the 2023-03-21 club + /// chat tab's only entry point. Binary evidence: RecNet.Runtime + /// DLDKCILCKNA.txt method BOOOAPKCCJI(Int64), instr + /// 045 Move rcx, "thread/club/{0}", verb constant + /// Move rdx, 0 at instr 054 = HTTPMethods.Get, query fields + /// maxCount (instr 067, default 10) and mode (instr + /// 079, default 0). The declared return type is + /// FGLDKEJLAKB<List<JNMKLHDFPOJ>>, so the body + /// must be a JSON ARRAY of chat threads even though a club has + /// exactly one channel — hence the one-element array. + /// + /// The three-segment path matched no template before, so this 404'd + /// and club chat could not open at all. + /// + /// is accepted and ignored: the client + /// always sends 0 and its semantics are not resolvable from the + /// ISIL (no other value is ever passed). + [HttpGet("/thread/club/{clubId:long}")] + public async Task GetClubThreads(long clubId, + [FromQuery] int maxCount = 10, + [FromQuery] int mode = 0) + { + _ = mode; + var take = Math.Clamp(maxCount, 1, 200); + + var club = await db.Clubs.FirstOrDefaultAsync(c => c.Id == clubId); + if (club is null) return Ok(Array.Empty()); + + // Only roster members see the club channel. Non-members get an + // empty array rather than a 403 — the client deserialises the + // body as a list before it ever looks at the status code. + var onRoster = await db.ClubMemberships.AnyAsync(m => + m.ClubId == clubId && m.PlayerId == Me && + (m.Permissions & ClubPendingFlag) == 0 && + (m.Permissions & ClubBannedFlag) == 0); + if (!onRoster) return Ok(Array.Empty()); + + var key = ClubKey(clubId); + var meta = await EnsureThreadRowAsync(key); + // Name the thread after the club the first time it materialises + // so the thread-list card has a label without a second fetch. + if (string.IsNullOrEmpty(meta.Name) && !string.IsNullOrEmpty(club.Name)) + { + meta.Name = club.Name.Length > 128 ? club.Name[..128] : club.Name; + meta.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + } + + var rows = await db.ChatMessages + .Where(c => c.ThreadKey == key) + .OrderByDescending(c => c.SentAt) + .Take(take) + .ToListAsync(); + var member = await db.ChatThreadMembers + .FirstOrDefaultAsync(m => m.ThreadKey == key && m.PlayerId == Me); + var participants = await LoadParticipantsAsync(new[] { key }); + participants.TryGetValue(key, out var playerIds); + var favorites = await LoadFavoritesAsync(new[] { key }); + + return Ok(new List> + { + ToWireThread( + meta, + member, + playerIds ?? new List(), + messages: rows.OrderBy(c => c.SentAt).ToList(), + isFavorited: favorites.Contains(key)), + }); } /// Recent messages for a thread, paginated by @@ -228,20 +338,8 @@ public class ChatController( // the sender's chat UI doesn't show its own outgoing message // until the next thread refresh. DM keys encode the two // participants directly; group chat threads fan out across the - // explicit membership table. - var recipients = new List(); - if (key.StartsWith("dm:") && TryParseDmKey(key, out var a, out var b)) - { - var other = a == Me ? b : a; - recipients.Add(other); - } - else if (key.StartsWith("group:")) - { - recipients = await db.ChatThreadMembers - .Where(m => m.ThreadKey == key && m.PlayerId != Me) - .Select(m => m.PlayerId) - .ToListAsync(); - } + // explicit membership table; club chats fan out over the roster. + var recipients = await ThreadRecipientsAsync(key); recipients.Add(Me); foreach (var rid in recipients) { @@ -341,7 +439,11 @@ public class ChatController( public async Task Leave(string chatThreadId) { chatThreadId = await ResolveThreadKeyAsync(chatThreadId); - if (chatThreadId.StartsWith("dm:")) + // DMs can't be left, and neither can club chats — club channel + // membership is the club roster, so "leaving" is leaving the + // club. Deleting the row here would only discard the caller's + // last-read pointer. + if (chatThreadId.StartsWith("dm:") || ClubIdFromKey(chatThreadId) != 0) return Ok(ChatInvalidArguments); var removed = await db.ChatThreadMembers .Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me) @@ -349,24 +451,40 @@ public class ChatController( return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound); } - public sealed record RenameRequest(string Name); + /// Rename payload. A plain class with a parameterless ctor + /// (not a positional record) because + /// constructs it with + /// Activator.CreateInstance and then sets properties. + public sealed class RenameRequest + { + public string? Name { get; set; } + } /// Set the thread's display name. Permission: any member /// of the thread can rename. For DMs the watch typically nudges /// users to pick a nickname after first send; group-chat creators - /// usually set the name at creation time. + /// usually set the name at creation time. + /// + /// The 2023-03-21 client sends this as a form POST, not a JSON PUT: + /// RecNet.Runtime DLDKCILCKNA.txt method + /// BKNMPEEMNPB(Int64, String), instr 122 + /// Move rcx, "thread/{0}/rename" with verb constant + /// Move rdx, 2 at instr 131 (= HTTPMethods.Post) and a single + /// form field name added at instr 145 via + /// BNDIAONDFFF.AFGEDDANEKP. PUT-only + [FromBody] gave + /// that client a 405 (and a 415 even if it had used PUT). The + /// response is consumed by an Action<MGGNHLHPHOF> + /// (instr 162) — a BARE integer body, not a wrapper object. [HttpPut("/thread/{chatThreadId}/rename")] - public async Task Rename(string chatThreadId, [FromBody] RenameRequest body) + [HttpPost("/thread/{chatThreadId}/rename")] + public async Task Rename(string chatThreadId, + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameRequest body) { chatThreadId = await ResolveThreadKeyAsync(chatThreadId); - var name = (body.Name ?? string.Empty).Trim(); + var name = (body?.Name ?? string.Empty).Trim(); if (name.Length > 128) name = name[..128]; - var iAmMember = chatThreadId.StartsWith("dm:") - ? IsDmParticipant(chatThreadId, Me) - : await db.ChatThreadMembers - .AnyAsync(m => m.ThreadKey == chatThreadId && m.PlayerId == Me); - if (!iAmMember) return Ok(ChatMembershipNotFound); + if (!await IsThreadMemberAsync(chatThreadId)) return Ok(ChatMembershipNotFound); var meta = await db.ChatThreads.FirstOrDefaultAsync(t => t.ThreadKey == chatThreadId); if (meta is null) @@ -384,6 +502,112 @@ public class ChatController( return Ok(ChatSuccess); } + /// Favourite payload — plain class for the same + /// reason as + /// . + public sealed class FavoriteRequest + { + public bool Favorite { get; set; } + } + + /// Star / unstar a chat thread for the caller. + /// + /// Binary evidence: RecNet.Runtime DLDKCILCKNA.txt method + /// DHKDPHCNLKD(Int64, Boolean) — instr 106 + /// Move rcx, "thread/{0}/favorite", verb constant + /// Move rdx, 3 at instr 115 (= HTTPMethods.Put), single form + /// field favorite (Boolean, boxed at instr 122, added at + /// instr 128). No route existed at all, so this 404'd. + /// + /// RESPONSE SHAPE: the continuation at instr 142 is + /// Func<GLIKNMPPEHL, MGGNHLHPHOF> — a client-side + /// PROJECTION, so the body is the wrapper object + /// GLIKNMPPEHL, NOT the bare chat-result int the method's + /// return type suggests. Its reader (OGCMIDEFOEE.txt instrs + /// 042/069) accepts exactly two keys, each in the usual three + /// casings: ChatResult and IsFavorited. + /// + /// The flag is per-(player, thread) and there is no column for it on + /// , so it is persisted in the + /// generic PlayerSettings table under + /// chat.favorite:<threadKey> and read back into the + /// thread DTO's IsFavorited by + /// . + [HttpPut("/thread/{chatThreadId}/favorite")] + [HttpPost("/thread/{chatThreadId}/favorite")] + public async Task Favorite(string chatThreadId, + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] FavoriteRequest body) + { + var key = await ResolveThreadKeyAsync(chatThreadId); + if (!await db.ChatThreads.AnyAsync(t => t.ThreadKey == key) && + !await db.ChatMessages.AnyAsync(c => c.ThreadKey == key)) + { + return Ok(new Dictionary + { + ["chatResult"] = ChatThreadNotFound, + ["isFavorited"] = false, + }); + } + if (!await IsThreadMemberAsync(key)) + { + return Ok(new Dictionary + { + ["chatResult"] = ChatMembershipNotFound, + ["isFavorited"] = false, + }); + } + + var favorite = body?.Favorite ?? false; + var settingKey = FavoriteSettingKey(key); + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == Me && s.Key == settingKey); + if (favorite) + { + if (row is null) + { + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = Me, + Key = settingKey, + Value = "1", + }); + } + else + { + row.Value = "1"; + } + } + else if (row is not null) + { + db.PlayerSettings.Remove(row); + } + await db.SaveChangesAsync(); + + return Ok(new Dictionary + { + ["chatResult"] = ChatSuccess, + ["isFavorited"] = favorite, + }); + } + + /// Is the caller a participant of this thread? DM keys + /// carry both ids, group chats use the membership table, club chats + /// use the club roster. + private async Task IsThreadMemberAsync(string key) + { + if (key.StartsWith("dm:")) return IsDmParticipant(key, Me); + var clubId = ClubIdFromKey(key); + if (clubId != 0) + { + return await db.ClubMemberships.AnyAsync(m => + m.ClubId == clubId && m.PlayerId == Me && + (m.Permissions & ClubPendingFlag) == 0 && + (m.Permissions & ClubBannedFlag) == 0); + } + return await db.ChatThreadMembers + .AnyAsync(m => m.ThreadKey == key && m.PlayerId == Me); + } + public sealed record CreateGroupChatRequest(List MemberIds, string? Name); /// Create a group chat. Generates a fresh @@ -456,7 +680,10 @@ public class ChatController( .ToListAsync(); var meta = await EnsureThreadRowAsync(key); var participants = ids.Concat(new[] { Me }).Distinct().Select(x => (int)x).ToList(); - return Ok(ToWireThread(meta, null, participants, messages: rows.OrderBy(c => c.SentAt).ToList())); + var favorites = await LoadFavoritesAsync(new[] { key }); + return Ok(ToWireThread(meta, null, participants, + messages: rows.OrderBy(c => c.SentAt).ToList(), + isFavorited: favorites.Contains(key))); } /// Form-urlencoded variant of used by @@ -491,19 +718,7 @@ public class ChatController( // Notify every participant, sender INCLUDED — see SendMessageJson // above for the rationale; the 2020 watch's chat send path needs // the self-push to refresh its own UI. - var recipients = new List(); - if (key.StartsWith("dm:") && TryParseDmKey(key, out var a, out var b)) - { - var other = a == Me ? b : a; - if (other != Me) recipients.Add(other); - } - else if (key.StartsWith("group:")) - { - recipients = await db.ChatThreadMembers - .Where(m => m.ThreadKey == key && m.PlayerId != Me) - .Select(m => m.PlayerId) - .ToListAsync(); - } + var recipients = await ThreadRecipientsAsync(key); recipients.Add(Me); foreach (var rid in recipients) { @@ -513,9 +728,11 @@ public class ChatController( } var participants = await LoadParticipantsAsync(new[] { key }); participants.TryGetValue(key, out var playerIds); + var favorites = await LoadFavoritesAsync(new[] { key }); return Ok(new Dictionary { - ["chatThread"] = ToWireThread(thread, null, playerIds ?? new List(), latestMessage: entry), + ["chatThread"] = ToWireThread(thread, null, playerIds ?? new List(), + latestMessage: entry, isFavorited: favorites.Contains(key)), ["chatResult"] = ChatSuccess, }); } @@ -545,19 +762,7 @@ public class ChatController( db.ChatMessages.Add(entry); await db.SaveChangesAsync(); - var recipients = new List(); - if (key.StartsWith("dm:") && TryParseDmKey(key, out var a, out var b)) - { - var other = a == Me ? b : a; - if (other != Me) recipients.Add(other); - } - else if (key.StartsWith("group:")) - { - recipients = await db.ChatThreadMembers - .Where(m => m.ThreadKey == key && m.PlayerId != Me) - .Select(m => m.PlayerId) - .ToListAsync(); - } + var recipients = await ThreadRecipientsAsync(key); // Include the sender so the form-POST send path also refreshes // its own chat thread — same reason as the JSON send path above. recipients.Add(Me); @@ -761,15 +966,109 @@ public class ChatController( result[group.Key] = group.Select(m => (int)m.PlayerId).Distinct().ToList(); } + // Club threads have no ChatThreadMembers rows — membership IS + // the club roster, so joining/leaving the club joins/leaves the + // chat automatically. + var clubIds = distinct + .Select(k => new { Key = k, ClubId = ClubIdFromKey(k) }) + .Where(x => x.ClubId != 0) + .ToList(); + if (clubIds.Count > 0) + { + var ids = clubIds.Select(x => x.ClubId).Distinct().ToList(); + var roster = await db.ClubMemberships + .Where(m => ids.Contains(m.ClubId) && + (m.Permissions & ClubPendingFlag) == 0 && + (m.Permissions & ClubBannedFlag) == 0) + .Select(m => new { m.ClubId, m.PlayerId }) + .ToListAsync(); + foreach (var x in clubIds) + { + result[x.Key] = roster + .Where(m => m.ClubId == x.ClubId) + .Select(m => (int)m.PlayerId) + .Distinct() + .ToList(); + } + } + return result; } + /// PlayerSettings key holding the caller's per-thread + /// favourite flag. The flag is per-(player, thread) and there is no + /// column for it on , so it + /// lives in the same generic per-player settings table the rest of + /// the server uses for exactly this kind of flag (see + /// RoomsController's playerdata and InventionsController). + private const string FavoritePrefix = "chat.favorite:"; + + private static string FavoriteSettingKey(string threadKey) => FavoritePrefix + threadKey; + + /// Thread keys the caller has starred, out of + /// . + private async Task> LoadFavoritesAsync(IEnumerable keys) + { + var distinct = keys.Distinct().ToList(); + if (distinct.Count == 0) return new HashSet(); + var settingKeys = distinct.Select(FavoriteSettingKey).ToList(); + var rows = await db.PlayerSettings + .AsNoTracking() + .Where(s => s.PlayerId == Me && settingKeys.Contains(s.Key) && s.Value == "1") + .Select(s => s.Key) + .ToListAsync(); + return rows.Select(k => k[FavoritePrefix.Length..]).ToHashSet(); + } + + /// Players to push a new message to. DM keys carry both + /// participants; group chats fan out over the membership table; + /// club chats fan out over the club roster. + private async Task> ThreadRecipientsAsync(string key) + { + if (key.StartsWith("dm:") && TryParseDmKey(key, out var a, out var b)) + { + var other = a == Me ? b : a; + return other == Me ? new List() : new List { other }; + } + if (key.StartsWith("group:")) + { + return await db.ChatThreadMembers + .Where(m => m.ThreadKey == key && m.PlayerId != Me) + .Select(m => m.PlayerId) + .ToListAsync(); + } + var clubId = ClubIdFromKey(key); + if (clubId != 0) + { + return await db.ClubMemberships + .Where(m => m.ClubId == clubId && m.PlayerId != Me && + (m.Permissions & ClubPendingFlag) == 0 && + (m.Permissions & ClubBannedFlag) == 0) + .Select(m => m.PlayerId) + .Distinct() + .ToListAsync(); + } + return new List(); + } + + /// Project a thread row onto the client's chat-thread DTO. + /// The 2023-03-21 reader (RecNet.Runtime EKEFFNBIOHJ.txt, instrs + /// 082/109/133/149/173/197/221/245/269) matches exactly nine keys, + /// each in PascalCase / camelCase / lowercase: + /// ChatThreadId, LastReadMessageId, Messages, LatestMessage, + /// PlayerIds, ChatThreadName, SnoozedUntil, IsFavorited, ClubId. + /// IsFavorited and ClubId did not exist on the 2020 + /// watch's DTO, so they were missing here; both are emitted now. + /// LatestMessage is emitted alongside Messages because + /// the thread-list card binds the preview off it even when the full + /// message array is present. private static Dictionary ToWireThread( ChatThreadEntity thread, ChatThreadMemberEntity? member, List playerIds, ChatMessageEntity? latestMessage = null, - List? messages = null) + List? messages = null, + bool isFavorited = false) { var dto = new Dictionary { @@ -778,11 +1077,15 @@ public class ChatController( ["lastReadMessageId"] = member?.LastReadMessageId ?? 0, ["snoozedUntil"] = member?.SnoozeUntil, ["playerIds"] = playerIds, + ["isFavorited"] = isFavorited, + ["clubId"] = ClubIdFromKey(thread.ThreadKey), }; if (messages is not null) { dto["messages"] = messages.Select(m => ToWireMessage(m, thread.Id)).ToList(); + if (messages.Count > 0) + dto["latestMessage"] = ToWireMessage(messages[^1], thread.Id); } else if (latestMessage is not null) { @@ -792,6 +1095,18 @@ public class ChatController( return dto; } + /// Canonical thread key for a club's chat channel. One + /// thread per club — the 2023 client's club chat tab is a single + /// channel, and GET thread/club/{id} returns it wrapped in a + /// one-element array. + private static string ClubKey(long clubId) => $"club:{clubId}"; + + /// Inverse of ; 0 for non-club + /// threads, which is what the client's ClubId field carries + /// for DMs and group chats. + private static long ClubIdFromKey(string key) => + key.StartsWith("club:") && long.TryParse(key.AsSpan(5), out var id) ? id : 0; + private static Dictionary ToWireMessage(ChatMessageEntity c, long chatThreadId) => new() { ["chatMessageId"] = c.Id, diff --git a/DorkNet.Server/Controllers/Clubs/ClubsController.cs b/DorkNet.Server/Controllers/Clubs/ClubsController.cs index 7b10ae8..8639b1a 100644 --- a/DorkNet.Server/Controllers/Clubs/ClubsController.cs +++ b/DorkNet.Server/Controllers/Clubs/ClubsController.cs @@ -18,13 +18,33 @@ namespace DorkNet.Server.Controllers.Clubs; /// URL surface (verb + template) verified against /// Cpp2IL_ISIL/.../JDJGIBLMFKK.txt: /// GET /club/home/me +/// PUT /club/home/me (2023, form clubId) +/// DELETE /club/home/me (2023) /// GET /club/mine/member /// GET /club/mine/created /// GET /club/categoryTags +/// DELETE /club/{clubId} (2023, disband) +/// GET /club/{clubId}/hasDisabledClubChat (2023, bare bool) +/// DELETE /club/{clubId}/mainimage (2023) +/// GET /club/{clubId}/members (2023, bare array) +/// GET /club/{clubId}/members/banned (2023, bare array) +/// GET /club/{clubId}/members/requests (2023, bare array) +/// GET /club/{clubId}/members/search (2023, paged envelope) +/// GET /club/{clubId}/members/requests/search (2023, paged envelope) +/// GET /announcements/mine (2023, flat array) +/// GET /announcements/subscription/mine (2023, flat array) /// GET /announcements/v2/mine/unread /// GET /announcements/v2/subscription/mine/unread /// POST /announcements/club/{clubId}/{announcementId}/read /// +/// Still unserved for want of schema (see the per-route notes in +/// docs/recroom-2023-client-api-complete.md): PUT +/// club/{id}/minlevel, PUT club/{id}/clubChatEnabled, PUT +/// club/{id}/permissions/{role} and DELETE +/// club/{id}/additionalimage/{slot} — each needs storage that does +/// not exist yet (ClubEntity.MinLevel, ClubEntity.ClubChatEnabled, a +/// per-role club-permission table, a club additional-image table). +/// /// Wire types (deserialiser JSON keys, per ISIL): /// Club (2020 PLILLKHMNDA / 2023 FOIJDINBPFG): /// ClubId, Name, Description, MainImageName, State, @@ -67,6 +87,50 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle return Ok(ToWireClub(home, memberCount)); } + /// PUT /club/home/me — "Set Home Clubhouse Room". The + /// 2023 client sends the target club in a form-urlencoded + /// clubId field (boxed Nullable<Int64> pushed through + /// BNDIAONDFFF.AFGEDDANEKP at IKMMOCKDKAF.txt:16923-16927) + /// and parses NO response DTO — the issuing method is + /// LDGADANDBIO JOPMBFIFFBB(Nullable<Int64>), the status-only + /// promise type, so an empty 200 is the contract. Verb ordinal 3 (PUT) + /// is the HasValue branch of the same method at :16911; the null branch + /// is the DELETE below (:16838). + [HttpPut("/club/home/me")] + [Authorize] + public async Task HomeMeSet() + { + var pid = Me; + long clubId = 0; + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var key in new[] { "clubId", "ClubId" }) + if (long.TryParse(form[key].FirstOrDefault(), out var fromForm) && fromForm > 0) + { + clubId = fromForm; + break; + } + } + if (clubId == 0 && long.TryParse(Request.Query["clubId"].FirstOrDefault(), out var fromQuery)) + clubId = fromQuery; + if (clubId <= 0) return BadRequest(new { error = "missing_club_id" }); + if (!await clubs.SetHomeClubAsync(pid, clubId)) return NotFound(); + return Ok(); + } + + /// DELETE /club/home/me — "Remove Home Clubhouse Room". + /// No body, no response DTO (IKMMOCKDKAF.txt:16830-16841: route + /// literal, host 13, verb ordinal 4, error string "Failed to Remove Home + /// Clubhouse Room"). Idempotent so a double-tap is harmless. + [HttpDelete("/club/home/me")] + [Authorize] + public async Task HomeMeClear() + { + await clubs.ClearHomeClubAsync(Me); + return Ok(); + } + [HttpGet("/club/mine/member")] [Authorize] public async Task MineMember() @@ -163,6 +227,35 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle return Ok(GroupByClub(rows, await LastReadByClubAsync(pid, rows.Select(a => a.ClubId)))); } + /// GET /announcements/mine — every announcement across the + /// clubs the caller is a member of, newest first. Distinct from the v2 + /// unread rollup above in BOTH shape and semantics: the issuing method is + /// FGLDKEJLAKB<List<JDPPAFLFNBD>> IOCFHCFLIMJ() + /// (IKMMOCKDKAF.txt:996), i.e. a FLAT array of Announcement objects + /// with no per-club envelope and no unread filter — the request literals + /// at :1075 carry the description "get all announcements for clubs I'm + /// in". + [HttpGet("/announcements/mine")] + [Authorize] + public async Task AnnouncementsMine() + { + var rows = await clubs.AnnouncementsForMemberClubsAsync(Me); + return Ok(rows.Select(ToWireAnnouncement).ToList()); + } + + /// GET /announcements/subscription/mine — same flat + /// List<JDPPAFLFNBD> shape as + /// (IIOMOLFOLPD at + /// IKMMOCKDKAF.txt:1083, route literal at :1162) but scoped to the + /// clubs the caller SUBSCRIBES to rather than belongs to. + [HttpGet("/announcements/subscription/mine")] + [Authorize] + public async Task AnnouncementsSubscriptionMine() + { + var rows = await clubs.AnnouncementsForSubscribedClubsAsync(Me); + return Ok(rows.Select(ToWireAnnouncement).ToList()); + } + [HttpGet("/announcements/v2/subscription/mine/unread")] [Authorize] public async Task UnreadSubscription() @@ -271,6 +364,50 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle return Ok(ToWireClub(club, members)); } + /// DELETE /club/{id} — "Delete Club" (disband). Issuing + /// method is LDGADANDBIO MACDNDJNODA(Int64, String) — the string is + /// the club NAME used only for the client-side confirmation copy and is + /// never transmitted; the request is a bare DELETE with no body + /// (IKMMOCKDKAF.txt:17107-17120, verb ordinal 4, host 13, error + /// string "Failed to Delete Club"). LDGADANDBIO is the status-only + /// promise so an empty 200 is the whole contract. + /// + /// Owner-only, and a soft delete — see + /// . + [HttpDelete("/club/{clubId:long}")] + [Authorize] + public async Task ClubDelete(long clubId) + { + try + { + var disbanded = await clubs.DisbandAsync(clubId, Me); + if (disbanded is null) return NotFound(); + return Ok(); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + /// GET /club/{id}/hasDisabledClubChat — the gate the club + /// chat flow checks before it will even request the club's thread. The + /// issuing method is FGLDKEJLAKB<System.Boolean> OFOJAHBKMOJ(Int64) + /// (IKMMOCKDKAF.txt:25422, route + verb ordinal 0 at :25540-25547), + /// so the body is a BARE JSON boolean — not an object, not a wrapper. + /// + /// The answer is the negation of the Club wire field + /// ClubChatEnabled that emits. That field + /// has no column on yet and its setter + /// (PUT club/{id}/clubChatEnabled) is therefore not implemented, so + /// no club can currently be in the disabled state and false is the + /// true answer for every club. Both sites must flip together when the + /// column lands. + [HttpGet("/club/{clubId:long}/hasDisabledClubChat")] + public async Task ClubHasDisabledChat(long clubId) + { + var club = await clubs.GetByIdAsync(clubId); + if (club is null) return NotFound(); + return Ok(false); + } + /// GET /club/{id}/details — the central club page /// payload. Wire shape is PIHMJGCGNLP with required keys /// Club, CoownerPermissions, ModeratorPermissions, @@ -461,6 +598,32 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle catch (UnauthorizedAccessException) { return Forbid(); } } + /// DELETE /club/{id}/mainimage — "Remove Club Image". + /// One method, BLEDOCHHHJM(Int64, String imageName), drives both + /// verbs: IKMMOCKDKAF.txt:16010 tests the captured imageName and + /// branches to verb ordinal 4 with no body when it is null (:16019-16024, + /// description "Remove Club Image") or verb 3 with the form field when it + /// is not (:16046-16050). Both branches go through + /// IBAKMFKEEDJ, so the response is the full LCLFBBPEMIH + /// details envelope, NOT a status-only 200. + /// + /// Registered as its own action rather than a third binding on + /// so the delete semantics are explicit — + /// aliasing an edit verb onto a destructive handler is the exact bug that + /// made PUT /announcements/club/{c}/{a} destroy posts. + [HttpDelete("/club/{clubId:long}/mainimage")] + [Authorize] + public async Task ClubMainImageDelete(long clubId) + { + try + { + var updated = await clubs.ModifyAsync(clubId, Me, c => c.ImageName = string.Empty); + if (updated is null) return NotFound(); + return await BuildDetailsResponseAsync(updated.Id); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + /// POST/PUT /club/{id}/additionalimage/{slot} — the club /// gallery's per-slot image setter (IKMMOCKDKAF.txt:16214). /// @@ -496,6 +659,332 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle return Ok(ToWireMembership(row)); } + // ── Member lists (roster / banned / requests, + their searches) ── + + /// GET /club/{id}/members — the club page's member roster. + /// Issuing method is + /// FGLDKEJLAKB<List<CADEIMCFIIG>> GFOBOIIGDJF(Int64, GNLOJEONFIG?, MJOOBDNCHBO?, Int32?, Int32?) + /// (IKMMOCKDKAF.txt:19444) so the body is a bare JSON ARRAY of the + /// same membership row already serves — no paged + /// envelope. Verb ordinal 0 (GET) at :19746, query keys verbatim from + /// :19753/:19777/:19809/:19822: membershipType, sortBy, + /// skip, take. + /// + /// membershipType is the MJOOBDNCHBO wire value, not the stored + /// perms int, so it is compared after + /// . With no filter the + /// roster is real members only — ban markers and pending rows have their + /// own endpoints below and must not leak into the member list. + [HttpGet("/club/{clubId:long}/members")] + public async Task ClubMembers( + [FromRoute] long clubId, + [FromQuery] int? membershipType, + [FromQuery] int? sortBy, + [FromQuery] int? skip, + [FromQuery] int? take) + { + var club = await clubs.GetByIdAsync(clubId); + if (club is null) return NotFound(); + + var rows = await LoadMemberRowsAsync(clubId); + rows = membershipType is int wanted + ? rows.Where(r => r.MembershipType == wanted).ToList() + : rows.Where(r => r.MembershipType >= ClubService.MembershipTypeMember).ToList(); + + return Ok(Page(SortMembers(rows, sortBy ?? 0), skip ?? 0, take ?? 0) + .Select(r => ToWireMembership(r.Row)) + .ToList()); + } + + /// GET /club/{id}/members/banned — the banned list. + /// MBKDPCIOMDD(Int64, GNLOJEONFIG?, Int32?, Int32?) returns + /// List<ICPNBOOIDLI> (IKMMOCKDKAF.txt:18692): a bare + /// array whose rows carry only AccountId (int), ClubId + /// (long) and CreatedAt — there is no MembershipType field on + /// ICPNBOOIDLI (recnet-runtime-decomp/ICPNBOOIDLI.cs). GET at :18978, + /// query keys sortBy/skip/take at :18985/:19016/:19028. + /// + /// Rows are the perms=256 ban markers stamped by + /// . Moderator-gated: the ban list is staff-only + /// information. + [HttpGet("/club/{clubId:long}/members/banned")] + [Authorize] + public async Task ClubMembersBanned( + [FromRoute] long clubId, + [FromQuery] int? sortBy, + [FromQuery] int? skip, + [FromQuery] int? take) + { + var club = await clubs.GetByIdAsync(clubId); + if (club is null) return NotFound(); + if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid(); + + var rows = (await LoadMemberRowsAsync(clubId)) + .Where(r => (r.Row.Permissions & 256) != 0) + .ToList(); + + return Ok(Page(SortMembers(rows, sortBy ?? 0), skip ?? 0, take ?? 0) + .Select(r => ToWireBan(r.Row)) + .ToList()); + } + + /// GET /club/{id}/members/requests — pending join + /// requests / outstanding invites. + /// GBJMBMCGCDL(Int64, Int32?, Int32?) returns + /// List<AADIHDCMEDB> (IKMMOCKDKAF.txt:21086) — a bare + /// array, GET at :21325, only skip + take query keys + /// (:21338, :21350). Error copy "Failed to get pending club member + /// requests" at :21394 confirms the screen. + [HttpGet("/club/{clubId:long}/members/requests")] + [Authorize] + public async Task ClubMemberRequests( + [FromRoute] long clubId, + [FromQuery] int? skip, + [FromQuery] int? take) + { + var club = await clubs.GetByIdAsync(clubId); + if (club is null) return NotFound(); + if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid(); + + var rows = PendingRows(await LoadMemberRowsAsync(clubId)); + return Ok(Page(SortMembers(rows, 1), skip ?? 0, take ?? 0) + .Select(r => ToWireMemberRequest(r.Row)) + .ToList()); + } + + /// GET /club/{id}/members/search — the member-list search + /// box. PGMGPEKCNJP returns MBMNHFAPFCJ + /// (IKMMOCKDKAF.txt:18108), a PAGED envelope — + /// List<CADEIMCFIIG> + Int32 total + String continuation + /// token (recnet-runtime-decomp/MBMNHFAPFCJ.cs) — not the bare array the + /// unsearched roster returns. GET at :18475; query keys are DOT-PREFIXED + /// and must be read off rather than bound + /// as properties: parameters.name (:18484), + /// parameters.type (:18492), parameters.sortBy (:18521), + /// parameters.maxCount (:18554), plus an unprefixed + /// continuationToken (:18563). + /// + /// The envelope's own key names are the one thing the binary cannot give + /// up (they live in DataMember blobs in global-metadata.dat), so the + /// entity-named pair validated on the sibling club/search envelope + /// — Clubs/TotalClubs/ContinuationToken — is mirrored + /// here as Members/TotalMembers, with Results/ + /// TotalResults alongside as the alternate spelling. Json.NET + /// ignores whichever pair the DTO doesn't declare. + [HttpGet("/club/{clubId:long}/members/search")] + public async Task ClubMembersSearch(long clubId) + { + var club = await clubs.GetByIdAsync(clubId); + if (club is null) return NotFound(); + + var name = QueryValue("parameters.name", "name"); + var type = QueryInt("parameters.type", "type", "membershipType"); + var sortBy = QueryInt("parameters.sortBy", "sortBy"); + var maxCount = QueryInt("parameters.maxCount", "maxCount", "take"); + var offset = QueryInt("continuationToken") ?? 0; + + var rows = await LoadMemberRowsAsync(clubId); + rows = type is int wanted + ? rows.Where(r => r.MembershipType == wanted).ToList() + : rows.Where(r => r.MembershipType >= ClubService.MembershipTypeMember).ToList(); + rows = FilterByName(rows, name); + + var ordered = SortMembers(rows, sortBy ?? 0).ToList(); + var (page, nextToken) = Slice(ordered, offset, maxCount); + return Ok(PagedEnvelope("Members", "TotalMembers", + page.Select(r => ToWireMembership(r.Row)).ToList(), ordered.Count, nextToken)); + } + + /// GET /club/{id}/members/requests/search — search inside + /// the pending-requests screen. EDANLDCJECM returns + /// EDFOCLNECPM (IKMMOCKDKAF.txt:21448): the paged envelope + /// over List<AADIHDCMEDB>. GET at :21812; dot-prefixed query + /// keys parameters.name (:21821), parameters.sortBy + /// (:21829, the NAHBJCGNJKA enum — Default=0, RequestDate_Asc=1, + /// RequestDate_Desc=2, Username_Asc=3, Username_Desc=4), + /// parameters.maxCount (:21862), parameters.status (:21867, + /// MDFFODMAIGJ — Invited=0, Requested=1, Denied=2) and unprefixed + /// continuationToken (:21896). + /// + /// RequestDate_Asc/Desc index the same JoinedAt column JoinDate_Asc/Desc + /// do on the roster sort, so the two enums share + /// . The status filter can only ever + /// match Requested — see . + [HttpGet("/club/{clubId:long}/members/requests/search")] + [Authorize] + public async Task ClubMemberRequestsSearch(long clubId) + { + var club = await clubs.GetByIdAsync(clubId); + if (club is null) return NotFound(); + if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid(); + + var name = QueryValue("parameters.name", "name"); + var sortBy = QueryInt("parameters.sortBy", "sortBy"); + var maxCount = QueryInt("parameters.maxCount", "maxCount", "take"); + var status = QueryInt("parameters.status", "status"); + var offset = QueryInt("continuationToken") ?? 0; + + var rows = FilterByName(PendingRows(await LoadMemberRowsAsync(clubId)), name); + if (status is int wantedStatus) + rows = rows.Where(r => RequestStatusFor(r.Row) == wantedStatus).ToList(); + + var ordered = SortMembers(rows, sortBy ?? 0).ToList(); + var (page, nextToken) = Slice(ordered, offset, maxCount); + return Ok(PagedEnvelope("Requests", "TotalRequests", + page.Select(r => ToWireMemberRequest(r.Row)).ToList(), ordered.Count, nextToken)); + } + + /// A membership row paired with the account's username, which + /// every list endpoint above needs for the Username_Asc/Desc sorts and + /// the parameters.name filter. Loaded once per request rather than + /// per row. + private sealed record MemberRow(ClubMembershipEntity Row, string Username) + { + public int MembershipType => ClubService.MembershipTypeFromPerms(Row.Permissions); + } + + private async Task> LoadMemberRowsAsync(long clubId) + { + var rows = await db.ClubMemberships.AsNoTracking() + .Where(m => m.ClubId == clubId) + .ToListAsync(); + if (rows.Count == 0) return new List(); + + var ids = rows.Select(r => r.PlayerId).Distinct().ToList(); + var names = await db.Players.AsNoTracking() + .Where(p => ids.Contains(p.Id)) + .Select(p => new { p.Id, p.Username, p.DisplayName }) + .ToListAsync(); + var byId = names.ToDictionary( + n => n.Id, + n => string.IsNullOrWhiteSpace(n.Username) ? n.DisplayName : n.Username); + + return rows + .Select(r => new MemberRow(r, byId.GetValueOrDefault(r.PlayerId, string.Empty))) + .ToList(); + } + + /// Rows carrying the pending marker (128) and NOT the ban marker + /// — the join-request / invite queue. + private static List PendingRows(List rows) => rows + .Where(r => (r.Row.Permissions & 128) != 0 && (r.Row.Permissions & 256) == 0) + .ToList(); + + private static List FilterByName(List rows, string? name) + { + if (string.IsNullOrWhiteSpace(name)) return rows; + var needle = name.Trim(); + return rows + .Where(r => r.Username.Contains(needle, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + /// Shared ordering for GNLOJEONFIG (roster/banned) and + /// NAHBJCGNJKA (requests) — both enums are Default=0, date asc=1, + /// date desc=2, Username_Asc=3, Username_Desc=4 over the same JoinedAt + /// column (recnet-runtime-decomp/GNLOJEONFIG.cs, NAHBJCGNJKA.cs). + /// Default puts the most privileged roles first, which is the order the + /// club page renders its roster in. + private static IEnumerable SortMembers(IEnumerable rows, int sortBy) => sortBy switch + { + 1 => rows.OrderBy(r => r.Row.JoinedAt).ThenBy(r => r.Row.Id), + 2 => rows.OrderByDescending(r => r.Row.JoinedAt).ThenByDescending(r => r.Row.Id), + 3 => rows.OrderBy(r => r.Username, StringComparer.OrdinalIgnoreCase).ThenBy(r => r.Row.Id), + 4 => rows.OrderByDescending(r => r.Username, StringComparer.OrdinalIgnoreCase).ThenBy(r => r.Row.Id), + _ => rows.OrderByDescending(r => r.MembershipType) + .ThenBy(r => r.Row.JoinedAt) + .ThenBy(r => r.Row.Id), + }; + + /// skip/take paging for the three bare-array lists. take=0 means + /// "unspecified" (the client omits the param), so it falls back to a + /// 100-row page rather than returning nothing. + private static List Page(IEnumerable rows, int skip, int take) => rows + .Skip(Math.Max(skip, 0)) + .Take(take <= 0 ? 100 : Math.Clamp(take, 1, 500)) + .ToList(); + + /// Continuation-token paging for the two search envelopes. The + /// token is opaque to the client (a String on the DTO), so it carries the + /// next row offset; an empty string means "no more pages", which is what + /// the sibling club/search envelope already returns. + private static (List Page, string NextToken) Slice( + List ordered, int offset, int? maxCount) + { + var start = Math.Clamp(offset, 0, ordered.Count); + var size = maxCount is int m && m > 0 ? Math.Clamp(m, 1, 500) : 30; + var page = ordered.Skip(start).Take(size).ToList(); + var next = start + page.Count; + return (page, next < ordered.Count ? next.ToString() : string.Empty); + } + + private static Dictionary PagedEnvelope( + string listKey, string totalKey, List rows, int total, string continuationToken) => + new() + { + [listKey] = rows, + ["Results"] = rows, + [totalKey] = total, + ["TotalResults"] = total, + ["ContinuationToken"] = continuationToken, + }; + + /// First non-empty value across a set of query-string aliases. + /// The 2023 client prefixes the search DTO's fields with + /// parameters., which ASP.NET cannot bind to a plain property, so + /// these are read straight off the query collection. + private string? QueryValue(params string[] keys) + { + foreach (var key in keys) + { + var value = Request.Query[key].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(value)) return value; + } + return null; + } + + private int? QueryInt(params string[] keys) => + int.TryParse(QueryValue(keys), out var parsed) ? parsed : null; + + /// ICPNBOOIDLI — the banned-list row. Three fields only + /// (recnet-runtime-decomp/ICPNBOOIDLI.cs): int AccountId, long ClubId, + /// DateTime. CreatedAt is the membership row's JoinedAt, which + /// leaves at the original join time when it + /// stamps the ban marker onto an existing row. + private static object ToWireBan(ClubMembershipEntity row) => new + { + AccountId = (int)row.PlayerId, + ClubId = row.ClubId, + CreatedAt = row.JoinedAt, + }; + + /// AADIHDCMEDB — the pending-request row. Field list from + /// recnet-runtime-decomp/AADIHDCMEDB.cs: long id, int AccountId, int? + /// inviter account id, long ClubId, MJOOBDNCHBO MembershipType, + /// MDFFODMAIGJ status, DateTime. + /// + /// Two fields the current perms model genuinely cannot fill, rather than + /// invent: the inviter is not recorded anywhere (invites and requests + /// both collapse to the single 128 marker on the target's own row) so it + /// is emitted as null — the DTO declares it nullable — and the status is + /// always Requested for the same reason. Nothing in the accept/deny flow + /// reads either field: those calls key off accountId. + private static object ToWireMemberRequest(ClubMembershipEntity row) => new + { + Id = row.Id, + RequestId = row.Id, + AccountId = (int)row.PlayerId, + InviterAccountId = (int?)null, + ClubId = row.ClubId, + MembershipType = ClubService.MembershipTypeFromPerms(row.Permissions), + Status = RequestStatusFor(row), + CreatedAt = row.JoinedAt, + }; + + /// MDFFODMAIGJ: Invited=0, Requested=1, Denied=2. Only Requested + /// is reachable — see . + private static int RequestStatusFor(ClubMembershipEntity row) => 1; + // ── Member mutations (void / IPromise) ─────────────────────────── /// POST /club/{id}/members/requesttojoin — caller diff --git a/DorkNet.Server/Controllers/Link/LinkController.cs b/DorkNet.Server/Controllers/Link/LinkController.cs new file mode 100644 index 0000000..47ba3c5 --- /dev/null +++ b/DorkNet.Server/Controllers/Link/LinkController.cs @@ -0,0 +1,441 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; +using DorkNet.Server.Auth; +using DorkNet.Server.Binding; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace DorkNet.Server.Controllers.Link; + +/// +/// The Link host (GJDLNNLKDIJ.Link = host ordinal 11) share-code +/// service — RecNet.Runtime/CKBKHENHCAN. Every "share this" surface in +/// the 2023 client funnels through here: +/// RecRoom.Sharing.ActionCode subclasses (FriendCode, ReferralCode, +/// MeetupCode, ClubCode, PlayerEventCode, RoomCode, InfluencerCode, PhotoCode) +/// create an action link — a short code the player reads out or pastes, +/// which the receiving client redeems — while LinkManager stores the +/// larger opaque payload behind a share URL as a data link. +/// +/// The five routes and their verbs, read off the dispatch call in +/// CKBKHENHCAN.txt (the shape is +/// (this, verb=rdx, host=r8, path=r9, body=stack), verb being a +/// BestHTTP.HTTPMethods ordinal — GET=0, POST=2, PUT=3): +/// +/// GET actionlink/{code} — CGIEJDIHJBD (:635), verb +/// 045 Move rdx, 0 (:793), host 050 LoadAddress r8, [rdx+11] +/// (:798), literal "actionlink/" (:786). +/// POST actionlink — PCNPNDPAFLK (:860), verb +/// 106 Move rdx, 2 (:1226), host 105 Move r8, 11 (:1225), +/// literal "actionlink" (:1224). +/// POST actionlink/{code}/consume — MKAKECJOKDG (:1380), +/// verb 077 Move rdx, 2 (:1675), path built by +/// String.Concat("actionlink/", WebUtility.UrlEncode(code), "/consume") +/// (:1665-1669). +/// PUT datalink — ONPLPBLKACA (:419), verb +/// 033 Move rdx, 3 (:560), literal "datalink" (:558). +/// GET datalink/{code} — KEHDDPCHPGK (:194), verb +/// 045 Move rdx, 0 (:352), literal "datalink/" (:345). +/// +/// All five were unrouted, so every share/redeem flow 404'd. +/// +/// Storage. Codes live in PlayerSettings — the server's +/// existing general-purpose per-player key/value table, already used for +/// ban-appeal codes (CompatibilityFeatureController) and external +/// friend invites (ExternalFriendInviteController) — under +/// actionlink:{CODE} / datalink:{CODE} keys on the creator's +/// row. That keeps codes durable across restarts without a schema change; the +/// cost is the 1024-character cap, +/// which is enforced explicitly below rather than left to trip a Postgres +/// varchar(1024) constraint at SaveChanges. +/// +[ApiController] +public class LinkController(DorkNetDbContext db, ILogger logger) : ControllerBase +{ + private const string ActionPrefix = "actionlink:"; + private const string DataPrefix = "datalink:"; + + /// Unambiguous uppercase alphabet — no O/0/I/1 — because action + /// codes are read aloud and re-typed into + /// ActionCodeConsumptionModel's input field. + private const string Alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; + + /// PlayerSettingEntity.Value is [MaxLength(1024)]. + private const int ValueLimit = 1024; + + /// RecRoom.Sharing.CBKKGBIJHAL (2023.06.21 dump.cs:1248232): + /// Unknown=-1, Friend=0, Referral=1, Meetup=2, Club=3, PlayerEvent=4, + /// Room=5, Influencer=6, Photo=7. Confirmed live at the consume call sites + /// — RoomCode passes 030 Move r8, 5 + /// (RoomCode_NestedType_IBLIEDEHMPI.txt:175) and ClubCode passes + /// 029 Move r8, 3 (ClubCode_NestedType_EAGCMJOEANN.txt:85). + private const int CodeTypeReferral = 1; + + // ── actionlink ──────────────────────────────────────────────────────────── + + /// POST actionlink — mint a share code. + /// + /// Form fields are added one at a time by + /// BNDIAONDFFF.AFGEDDANEKP: data (:1234), validHours + /// (:1249), maxCount (:1261, boxed Nullable<Int32> — + /// omitted when null), codeType (:1273) and extraDataId + /// (:1287, boxed Nullable<Int64>). The explicit + /// [ModelBinder] is mandatory: [ApiController] would + /// otherwise re-infer [FromBody] and 415 the form post. + /// + /// The issuing method is + /// FGLDKEJLAKB<System.String> PCNPNDPAFLK(...) (:860), so the + /// body is a bare JSON string — the code itself. Ok(string) + /// would go out as unquoted text/plain via + /// StringOutputFormatter, which the client's String formatter + /// cannot read, hence the explicit Content(JsonSerializer.Serialize(…)) + /// (same pattern as the ban-appeal code handler). + [HttpPost("/actionlink")] + [Authorize] + public async Task ActionLinkCreate( + [ModelBinder(typeof(FormOrJsonModelBinder))] ActionLinkCreateRequest req) + { + var me = this.RequireCurrentPlayerId(); + var data = req.Data ?? string.Empty; + + var payload = new ActionLinkPayload + { + Data = data, + ExpiresAt = req.ValidHours > 0 + ? (DateTime?)DateTime.UtcNow.AddHours(req.ValidHours) + : null, + MaxCount = req.MaxCount is > 0 ? req.MaxCount : (int?)null, + Uses = 0, + CodeType = req.CodeType, + ExtraDataId = req.ExtraDataId, + }; + + var encoded = JsonSerializer.Serialize(payload); + if (encoded.Length > ValueLimit) + { + logger.LogWarning( + "[link] actionlink payload too large player={PlayerId} codeType={CodeType} bytes={Length} limit={Limit}", + me, req.CodeType, encoded.Length, ValueLimit); + return BadRequest(new { Error = "Action link data is too large to store." }); + } + + var code = await ReserveCodeAsync(ActionPrefix, 8); + if (code is null) return StatusCode(StatusCodes.Status503ServiceUnavailable); + + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = me, + Key = ActionPrefix + code, + Value = encoded, + }); + await db.SaveChangesAsync(); + + logger.LogInformation( + "[link] actionlink created code={Code} player={PlayerId} codeType={CodeType} validHours={ValidHours} maxCount={MaxCount}", + code, me, req.CodeType, req.ValidHours, req.MaxCount); + return Content(JsonSerializer.Serialize(code), "application/json"); + } + + /// GET actionlink/{code} — peek at a code without spending + /// a use. + /// + /// Anonymous on purpose: ActionCode.ModifyPreAuthLaunchTarget + /// resolves an inbound deep link during BootSequence, i.e. before + /// the player has a token. + /// + /// Return type FGLDKEJLAKB<NBAFGFFIOLK> (:635); the + /// generated formatter DPIECEMDKPL registers exactly three members — + /// CreatorPlayerId (:267, Int32), Data (:294, String), + /// IsValid (:310, Boolean) — each also in camelCase (:278/:302/:318) + /// and lowercase (:286/:326), so the server's camelCase anonymous-object + /// serialization binds. An unknown code answers 200 with + /// IsValid:false rather than 404 so the client always gets a body it + /// can parse instead of faulting the promise. + [HttpGet("/actionlink/{code}")] + [AllowAnonymous] + public async Task ActionLinkGet(string code) + { + var row = await FindAsync(ActionPrefix, code); + var payload = Decode(row); + if (row is null || payload is null) return Ok(ActionLinkResult(0, string.Empty, false)); + + var valid = IsUsable(payload); + return Ok(ActionLinkResult((int)row.PlayerId, valid ? payload.Data : string.Empty, valid)); + } + + /// POST actionlink/{code}/consume — redeem a code. + /// + /// Form fields: id (:1684 — the code again), validHours + /// (:1692, Nullable<Int32>), codeType (:1720), + /// newPlayer (:1732) and newInstall (:1745). Response shape + /// is the same NBAFGFFIOLK as the GET (:1380). + /// + /// validHours here is the creating ActionCode's + /// Configuration.AutoRenewHours — the call sites load it immediately + /// before dispatch (043 Call Configuration.get_AutoRenewHours, + /// RoomCode_NestedType_IBLIEDEHMPI.txt:168; ClubCode_NestedType_EAGCMJOEANN.txt:78) + /// and it is null for codes whose AutoRenew is off. So a + /// present value means "this code renews on use": the expiry is pushed out + /// that many hours instead of the code burning down. + /// + /// codeType is checked against the stored one — a Room code + /// (5) redeemed through the Club (3) flow would otherwise hand the club + /// handler a room payload to parse. + /// + /// Referral codes are the one type with no other server touchpoint: + /// ReferralCode has no HTTP routes of its own, and + /// IncentivizedReferralsController counts + /// referral:credited:{inviterId}:{inviteeId} rows that nothing else + /// writes. Redeeming one here as a new player is what credits the + /// inviter. + /// + /// Anonymous like the GET — the consume can fire from the pre-auth + /// launch-target path; the caller's id is only needed for referral + /// crediting. + [HttpPost("/actionlink/{code}/consume")] + [AllowAnonymous] + public async Task ActionLinkConsume( + string code, + [ModelBinder(typeof(FormOrJsonModelBinder))] ActionLinkConsumeRequest req) + { + // The client sends the code in the path AND as the `id` field; prefer + // the path, fall back to the field. + var wanted = string.IsNullOrWhiteSpace(code) ? req.Id : code; + var row = await FindAsync(ActionPrefix, wanted); + var payload = Decode(row); + if (row is null || payload is null) + { + logger.LogInformation("[link] actionlink consume miss code={Code}", Normalize(wanted)); + return Ok(ActionLinkResult(0, string.Empty, false)); + } + + if (!IsUsable(payload) || payload.CodeType != req.CodeType) + { + logger.LogInformation( + "[link] actionlink consume rejected code={Code} storedType={StoredType} askedType={AskedType} uses={Uses}/{MaxCount} expires={Expires}", + Normalize(wanted), payload.CodeType, req.CodeType, payload.Uses, payload.MaxCount, payload.ExpiresAt); + return Ok(ActionLinkResult((int)row.PlayerId, string.Empty, false)); + } + + payload.Uses++; + // AutoRenewHours present ⇒ the code renews rather than ages out. + if (req.ValidHours is > 0) payload.ExpiresAt = DateTime.UtcNow.AddHours(req.ValidHours.Value); + + var encoded = JsonSerializer.Serialize(payload); + if (encoded.Length <= ValueLimit) row.Value = encoded; + + var consumer = this.CurrentPlayerId(); + await CreditReferralAsync(payload, row.PlayerId, consumer, req.NewPlayer); + await db.SaveChangesAsync(); + + logger.LogInformation( + "[link] actionlink consumed code={Code} creator={Creator} consumer={Consumer} type={CodeType} uses={Uses} newPlayer={NewPlayer} newInstall={NewInstall}", + Normalize(wanted), row.PlayerId, consumer ?? 0, payload.CodeType, payload.Uses, req.NewPlayer, req.NewInstall); + return Ok(ActionLinkResult((int)row.PlayerId, payload.Data, true)); + } + + // ── datalink ────────────────────────────────────────────────────────────── + + /// PUT datalink — stash the payload behind a share URL. + /// + /// Single form field data (:569, added right after the + /// 033 Move rdx, 3 PUT dispatch at :560). Return type is + /// FGLDKEJLAKB<System.String> (:419) — a bare JSON string + /// holding the generated code, so the same explicit + /// Content(JsonSerializer.Serialize(…), "application/json") as the + /// action-link create. + /// + /// Codes are 10 characters here rather than 8: nobody types a + /// datalink code, it only ever travels inside a URL, so the extra entropy + /// is free. + [HttpPut("/datalink")] + [Authorize] + public async Task DataLinkCreate( + [ModelBinder(typeof(FormOrJsonModelBinder))] DataLinkCreateRequest req) + { + var me = this.RequireCurrentPlayerId(); + var data = req.Data ?? string.Empty; + if (data.Length > ValueLimit) + { + logger.LogWarning( + "[link] datalink payload too large player={PlayerId} bytes={Length} limit={Limit}", + me, data.Length, ValueLimit); + return BadRequest(new { Error = "Data link payload is too large to store." }); + } + + var code = await ReserveCodeAsync(DataPrefix, 10); + if (code is null) return StatusCode(StatusCodes.Status503ServiceUnavailable); + + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = me, + Key = DataPrefix + code, + Value = data, + }); + await db.SaveChangesAsync(); + + logger.LogInformation("[link] datalink created code={Code} player={PlayerId} bytes={Length}", + code, me, data.Length); + return Content(JsonSerializer.Serialize(code), "application/json"); + } + + /// GET datalink/{code} — resolve a share URL back to its + /// payload. + /// + /// Return type FGLDKEJLAKB<LBBKBMPAEIO> (:194) whose + /// generated formatter IKBPJACLIHH registers a single member, + /// Data (:139) — plus the data (:150) alias the server's + /// camelCase serializer emits. Anonymous, like the action-link read: this + /// is resolved from a cold-boot deep link. An unknown code returns an empty + /// Data rather than 404 so the strict reader still gets an + /// object. + [HttpGet("/datalink/{code}")] + [AllowAnonymous] + public async Task DataLinkGet(string code) + { + var row = await FindAsync(DataPrefix, code); + return Ok(new { Data = row?.Value ?? string.Empty }); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + /// Codes are stored uppercase so a player who types their share + /// code in lowercase still redeems it. + private static string Normalize(string? code) + => (code ?? string.Empty).Trim().ToUpperInvariant(); + + private Task FindAsync(string prefix, string? code) + { + var normalized = Normalize(code); + if (normalized.Length == 0) return Task.FromResult(null); + var key = prefix + normalized; + return db.PlayerSettings.FirstOrDefaultAsync(s => s.Key == key); + } + + /// Generates a code that is free across every player's rows (the + /// unique index is on (PlayerId, Key), but a code has to be globally + /// unique because lookups are by key alone). Retries a handful of times + /// before giving up rather than colliding on insert. + private async Task ReserveCodeAsync(string prefix, int length) + { + for (var attempt = 0; attempt < 6; attempt++) + { + var code = NewCode(length); + var key = prefix + code; + if (!await db.PlayerSettings.AnyAsync(s => s.Key == key)) return code; + } + logger.LogError("[link] exhausted code-generation attempts for prefix={Prefix}", prefix); + return null; + } + + private static string NewCode(int length) + { + var chars = new char[length]; + for (var i = 0; i < length; i++) + chars[i] = Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)]; + return new string(chars); + } + + private ActionLinkPayload? Decode(PlayerSettingEntity? row) + { + if (row is null) return null; + try + { + return JsonSerializer.Deserialize(row.Value); + } + catch (JsonException ex) + { + logger.LogWarning(ex, "[link] unreadable action-link row id={RowId} key={Key}", row.Id, row.Key); + return null; + } + } + + private static bool IsUsable(ActionLinkPayload payload) + { + if (payload.ExpiresAt is { } expiry && expiry <= DateTime.UtcNow) return false; + if (payload.MaxCount is { } max && payload.Uses >= max) return false; + return true; + } + + /// Records "consumer was referred by creator" in the + /// shape IncentivizedReferralsController already reads + /// (referral:credited:{inviterId}:{inviteeId}, counted by key prefix + /// regardless of which row owns it — stored on the invitee, matching the + /// externalinvite:redeemed:* convention). Only fires for referral + /// codes redeemed by a signed-in, brand-new player who is not the + /// creator. + private async Task CreditReferralAsync( + ActionLinkPayload payload, long creatorId, long? consumerId, bool newPlayer) + { + if (payload.CodeType != CodeTypeReferral) return; + if (!newPlayer) return; + if (consumerId is not { } consumer || consumer == creatorId) return; + + var key = $"referral:credited:{creatorId}:{consumer}"; + if (await db.PlayerSettings.AnyAsync(s => s.PlayerId == consumer && s.Key == key)) return; + + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = consumer, + Key = key, + Value = DateTime.UtcNow.ToString("O"), + }); + logger.LogInformation("[link] referral credited inviter={Inviter} invitee={Invitee}", creatorId, consumer); + } + + private static object ActionLinkResult(int creatorPlayerId, string data, bool isValid) => new + { + CreatorPlayerId = creatorPlayerId, + Data = data, + IsValid = isValid, + }; + + /// Everything the client told us at create time, packed into the + /// single PlayerSettings.Value string. Keys are one character each + /// to leave as much of the 1024-character budget as possible for + /// . + private sealed class ActionLinkPayload + { + [JsonPropertyName("d")] public string Data { get; set; } = string.Empty; + [JsonPropertyName("e")] public DateTime? ExpiresAt { get; set; } + [JsonPropertyName("m")] public int? MaxCount { get; set; } + [JsonPropertyName("u")] public int Uses { get; set; } + [JsonPropertyName("t")] public int CodeType { get; set; } + [JsonPropertyName("x")] public long? ExtraDataId { get; set; } + } +} + +/// Form fields of POST actionlink — CKBKHENHCAN.txt:1234 +/// (data), :1249 (validHours), :1261 (maxCount), :1273 +/// (codeType), :1287 (extraDataId). +public sealed class ActionLinkCreateRequest +{ + public string? Data { get; set; } + public int ValidHours { get; set; } + public int? MaxCount { get; set; } + public int CodeType { get; set; } + public long? ExtraDataId { get; set; } +} + +/// Form fields of POST actionlink/{code}/consume — +/// CKBKHENHCAN.txt:1684 (id), :1692 (validHours), :1720 +/// (codeType), :1732 (newPlayer), :1745 +/// (newInstall). +public sealed class ActionLinkConsumeRequest +{ + public string? Id { get; set; } + public int? ValidHours { get; set; } + public int CodeType { get; set; } + public bool NewPlayer { get; set; } + public bool NewInstall { get; set; } +} + +/// Sole form field of PUT datalink — CKBKHENHCAN.txt:569. +public sealed class DataLinkCreateRequest +{ + public string? Data { get; set; } +} diff --git a/DorkNet.Server/Data/url-coverage.md b/DorkNet.Server/Data/url-coverage.md index 89ab964..37a3d1e 100644 --- a/DorkNet.Server/Data/url-coverage.md +++ b/DorkNet.Server/Data/url-coverage.md @@ -161,6 +161,7 @@ Note: counts are approximate because several client URLs (`api/avatar/v3/saved`, | GET | `api/inventions/v3/saved` | REAL | `InventionsController.Saved` — caller's own creations (SAVED list) | | POST | `api/inventions/v3/save` | REAL | `InventionsController.Save` — creates a new `InventionEntity` (initial save) | | POST | `api/inventions/v3/addversion` | REAL | `InventionsController.AddVersion` — appends `InventionVersionEntity`, bumps `CurrentVersionNumber` | +| POST | `remote-run/push-to-studio` | REAL | `InventionsController.PushToStudio` (also `roomserver/` alias) — Rec Room Studio remote-run handoff. Raw-JSON body `{SessionId, RoomId, SubRoomId, UnityAssetId, RoomData:{Filename,Hash,OwnershipProof}, SubRoomData:{…}, SavedByAccountId}`; stamps the pushed `RoomDataBlobEntity` with its room/sub-room, records `UnityAssetId` on the `RoomScene`, sets `Rooms.StudioSessionId`, keeps the push under `remoterun:{SessionId}` in `PlayerSettings`. Does NOT commit the live save (that stays `rooms/{id}/subrooms/{id}/data`). Replies with the flattened `RemoteRunDetails` | ### `api/Leaderboard/` diff --git a/DorkNet.Server/Services/ClubService.cs b/DorkNet.Server/Services/ClubService.cs index e940ac5..6ea71c5 100644 --- a/DorkNet.Server/Services/ClubService.cs +++ b/DorkNet.Server/Services/ClubService.cs @@ -185,19 +185,41 @@ public class ClubService(DorkNetDbContext db) .ToList(); } + /// + /// Per-player settings key holding the explicitly chosen home club. + /// is the server's general-purpose + /// per-player key/value bag (the same store MatchPlayerController + /// and RoomsController use for stateful scalars), so the 2023 + /// client's PUT/DELETE club/home/me pair persists here rather + /// than needing a new column on . + /// + public const string HomeClubSettingKey = "club.home"; + /// /// Composite home-tab payload for GET /club/home/me. The /// watch's deserialiser for this endpoint expects a single /// RecNet.Club object (typeof(System.Action`1<PLILLKHMNDA>) /// per ISIL) — typically the player's "home club" (set via - /// SetMyHomeClub) rendered as the headline card. We pick the - /// first club the player is a member of, ordered by membership - /// recency. Returns null when the player has no club memberships - /// — the controller turns that into - /// so the watch knows to render the "join a club" empty state. + /// SetMyHomeClub) rendered as the headline card. An explicit + /// pick made through always wins; when + /// nothing is stored (or the stored club has since been disbanded) we + /// fall back to the most recently joined club so the tab is never + /// blank for an existing member. Returns null when the player has no + /// club memberships at all — the controller turns that into the + /// ClubId=0 sentinel the watch renders as "no home club". /// public async Task HomeClubAsync(long playerId) { + var stored = await db.PlayerSettings.AsNoTracking() + .Where(s => s.PlayerId == playerId && s.Key == HomeClubSettingKey) + .Select(s => s.Value) + .FirstOrDefaultAsync(); + if (long.TryParse(stored, out var storedId) && storedId > 0) + { + var chosen = await db.Clubs.FirstOrDefaultAsync(c => c.Id == storedId && c.State == 0); + if (chosen is not null) return chosen; + } + // Most-recent-joined club wins. Owners are members too (the // /create flow inserts an owner-permissions=127 membership // row), so a brand-new club creator sees their fresh club here. @@ -209,6 +231,112 @@ public class ClubService(DorkNetDbContext db) return pick; } + /// Persist the player's explicit home-club pick. Returns false + /// when the club id doesn't resolve to a live club so the caller can 404 + /// instead of storing a dangling id. + public async Task SetHomeClubAsync(long playerId, long clubId) + { + if (!await db.Clubs.AnyAsync(c => c.Id == clubId && c.State == 0)) return false; + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == HomeClubSettingKey); + if (row is null) + { + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = playerId, + Key = HomeClubSettingKey, + Value = clubId.ToString(), + }); + } + else + { + row.Value = clubId.ToString(); + } + await db.SaveChangesAsync(); + return true; + } + + /// Drop the player's explicit home-club pick. Idempotent — + /// missing row is a no-op, matching the client's fire-and-forget + /// "Remove Home Clubhouse Room" call. + public async Task ClearHomeClubAsync(long playerId) + { + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == HomeClubSettingKey); + if (row is null) return; + db.PlayerSettings.Remove(row); + await db.SaveChangesAsync(); + } + + /// + /// Disband a club — the client's "Delete Club" action. Owner-only + /// (unlike , which co-owners and moderators + /// can drive), and a soft delete: State is stamped with the client's + /// IACDINKNHKB.MarkedForDelete = 1000 so every read path here + /// (all of which filter State == 0) stops surfacing it while + /// announcements / memberships stay intact for moderation review. + /// Returns null when the club doesn't exist; throws + /// when the caller isn't the + /// creator. + /// + public async Task DisbandAsync(long clubId, long callerId) + { + var club = await db.Clubs.FirstOrDefaultAsync(c => c.Id == clubId && c.State == 0); + if (club is null) return null; + if (club.CreatorPlayerId != callerId) throw new UnauthorizedAccessException(); + club.State = 1000; // IACDINKNHKB.MarkedForDelete + club.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return club; + } + + /// Every announcement across the clubs the player is a real + /// member of (pending invites/requests = 128 and ban markers = 256 are + /// excluded — they must not leak a club's board). Backs the flat + /// GET /announcements/mine list, which is a different wire shape + /// from the per-club unread rollup. + public async Task> AnnouncementsForMemberClubsAsync( + long playerId, int take = 100) + { + var clubIds = await db.ClubMemberships + .Where(m => m.PlayerId == playerId && (m.Permissions & 384) == 0) + .Select(m => m.ClubId) + .ToListAsync(); + return await AnnouncementsAcrossClubsAsync(clubIds, take); + } + + /// Every announcement across the clubs the player subscribes + /// to. Backs the flat GET /announcements/subscription/mine + /// list. + public async Task> AnnouncementsForSubscribedClubsAsync( + long playerId, int take = 100) + { + var clubIds = await db.ClubSubscriptions + .Where(s => s.PlayerId == playerId) + .Select(s => s.ClubId) + .ToListAsync(); + return await AnnouncementsAcrossClubsAsync(clubIds, take); + } + + private async Task> AnnouncementsAcrossClubsAsync( + List clubIds, int take) + { + if (clubIds.Count == 0) return new List(); + // Disbanded clubs keep their rows but must not surface on the + // aggregated feed, so the club set is re-filtered on State. + var live = await db.Clubs + .Where(c => clubIds.Contains(c.Id) && c.State == 0) + .Select(c => c.Id) + .ToListAsync(); + if (live.Count == 0) return new List(); + return await db.ClubAnnouncements + .Where(a => live.Contains(a.ClubId)) + .OrderByDescending(a => a.CreatedAt) + .ThenByDescending(a => a.Id) + .Take(Math.Clamp(take, 1, 500)) + .ToListAsync(); + } + /// /// Upserts a read-marker so subsequent /// / calls skip the diff --git a/DorkNet.Server/Services/PlayerAccountPurge.cs b/DorkNet.Server/Services/PlayerAccountPurge.cs new file mode 100644 index 0000000..ec9ae2f --- /dev/null +++ b/DorkNet.Server/Services/PlayerAccountPurge.cs @@ -0,0 +1,153 @@ +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Data; + +namespace DorkNet.Server.Services; + +/// +/// The single account-erasure cascade used by every "remove this player" +/// path in the server: the admin SPA's +/// DELETE api/admin/v1/players/{id} and the 2023 client's +/// DELETE account/me ("delete local account"). +/// +/// Personal rows are deleted outright; authored durable content (rooms, +/// clubs, inventions, playlists, chat threads …) is reassigned to the +/// system account so other players' worlds don't lose their contents when +/// one author leaves. Kept as a static helper — not a DI service — so both +/// call sites share one implementation without either controller having to +/// grow a constructor dependency. +/// +/// Callers are responsible for the surrounding transaction, for removing +/// the Players row itself, and for kicking the live session. +/// +public static class PlayerAccountPurge +{ + /// Delete/reassign every row that references + /// . Returns a one-line audit summary. + public static async Task PurgeAsync( + DorkNetDbContext db, long playerId, long systemPlayerId) + { + var now = DateTime.UtcNow; + var deleted = 0; + var reassigned = 0; + + deleted += await db.Avatars.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.PlayerSettings.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.Relationships.Where(x => x.RequesterId == playerId || x.TargetId == playerId).ExecuteDeleteAsync(); + deleted += await db.CurrencyBalances.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.PlayerInventory.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.PlayerDevices.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.NotificationPrefs.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.CohortAssignments.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.PushTokens.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.PlatformIgnores.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.Cards.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.ObjectiveProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.GameRewardSelections.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.CouponRedemptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.LeaderboardStats.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.PlayerElo.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.RoyalePlayerProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.RoyaleMatchPlayers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + + deleted += await db.Messages.Where(x => x.SenderPlayerId == playerId || x.RecipientPlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.ChatMessages.Where(x => x.SenderPlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.ChatThreadMembers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + reassigned += await db.ChatThreads + .Where(x => x.CreatorPlayerId == playerId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId)); + + deleted += await db.Cheers.Where(x => x.FromPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.Reports.Where(x => x.ReporterPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.BugReports.Where(x => x.ReporterPlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.Subscriptions.Where(x => x.SubscriberPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync(); + + deleted += await db.ClubMemberships.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.ClubSubscriptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.ClubAnnouncementReads.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + reassigned += await db.Clubs + .Where(x => x.CreatorPlayerId == playerId) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.CreatorPlayerId, systemPlayerId) + .SetProperty(x => x.UpdatedAt, now)); + reassigned += await db.ClubAnnouncements + .Where(x => x.AuthorPlayerId == playerId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.AuthorPlayerId, systemPlayerId)); + + deleted += await db.RoomBookmarks.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.RoomVisits.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.RoomRoles.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.RoomBans.Where(x => x.BannedPlayerId == playerId).ExecuteDeleteAsync(); + reassigned += await db.RoomBans + .Where(x => x.BannedByPlayerId == playerId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.BannedByPlayerId, systemPlayerId)); + reassigned += await db.RoomRoles + .Where(x => x.GrantedByPlayerId == playerId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.GrantedByPlayerId, systemPlayerId)); + reassigned += await db.Rooms + .Where(x => x.CreatorPlayerId == playerId) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.CreatorPlayerId, systemPlayerId) + .SetProperty(x => x.UpdatedAt, now)); + reassigned += await db.RoomDataBlobs + .Where(x => x.UploadedByPlayerId == playerId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.UploadedByPlayerId, systemPlayerId)); + reassigned += await db.RoomKeys + .Where(x => x.CreatorPlayerId == playerId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId)); + deleted += await db.RoomKeyPurchases.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + + deleted += await db.PlaylistInteractions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + reassigned += await db.Playlists + .Where(x => x.CreatorPlayerId == playerId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId)); + + deleted += await db.PlayerEventResponses + .Where(x => x.PlayerId == playerId + || db.PlayerEvents.Where(e => e.CreatorPlayerId == playerId).Select(e => e.Id).Contains(x.EventId)) + .ExecuteDeleteAsync(); + deleted += await db.PlayerEvents.Where(x => x.CreatorPlayerId == playerId).ExecuteDeleteAsync(); + + deleted += await db.PrivateInstanceInvitees.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + var privateInstanceIds = await db.PrivateInstances + .Where(x => x.OwnerPlayerId == playerId) + .Select(x => x.Id) + .ToListAsync(); + if (privateInstanceIds.Count > 0) + { + deleted += await db.PrivateInstanceInvitees + .Where(x => privateInstanceIds.Contains(x.PrivateInstanceId)) + .ExecuteDeleteAsync(); + deleted += await db.PrivateInstances + .Where(x => privateInstanceIds.Contains(x.Id)) + .ExecuteDeleteAsync(); + } + + deleted += await db.GiftPackages + .Where(x => x.RecipientPlayerId == playerId) + .ExecuteDeleteAsync(); + if (playerId <= int.MaxValue) + { + reassigned += await db.GiftPackages + .Where(x => x.FromPlayerId == (int)playerId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.FromPlayerId, (int?)null)); + } + + reassigned += await db.Inventions + .Where(x => x.CreatorPlayerId == playerId) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.CreatorPlayerId, systemPlayerId) + .SetProperty(x => x.UpdatedAt, now)); + reassigned += await db.Photos + .Where(x => x.UploaderPlayerId == playerId) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.UploaderPlayerId, systemPlayerId) + .SetProperty(x => x.DeletedAt, now) + .SetProperty(x => x.IsPublic, false)); + + reassigned += await db.SignupCodes + .Where(x => x.RedeemedByPlayerId == playerId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.RedeemedByPlayerId, (long?)null)); + + return $"deleted_rows={deleted} reassigned_rows={reassigned}"; + } +} diff --git a/docs/recroom-2023-client-api-complete.md b/docs/recroom-2023-client-api-complete.md index 693c9bf..52bca8c 100644 --- a/docs/recroom-2023-client-api-complete.md +++ b/docs/recroom-2023-client-api-complete.md @@ -1557,17 +1557,17 @@ Of 65 real client routes: ~17 fully work. 20 routes are entirely MISSING (all cl | PUT | `comments/read/{0}/{1}` | none | status-only ('Failed to update latest read comment'). MISSING on server | | GET | `comments/unreadcounts` | roomIds (repeated Int64 list). Verb is dynamic: GET when <100 room ids (query), POST with form body when >=100 (verb = count<100 ? 0 : 2 — cmovl at instr 059-061). Server registers | JSON object mapping roomId (stringified Int64 key) -> unread count (UInt32) | | GET | `members/bulk` | query: id (repeated, one per account). clubId is NOT transmitted — it is only the client-side cache key (response handler NJNJEOFNJIG(clubId, List) at IKMMOCKDKAF.txt: | JSON array; FCKGOFHNDNJ CLR: int AccountId, MJOOBDNCHBO MembershipType | -| GET | `thread/club/{0}` | query: maxCount (int), mode (int, semantics UNKNOWN). Chat host (GJDLNNLKDIJ.Chat=8) | JSON array of chat threads. JNMKLHDFPOJ CLR (recnet-runtime-decomp/JNMKLHDFPOJ.cs): 2 longs (thread id + ?), List messages, ChatMessage latest, List playerIds, string name, DateTime? snoozedUntil, long | -| PUT | `thread/message/{0}/moderate` | form-urlencoded: moderationState (byte enum: Active=0,Junior_Pending=11,Moderation_Pending=100,Moderation_Closed=101,Moderation_Banned=102) | chat-result enum MGGNHLHPHOF (Success=0,InvalidArguments,ThreadNotFound,MembershipNotFound,PlayerAlreadyOnThread,CannotMessagePlayer,InvalidCharacters,RecentlyLeftThread,ThreadTooLarge) — bare value. MISSING on server | +| GET | `thread/club/{0}` | query: maxCount (int, default 10), mode (int, semantics UNKNOWN — client always sends 0). Chat host (GJDLNNLKDIJ.Chat=8) | JSON array of chat threads. The thread reader (EKEFFNBIOHJ.txt instrs 082/109/133/149/173/197/221/245/269) takes exactly nine keys in three casings: ChatThreadId, LastReadMessageId, Messages, LatestMessage, PlayerIds, ChatThreadName, SnoozedUntil, IsFavorited, ClubId. IMPLEMENTED: `ChatController.GetClubThreads` — one `club:{clubId}` thread per club, roster-gated, returned as a one-element array | +| PUT | `thread/message/{0}/moderate` | form-urlencoded: moderationState (byte enum: Active=0,Junior_Pending=11,Moderation_Pending=100,Moderation_Closed=101,Moderation_Banned=102) | chat-result enum MGGNHLHPHOF (Success=0,InvalidArguments,ThreadNotFound,MembershipNotFound,PlayerAlreadyOnThread,CannotMessagePlayer,InvalidCharacters,RecentlyLeftThread,ThreadTooLarge) — bare value (`Action` at DLDKCILCKNA.txt instr 101). STILL MISSING on server — blocked on a `ChatMessageEntity.ModerationState` column | | POST | `thread/withmembers` | form-urlencoded: ids (repeated Int32 list), messageCount (int). Server has a form-reading variant (ChatController.cs:438-446) — matches | single thread object (get-or-create DM/group with these members) | | GET | `thread/{0}` | query: messageCount (int) | single thread object with messages | | POST | `thread/{0}` | form-urlencoded: messageContents = JSON string of DLDKCILCKNA.MessageJson with PRESERVED field names {Type (int: Text=0,PartyInvite=1), Version (int), Data (string)} (recnet-runtim | ANDECIOOJNB: {ChatMessage, chat-result enum}. Server's validated keys: chatMessage, chatResult; message keys: chatMessageId, chatThreadId, senderPlayerId, timeSent, contents (ChatController.cs:792-810) | -| PUT | `thread/{0}/favorite` | form-urlencoded: favorite (bool) | chat-result enum. MISSING on server | +| PUT | `thread/{0}/favorite` | form-urlencoded: favorite (bool) | NOT a bare enum — the continuation is `Func` (DLDKCILCKNA.txt instr 142), so the body is the wrapper object GLIKNMPPEHL, read by OGCMIDEFOEE.txt (instrs 042/069) with exactly two keys in three casings: ChatResult, IsFavorited. IMPLEMENTED: `ChatController.Favorite` — flag persisted in PlayerSettings under `chat.favorite:` and echoed back in the thread DTO's IsFavorited | | POST | `thread/{0}/leave` | none | chat-result enum. VERB MISMATCH: server registers HttpDelete only (ChatController.cs:337) → 405 for this client's POST | | POST | `thread/{0}/member/{1}` | none (both ids in path) | chat-result enum (PlayerAlreadyOnThread etc.). Server POST exists (ChatController.cs:673) | | GET | `thread/{0}/message` | query: messageCount (int), mode (int, semantics UNKNOWN), referenceMessageId (Int64) | JSON array of ChatMessage. CLR (recnet-runtime-decomp/RecNet/ChatMessage.cs): long MessageId, long ThreadId, int SenderAccountId, DateTime SentAt, string contents, MOFGOGFEHNN moderation state, MessageJson parsed content | | POST | `thread/{0}/message/{1}/read` | none | chat-result enum. Server accepts POST+PUT (ChatController.cs:268-269) — OK | -| POST | `thread/{0}/rename` | form-urlencoded: name (string) | chat-result enum. MISMATCH: server registers PUT only with [FromBody] JSON (ChatController.cs:355-356) → 405/415 for this client's form POST | +| POST | `thread/{0}/rename` | form-urlencoded: name (string) | chat-result enum, bare value (`Action` at DLDKCILCKNA.txt instr 162). FIXED: `ChatController.Rename` now registers PUT+POST and binds via FormOrJsonModelBinder | | POST | `thread/{0}/snooze` | form-urlencoded: snooze (bool) | chat-result enum. ENCODING MISMATCH: server POST exists but binds [FromBody] SnoozeRequest JSON (ChatController.cs:307-308) → 415 on form content-type | #### Not HTTP routes @@ -1586,29 +1586,23 @@ Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:338` **Fix.** In BuildDetailsResponseAsync emit each *Permissions key as an object {ClubId, MembershipType, approveMember, banUnban, createEvent, editDetails, editPermissionSettings, postAnnouncement} (key casing of the 6 bools per the PUT permissions request closure; response casing UNKNOWN — mirror request casing and validate live). Applies to all 8 envelope-returning handlers at once. -##### `PUT club/home/me` — MISSING (breaks-gameplay) +##### `PUT club/home/me` — FIXED -Only HttpGet is registered for /club/home/me (ClubsController.cs:44); a PUT gets 405. The 2023 client's 'Set Home Clubhouse Room' sends PUT with form-urlencoded clubId and fails every time. +`ClubsController.HomeMeSet` reads the form-urlencoded `clubId` (query fallback) and stores it through `ClubService.SetHomeClubAsync`, which persists into the general-purpose `PlayerSettings` key/value bag under key `club.home` — no new column needed. Returns an empty 200; the client's `LDGADANDBIO JOPMBFIFFBB(Nullable)` parses no DTO. `HomeClubAsync` now prefers the stored pick and only falls back to most-recently-joined when nothing is stored or the stored club was disbanded. -**Fix.** Add [HttpPut("/club/home/me")] reading Request.Form["clubId"] (long), persist a HomeClubId on the player (new column/service method), return 200 empty (client parses no DTO). +##### `DELETE club/home/me` — FIXED -##### `DELETE club/home/me` — MISSING (breaks-gameplay) +`ClubsController.HomeMeClear` removes the `club.home` `PlayerSettings` row (idempotent) and returns an empty 200. -No HttpDelete registered → 405. 'Remove Home Clubhouse Room' fails. +##### `DELETE club/{0}` — FIXED -**Fix.** Add [HttpDelete("/club/home/me")] clearing the stored home club; return 200 empty. +`ClubsController.ClubDelete` → `ClubService.DisbandAsync`: owner-only (co-owners/moderators are rejected, unlike `ModifyAsync`), soft delete stamping `State = 1000` (`IACDINKNHKB.MarkedForDelete`). Every read path here filters `State == 0`, so the club disappears from browse/mine/details while its announcements and membership rows survive for moderation. Returns an empty 200. -##### `DELETE club/{0}` — MISSING (breaks-gameplay) +##### `PUT club/{0}/clubChatEnabled` — MISSING (breaks-gameplay) — BLOCKED ON SCHEMA -Only GET /club/{clubId:long} exists → DELETE 405. 'Delete Club' (disband) always fails. +No route → 404. Club-chat toggle in settings fails. Verified from the binary: `MEPEAAJOGLP(Int64, Boolean)` at IKMMOCKDKAF.txt:25601, route literal :25779, verb ordinal 3 (PUT) at :25802, form key `clubChatEnabled` (IKMMOCKDKAF_NestedType_BLCIHIILJNE.txt:67), response is the `LCLFBBPEMIH` details envelope. Deliberately NOT stubbed: a handler that accepted the toggle and dropped it would make the settings screen lie, and `GET club/{0}/hasDisabledClubChat` (now served) would keep answering `false`. -**Fix.** Add [HttpDelete("/club/{clubId:long}")]: owner-only, set State=1000 (MarkedForDelete) or hard-delete club + membership rows; return 200 empty (status-only on client). - -##### `PUT club/{0}/clubChatEnabled` — MISSING (breaks-gameplay) - -No route anywhere in the server (grep for clubChatEnabled: zero hits) → 404. Club-chat toggle in settings fails. - -**Fix.** Add [HttpPut("/club/{clubId:long}/clubChatEnabled")] reading form key clubChatEnabled (bool), persist a ClubChatEnabled column on ClubEntity, return the details envelope. +**Fix.** Needs `ClubEntity.ClubChatEnabled` (bool, default true). Once the column exists: `[HttpPut("/club/{clubId:long}/clubChatEnabled")]` reading the form `clubChatEnabled` through `FormOrJsonModelBinder`, write via `ModifyAsync`, return `BuildDetailsResponseAsync`, and update BOTH `ToWireClub`'s `ClubChatEnabled = true` literal and `ClubsController.ClubHasDisabledChat`'s `false`. ##### `PUT club/{0}/clubhouse` — SHAPE_MISMATCH (breaks-gameplay) @@ -1626,11 +1620,9 @@ Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:258` **Fix.** Emit MMOCDPPONNG-shaped objects for the three permission keys (see club/create finding); persist CustomTags/AdditionalImages later. -##### `GET club/{0}/hasDisabledClubChat` — MISSING (breaks-gameplay) +##### `GET club/{0}/hasDisabledClubChat` — FIXED (pending schema) -No route (grep zero hits) → 404. The club chat thread open path checks this first, so club chat cannot open even after thread/club/{id} is added. - -**Fix.** Add [HttpGet("/club/{clubId:long}/hasDisabledClubChat")] returning a bare JSON boolean (false, or !ClubChatEnabled once persisted). +`ClubsController.ClubHasDisabledChat` 404s an unknown/disbanded club and otherwise answers a bare JSON `false` (the issuing method is `FGLDKEJLAKB OFOJAHBKMOJ(Int64)` — a bare boolean body, not an object). `false` is the *true* answer today: the value is the negation of the Club wire field `ClubChatEnabled`, which has no `ClubEntity` column and whose setter (`PUT club/{0}/clubChatEnabled`) is therefore still unimplemented, so no club can be in the disabled state. When the column lands, this handler and `ToWireClub`'s `ClubChatEnabled` literal must change together. ##### `PUT club/{0}/mainimage` — SHAPE_MISMATCH (breaks-gameplay) @@ -1640,11 +1632,9 @@ Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:396` **Fix.** Read Request.Form["imageName"] when HasFormContentType (mirror ReadCreateClubRequestAsync pattern), keep JSON path for other callers. -##### `GET club/{0}/members` — MISSING (breaks-gameplay) +##### `GET club/{0}/members` — FIXED -Only /club/{clubId}/members/{playerId:long} exists; 'members' with query params has no matching template (sub-literals like 'banned' fail the :long constraint too) → 404. The club page member list never loads. - -**Fix.** Add [HttpGet("/club/{clubId:long}/members")] with query membershipType/sortBy/skip/take, returning a JSON array of {AccountId:int, ClubId:long, MembershipType:int, CreatedAt} rows (reuse ToWireMembership, ClubsController.cs:828-834). +`ClubsController.ClubMembers` binds `membershipType`/`sortBy`/`skip`/`take` and returns the bare `List` array via the existing `ToWireMembership` projection. `membershipType` is compared as the MJOOBDNCHBO wire value (after `MembershipTypeFromPerms`), not the stored perms int; with no filter the roster is real members only, so ban markers (256) and pending rows (128) stay in their own endpoints. `sortBy` implements GNLOJEONFIG (Default=privileged-first, JoinDate_Asc/Desc, Username_Asc/Desc) — usernames are joined in from `Players` once per request. ##### `POST club/{0}/members/acceptrequest` — SHAPE_MISMATCH (breaks-gameplay) @@ -1758,11 +1748,11 @@ Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:697` **Fix.** Accept repeated 'id' query params; when clubId is absent, return each account's membership without club scoping (e.g. highest/most-relevant membership per account) or infer from context — response stays [{AccountId, MembershipType}]. -##### `GET thread/club/{0}` — MISSING (breaks-gameplay) +##### `GET thread/club/{0}` — FIXED (was breaks-gameplay) -'thread/club/123' (3 segments) matches no template — thread/{chatThreadId} is 2 segments — → 404. The club chat tab cannot open at all. +'thread/club/123' (3 segments) matched no template — thread/{chatThreadId} is 2 segments — → 404. The club chat tab could not open at all. -**Fix.** Add [HttpGet("/thread/club/{clubId:long}")] in ChatController: get-or-create a club thread key (e.g. "club:{clubId}"), honor query maxCount, and return a JSON ARRAY of ToWireThread objects (client expects List) with ClubId included in the thread wire object; extend ThreadKey routing helpers (ResolveThreadKeyAsync, participant loading, send fan-out to club members) to understand club: keys so thread/{id} POST/GET work on it. +**Fixed.** `ChatController.GetClubThreads` — `[HttpGet("/thread/club/{clubId:long}")]`, get-or-creates the `club:{clubId}` thread (named after the club on first materialisation), honours `maxCount`, accepts and ignores `mode`, and returns a one-element JSON ARRAY of ToWireThread objects. Non-roster callers and unknown clubs get `[]` (the client deserialises the body before it looks at the status). The thread helpers now understand `club:` keys: `LoadParticipantsAsync` resolves participants from ClubMemberships (pending 128 / banned 256 excluded), `ThreadRecipientsAsync` fans new messages out over the roster, `IsThreadMemberAsync` gates rename/favorite on roster membership, `Leave` refuses club threads, and `GetThreads` lists club channels that have messages. ToWireThread now also emits the two keys the 2023 reader wants and the 2020 DTO lacked: `isFavorited` and `clubId`. ##### `POST thread/{0}/leave` — VERB_MISMATCH (breaks-gameplay) @@ -1780,55 +1770,47 @@ Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:417` **Fix.** Persist additional image slots (new AdditionalImages storage keyed clubId+slot), read Request.Form["imageName"] when HasFormContentType, echo them in the envelope's AdditionalImages as [{imageName,slot}] per HIKCHBLAMLP. -##### `DELETE club/{0}/additionalimage/{1}` — MISSING (degraded) +##### `DELETE club/{0}/additionalimage/{1}` — MISSING (degraded) — BLOCKED ON SCHEMA -Only POST/PUT registered (ClubsController.cs:417-418) → DELETE 405. 'Remove Additional Club Image' fails. +Only POST/PUT registered → DELETE 405. 'Remove Additional Club Image' fails. Verified from the binary: `AKJJLMLPBKN(Int64, Int32 slot, String imageName)` at IKMMOCKDKAF.txt:16054 branches on the captured imageName (:16219) — verb ordinal 4 with no body when null (description "Remove Additional Club Image", :16229-16234), verb 3 with the form field otherwise (:16256-16260) — and both branches parse the `LCLFBBPEMIH` details envelope. -**Fix.** Add [HttpDelete("/club/{clubId:long}/additionalimage/{slot:int}")] clearing the slot, return details envelope. +Deliberately NOT registered: the PUT twin is already an admitted stub that persists nothing, so a DELETE would have nothing to clear. Registering it would only convert a visible 405 into a silent success while the gallery stays blank. -##### `DELETE club/{0}/mainimage` — MISSING (degraded) +**Fix.** Needs the additional-image store the PUT twin also wants: a `ClubAdditionalImageEntity` keyed (ClubId, Slot) with an ImageName column. Then register `[HttpDelete("/club/{clubId:long}/additionalimage/{slot:int}")]`, delete the row, and have `BuildDetailsResponseAsync` project the rows into the envelope's `AdditionalImages` as `HIKCHBLAMLP` {imageName, slot} instead of the current `Array.Empty()`. -Only POST/PUT registered (:396-397) → DELETE 405. 'Remove Club Image' fails. +##### `DELETE club/{0}/mainimage` — FIXED -**Fix.** Add [HttpDelete("/club/{clubId:long}/mainimage")] setting ImageName='' and returning the details envelope. +`ClubsController.ClubMainImageDelete` is its own action (NOT a third verb binding on the POST/PUT setter — aliasing an edit verb onto a destructive handler is exactly what made `PUT /announcements/club/{c}/{a}` delete posts). Clears `ImageName` through `ModifyAsync` and returns the full `LCLFBBPEMIH` details envelope, which is what `IBAKMFKEEDJ` parses on both branches of `BLEDOCHHHJM` — the DELETE branch is *not* status-only. -##### `GET club/{0}/members/banned` — MISSING (degraded) +##### `GET club/{0}/members/banned` — FIXED -No route → 404 ('banned' fails the {playerId:long} constraint of the single-member route). Banned list screen empty-errors. +`ClubsController.ClubMembersBanned` returns the bare `List` array — three fields only ({AccountId:int, ClubId:long, CreatedAt}; ICPNBOOIDLI has no MembershipType) — over rows carrying the perms 256 ban marker, with `sortBy`/`skip`/`take`. Moderator-gated (`CanManageAsync`): the ban list is staff-only information. -**Fix.** Add [HttpGet("/club/{clubId:long}/members/banned")] (sortBy/skip/take query) returning array of {AccountId:int, ClubId:long, CreatedAt} for rows with perms ban marker 256 (ICPNBOOIDLI layout). +##### `GET club/{0}/members/requests` — FIXED -##### `GET club/{0}/members/requests` — MISSING (degraded) +`ClubsController.ClubMemberRequests` returns the bare `List` array over rows with the 128 pending marker (and without the 256 ban marker), `skip`/`take`, moderator-gated. Two fields are still un-fillable rather than invented: `InviterAccountId` is emitted as null (the DTO declares it nullable; invites and requests both collapse to the single 128 marker on the target's own row, so no inviter is recorded) and `Status` is always Requested for the same reason. Neither is read by the accept/deny flow, which keys off `accountId`. Persisting invited-vs-requested would let `Status` be filled properly. -No route → 404. Pending-join-requests screen fails ('Failed to get pending club member requests'). +##### `GET club/{0}/members/requests/search` — FIXED -**Fix.** Add [HttpGet("/club/{clubId:long}/members/requests")] (skip/take) returning array of AADIHDCMEDB-layout rows (request id, AccountId, inviter id?, ClubId, MembershipType, status Invited=0/Requested=1/Denied=2, CreatedAt). Response key casing UNKNOWN — dual-case via Dictionary per the camelCase-serializer memory, then validate live. Note: current perms model collapses invited vs requested (MembershipTypeFromPerms returns Requested for any 128 row, ClubService.cs:354) — persist the distinction to fill status correctly. +`ClubsController.ClubMemberRequestsSearch` reads the dot-prefixed keys straight off `Request.Query` (`parameters.name`, `parameters.sortBy`, `parameters.maxCount`, `parameters.status`, plus the unprefixed `continuationToken`) and returns the `EDFOCLNECPM` paged envelope. `continuationToken` is an opaque row offset (String on the DTO); an empty string means no further pages, matching the `club/search` envelope. NAHBJCGNJKA's RequestDate_Asc/Desc index the same JoinedAt column GNLOJEONFIG's JoinDate_Asc/Desc do, so both enums share one comparator. Envelope key names remain unverified (DataMember blobs are not in the ISIL/decompile), so it emits the entity-named pair validated on the sibling `club/search` envelope — `Requests`/`TotalRequests` — alongside `Results`/`TotalResults`; Json.NET ignores whichever pair the DTO doesn't declare. -##### `GET club/{0}/members/requests/search` — MISSING (degraded) +##### `GET club/{0}/members/search` — FIXED -No route → 404. Request-search box fails. +`ClubsController.ClubMembersSearch`, same treatment: `parameters.name`/`parameters.type`/`parameters.sortBy`/`parameters.maxCount` + `continuationToken` off `Request.Query`, returning the `MBMNHFAPFCJ` paged envelope with `Members`/`TotalMembers` plus `Results`/`TotalResults` and `ContinuationToken`. -**Fix.** Add [HttpGet("/club/{clubId:long}/members/requests/search")] binding query keys 'parameters.name', 'parameters.sortBy', 'parameters.maxCount', 'parameters.status', 'continuationToken' (bind via Request.Query, not [FromQuery] props, because of the dotted prefix) and return a paged envelope {list of AADIHDCMEDB rows, total int, continuationToken} — envelope key casing UNKNOWN, validate live. +##### `PUT club/{0}/minlevel` — MISSING (degraded) — BLOCKED ON SCHEMA -##### `GET club/{0}/members/search` — MISSING (degraded) +No club minlevel route (only Rooms.MinLevel exists) → 404. 'Min Level to Join' setting fails. Verified from the binary: `AOAFDLMFCGB(Int64, Int32)` at IKMMOCKDKAF.txt:16448, route literal :16561, verb ordinal 3 (PUT) at :16584, form key `minLevel` (IKMMOCKDKAF_NestedType_AOOCOABJPDN.txt:67), response is the `LCLFBBPEMIH` details envelope via IBAKMFKEEDJ. -No route → 404. Member-list search box fails ('SearchMembers failed'). - -**Fix.** Add [HttpGet("/club/{clubId:long}/members/search")] binding 'parameters.name'/'parameters.type'/'parameters.sortBy'/'parameters.maxCount'/'continuationToken' from Request.Query; return paged envelope of membership rows (CADEIMCFIIG layout) — envelope casing UNKNOWN, validate live. - -##### `PUT club/{0}/minlevel` — MISSING (degraded) - -No club minlevel route (only Rooms.MinLevel exists) → 404. 'Min Level to Join' setting fails. - -**Fix.** Add [HttpPut("/club/{clubId:long}/minlevel")] reading form minLevel (int), persist MinLevel on ClubEntity, return details envelope. +**Fix.** Needs `ClubEntity.MinLevel` (int, default 0). Once the column exists: `[HttpPut("/club/{clubId:long}/minlevel")]` reading the form `minLevel` through `FormOrJsonModelBinder`, write via `ModifyAsync`, return `BuildDetailsResponseAsync`, and swap `ToWireClub`'s `MinLevel = 0` literal for the column. ##### `PUT club/{0}/permissions/{1}` — MISSING (degraded) -Only HttpGet is registered → PUT 405. Per-role permission checklist saves fail. (The GET's bare-int response is a separate 2023 mismatch but the 2023 client doesn't call GET here.) +Only HttpGet is registered → PUT 405. Per-role permission checklist saves fail. BLOCKED ON SCHEMA. Verified from the binary: `HDDNEPNKCIK(MMOCDPPONNG)` at IKMMOCKDKAF.txt:17537 formats the route from the DTO's own ClubId + MembershipType (:17671-17689), verb ordinal 3 (PUT) at :17709, description "Modify Club Permissions", response is the `LCLFBBPEMIH` details envelope. Form keys are the 6 bools `editDetails`/`approveMember`/`createEvent`/`postAnnouncement`/`editPermissionSettings`/`banUnban` (IKMMOCKDKAF_NestedType_BOIMHOCCOEI.txt:173-253), matching the response-side reader BPHCOIBNCDP.txt:638-758. -Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:290 (GET only)` +Deliberately NOT stubbed: `PermissionsForRole` derives the six bools from the role with a fixed default policy and there is nowhere to store an override, so a handler would accept the checklist and silently discard it — the screen would appear to save and revert on reload. -**Fix.** Add [HttpPut("/club/{clubId:long}/permissions/{role:int}")] reading the 6 form bools (approveMember/banUnban/createEvent/editDetails/editPermissionSettings/postAnnouncement), persist per-role permission rows, return the details envelope with object-shaped permissions. +**Fix.** Needs a per-role permission table (ClubId, MembershipType, the 6 bools). Then register `[HttpPut("/club/{clubId:long}/permissions/{role:int}")]` binding those 6 form keys through `FormOrJsonModelBinder`, upsert the row owner-only, and have `PermissionsForRole` read the stored row (falling back to the current derived default when absent) so `BuildDetailsResponseAsync` reflects the save. ##### `POST comments/create/{0}` — MISSING (degraded) @@ -1848,19 +1830,21 @@ No route → 404. Room-details comment list never loads ('Failed to get room com **Fix.** Add [HttpGet("/comments/get/{roomId:long}")] with query count (int) + minId (long), returning JSON array of RoomComment. -##### `PUT thread/message/{0}/moderate` — MISSING (degraded) +##### `PUT thread/message/{0}/moderate` — MISSING (degraded) — BLOCKED ON SCHEMA 4-segment path matches no thread template → 404. Chat moderation (hide/flag) fails. -**Fix.** Add [HttpPut("/thread/message/{messageId:long}/moderate")] reading form moderationState (int), persist a ModerationState column on ChatMessageEntity, return bare int 0 (MGGNHLHPHOF.Success). +Verb + shape re-verified from the binary: DLDKCILCKNA.txt `PLBCHNGAEML(ChatMessage, MOFGOGFEHNN)`, instr 065 `Move rcx, "thread/message/{0}/moderate"`, verb `Move rdx, 3` at instr 075 (PUT), host `Move r8, 8` (Chat), single form field `moderationState` at instr 087, response consumed by `Action` at instr 101 = bare int. -##### `POST thread/{0}/rename` — VERB_MISMATCH (degraded) +**Fix.** Add `ChatMessageEntity.ModerationState` (int, default 0 = Active) + migration, emit it as the `ModerationState` key in `ChatController.ToWireMessage` (the ChatMessage reader CBKDKACJDOF.txt reads ChatMessageId / ChatThreadId / SenderPlayerId / TimeSent / Contents / ModerationState), then add `[HttpPut("/thread/message/{messageId:long}/moderate")]` reading form `moderationState` and returning bare int 0. Deliberately NOT stubbed: without the column the handler could only discard the request, which hides the gap behind a 200. -Server registers PUT only with [FromBody] RenameRequest JSON; the client sends POST with form 'name' → 405 (and would 415 even as PUT). Renaming a group chat fails. +##### `POST thread/{0}/rename` — FIXED (was verb_mismatch, degraded) -Handler: `DorkNet.Server/Controllers/Chat/ChatController.cs:355` +Server registered PUT only with [FromBody] RenameRequest JSON; the client sends POST with form 'name' → 405 (and would 415 even as PUT). Renaming a group chat failed. -**Fix.** Add [HttpPost] on the route plus a form-content branch reading Request.Form["name"]; keep returning bare int 0. +Handler: `DorkNet.Server/Controllers/Chat/ChatController.cs` — `ChatController.Rename` + +**Fixed.** Route now carries both `[HttpPut]` and `[HttpPost]`; `RenameRequest` became a plain class with a parameterless ctor bound by `[ModelBinder(typeof(FormOrJsonModelBinder))]`, so the client's form `name` and the admin SPA's JSON both bind. Still returns the bare int (`Action` at DLDKCILCKNA.txt instr 162). Membership is now checked through `IsThreadMemberAsync`, which also understands club threads. ##### `POST thread/{0}/snooze` — SHAPE_MISMATCH (degraded) @@ -1884,11 +1868,11 @@ Handler: `DorkNet.Server/Controllers/API/Comments/CommentsController.cs:40` **Fix.** Once comments/read persists per-player read state, compute real counts (max comment id per room vs player's last-read). -##### `PUT thread/{0}/favorite` — MISSING (cosmetic) +##### `PUT thread/{0}/favorite` — FIXED (was cosmetic) -No route → 404. Starring a thread fails. +No route → 404. Starring a thread failed. -**Fix.** Add [HttpPut("/thread/{chatThreadId}/favorite")] reading form favorite (bool), persist Favorited on ChatThreadMemberEntity, return bare int 0. +**Fixed.** `ChatController.Favorite` — `[HttpPut]`+`[HttpPost]` on `/thread/{chatThreadId}/favorite`, form field `favorite` bound with FormOrJsonModelBinder. The response is NOT a bare int as previously recorded: the client's continuation is `Func` (DLDKCILCKNA.txt instr 142), i.e. a client-side projection off a wrapper object, so the handler returns `{chatResult, isFavorited}` (reader OGCMIDEFOEE.txt, both keys casing-tolerant). The flag is per-(player, thread) and there is no column for it, so it is persisted in the generic PlayerSettings table under `chat.favorite:` and read back into every thread DTO's `IsFavorited` via `LoadFavoritesAsync`. Unknown threads answer ThreadNotFound(2), non-members MembershipNotFound(3). ### Relationships, friends and messaging @@ -2322,17 +2306,19 @@ Handler: `DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs:117` **Fix.** Bind {RoomId,SubRoomId,KeepsakeCategory}, store a real KeepsakeInstance row (guid id, room, subroom, category, placer), and return the bare Guid as the JSON body. -##### `DELETE api/keepsakes/{keepsakeInstanceId:guid}` — MISSING (breaks-gameplay) +##### `DELETE api/keepsakes/{keepsakeInstanceId:guid}` — FIXED -No DELETE route under api/keepsakes at all; a GUID path segment matches no template in KeepsakesController -> 404. Removing a placed keepsake fails. +Was: no DELETE route under api/keepsakes at all; a GUID path segment matched no template in KeepsakesController -> 404, so removing a placed keepsake failed. -**Fix.** Add [HttpDelete("{keepsakeInstanceId:guid}")] removing the instance (owner/placer gated), 200 with no body. +**Fixed.** `KeepsakesController.Delete` ([HttpDelete("{keepsakeInstanceId:guid}")]) resolves the guid back to the row whose EventKey encodes it, gates on placer / room creator / accepted co-owner (RoomRoles.Role==0) / admin, deletes the instance plus any collection rows pointing at it, and returns a bodiless 200 — the client's issuing method returns the non-generic `LDGADANDBIO` promise (NCCLEJPIABA.JBKENCNIEPA, NCCLEJPIABA.txt:1544 + `Move rdx, 4`@1698) so no body is read. -##### `POST api/keepsakes/{keepsakeInstanceId:guid}/collect` — MISSING (breaks-gameplay) +##### `POST api/keepsakes/{keepsakeInstanceId:guid}/collect` — FIXED -Route not registered -> 404. Collecting a keepsake in-room fails; client expects {"TotalXp":int,"SocialBoostXp":int}. +Was: route not registered -> 404, so collecting a keepsake in-room failed. -**Fix.** Add [HttpPost("{keepsakeInstanceId:guid}/collect")] recording a collection row and returning {TotalXp,SocialBoostXp} (SocialBoostXp 0 while SocialXpBoostEnabled=false). +**Fixed.** `KeepsakesController.Collect` ([HttpPost("{keepsakeInstanceId:guid}/collect")]) writes a collection row (`Category="collection"`, `EventKey="collect:{roomId}:{instanceGuid:N}"` — no schema change) and returns `{"TotalXp":int,"SocialBoostXp":int}` (DHNBKMHDANK; keys from its Utf8Json formatter, RecNet.Runtime/PKCMBJFBHBO.txt:42,69). TotalXp is the whole award — the client renders `TotalXp - SocialBoostXp` as the base figure (PDFJLLECNBE_NestedType_LKIPMJFEAFK.txt:82-99) — and is priced from the same category `XpValue` that `api/keepsakes/categories` serves. SocialBoostXp is 0 while SocialXpBoostEnabled=false. Re-collecting the same instance is idempotent and awards 0. + +Two supporting corrections in the same controller: `api/keepsakes/categories` now emits the full `PIHCLHIKEPH` id set (0-8, dump.cs:1199524) instead of three entries keyed by DorkNet's internal account/event/room buckets — the client folds the list into a `Dictionary` that every placed instance is looked up in — and `api/keepsakes/rooms/{roomId}` now returns real `CollectionRecords` (`{AccountId, KeepsakeInstanceId, CollectedAt}`, dump.cs:1234642 / CNNAFLNKDCL.txt:48,75,99) scoped to the caller instead of an empty array. ##### `GET api/keepsakes/events/{progressionEventId}` — SHAPE_MISMATCH (breaks-gameplay) @@ -2348,11 +2334,15 @@ Route not registered anywhere -> 404. Claiming a reward chest on the progression **Fix.** Add [HttpPost("api/progressionEvents/collect/{eventId:long}/{rewardIndex:int}")] that increments the player's RewardsCollected and returns a GiftDrop object (reuse the gifts/generate wire builder). -##### `GET event/{0}/instances` — MISSING (breaks-gameplay) +##### `GET event/{0}/instances` — FIXED -Only /room/{roomId:long}/instances exists (MatchController.cs:418); no event/{eventId}/instances is registered anywhere -> 404 and the 'join' instance browser for multi-instance events never populates. Client wants List<{RoomInstanceId,RoomId,SubRoomId,IsFull,CreatedAt,PlayerIds}> — the 2023 Utf8Json reader accepts the camelCase keys the room variant already emits (MatchController.cs:478-486). +Was: only /room/{roomId:long}/instances existed; no event/{eventId}/instances was registered anywhere -> 404 and the 'join' instance browser for multi-instance events never populated. Client wants List<{RoomInstanceId,RoomId,SubRoomId,IsFull,CreatedAt,PlayerIds}> — the 2023 Utf8Json reader accepts the camelCase keys the room variant already emits. -**Fix.** Add [HttpGet("/event/{eventId:long}/instances")] in MatchController: resolve the event's RoomId (and SubRoomId when set) then reuse the RoomInstances enumeration/wire code. +Verb/shape ground truth: `Matchmaking+b__0` (Matchmaking_NestedType_CBMHJMNIHNN.txt:14) formats `"event/{0}/instances"` at :296 with the PlayerEventId ([rdi+16] of the HPIOAGDJHDH it closes over) and news up BNDIAONDFFF with rdx=0 at :124 = HTTPMethods.Get. Return type `FGLDKEJLAKB>` = bare JSON array; PNDCMIMEJLD's getters (PNDCMIMEJLD.txt:3-197) are Int64/Int64/Int64/Boolean/DateTime/List\/String/Int32/Boolean/String, i.e. the same SimpleRoomInstance the room browser returns (Matchmaking.txt:14237 uses an identical BNDIAONDFFF/verb-0 sequence). The trailing SubroomName/PlayerCount/HasModPresent/HashedInstanceId are populated locally on the watch and are not read off the wire. + +**Fixed.** `[HttpGet("/event/{eventId:long}/instances")]` now lives on `PlayerEventsController.EventInstances` (the route is a matchmaking-host-root path but the data is a player-event one): it resolves the event's RoomId plus the SubRoomId persisted in the event's extras blob, then enumerates PlayerPresenceService instances for that room (filtered to the sub-room when the event names one) and merges in the caller's own PrivateInstances rows, emitting the same six camelCase keys as `room/{id}/instances`. `isFull` is computed from the instance's MaxCapacity vs its presenced player count. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs` (`EventInstances`) ##### `GET api/playerevents/v1/{0}` — SHAPE_MISMATCH (degraded) @@ -2617,6 +2607,8 @@ Only bare GET api/images/v6 (query ?name=) is registered. api/images/v6/123 fall **Fix.** Add [HttpGet("api/images/v6/{id:long}")] to ImagesController resolving db.Photos by Id (public or owned) and returning BuildImageInfo(photo, photo.UploaderPlayerId) — BuildImageInfo already carries all 12 LGLCPNPJCEC keys (Id, ImageName, PlayerId, RoomId, PlayerEventId, Accessibility, AccessibilityLocked, Type, CreatedAt, TaggedPlayerIds, CheerCount, CommentCount). +**Status: FIXED.** `ImagesController.ImageByIdV6` (`[HttpGet("api/images/v6/{id:long}")]`, anonymous, public-or-owner) returns a single BuildImageInfo object. Key list re-confirmed against the generated serializer `IBILPLGNAJE.txt:819-1078`. + ##### `GET api/images/v5/bulk` — SHAPE_MISMATCH (degraded) Two defects. (1) Response: items are BuildImageInfo objects but the client deserializes ICOFKEGOGOD whose keys are SavedImageId / SavedImageType / ClubId / Description — the builder sends Id and Type instead of SavedImageId and SavedImageType and has no ClubId, so the client's photo id reads as default 0 (or the strict reader throws on the missing non-nullable Int64 — throw-vs-default UNKNOWN); every downstream per-photo action (cheer, detail, delete) then targets id 0. (2) POST fallback (>=100 ids): client posts FORM field "ids", but the action binds [FromBody] JSON → ASP.NET returns 415 for form content. @@ -2633,6 +2625,8 @@ Handler: `DorkNet.Server\Controllers\API\Images\ImagesController.cs:539` **Fix.** Add [HttpGet("api/images/v5/cheered/bulk")] (and v4) parsing ids from Request.Query["id"] (comma-split all values); on POST also accept Request.Form["id"]. +**Status: FIXED.** `ImagesController.CheeredBulk` is now registered GET+POST on v4 and v5 and takes no bound parameter at all — ids come from `ReadCheerBulkIdsAsync`, which reads query values, then form values when the body is form-encoded, then a JSON body (array or object) otherwise. Dropping `[FromBody]` is what removes the 415 on the >=100-id form POST. + ##### `GET api/images/v1/slideshow` — SHAPE_MISMATCH (degraded) Top level {ValidTill, Images} is right, but each Images item must be NBNFGGCGCLP {SavedImageId, ImageName, Username, PlayerId, RoomName, RoomId}. BuildImageInfo supplies ImageName/PlayerId/RoomId but has NO SavedImageId (sends Id), NO Username, NO RoomName — the slideshow reader gets default 0/null for those (or throws on the non-nullable SavedImageId — UNKNOWN), so slideshow entries lose attribution or fail to render. @@ -2663,6 +2657,8 @@ No route matches; the request lands in the api/images/{*path} catch-all and 404s **Fix.** Add [HttpPost("api/images/v1/{id:long}/report")] [Authorize] that inserts a ReportEntity (ReporterPlayerId = caller, TargetPhotoId = id, TargetPlayerId = photo.UploaderPlayerId) and returns 200 with any body. +**Status: FIXED.** `ImagesController.ReportPhoto`. ReportEntity has no TargetPhotoId column and this round adds no schema, so the photo id + blob name ride in `Message` as `[photo {id} {blobName}] …` (the same convention `ClubService.AddClubReportAsync` uses for `[club {id}]`); TargetPlayerId = uploader, RoomId = the photo's room, Category = 5 (Other — the wire carries no category). Adding a `TargetPhotoId` column later would let the admin queue link straight to the image. + ##### `GET api/inventions/v1/update` — SHAPE_MISMATCH (degraded) Route, GET verb, and all four one-of query params (name/description/imgName/permission) bind and persist correctly. But the response is a bare ToWire invention object; the client deserializes BDNCJIPHHOK {"Status":Int32,"Invention":obj,"InventionVersion":obj} — none of those keys exist in the reply, so Status reads default (or the strict reader throws — UNKNOWN) and Invention is null, leaving the edit UI without the updated entity. Additionally the Invention payload itself is missing keys (see the ToWire finding). @@ -2831,11 +2827,17 @@ Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:388` **Fix.** Return Ok(new { IsCheering = await db.Cheers.AnyAsync(c => c.FromPlayerId == pid && c.TargetInventionId == id), Invention = ToWire(i), CanEdit = ... }) — extra keys harmless. -##### `GET showcase/{accountId}` — MISSING (degraded) +##### `GET showcase/{accountId}` — FIXED -No route anywhere in the server (grep across all controllers) for the rooms-service client's showcase list. The client expects a bare JSON array of Int64 room ids; a 404 means the profile 'showcase' room list never loads for any player. +Was missing everywhere (404), so the profile 'Showcase Rooms' carousel (`RoomListModel.JMGLGEKHHAK.PlayerShowcaseRooms = 7`, fed by `AccountModelController.RoomsShowcaseLinkImpl`) never loaded for any player. -**Fix.** Add GET showcase/{accountId:int} (RoomsController area, same host as rooms/*): return the account's showcased room ids — interim implementation: the player's public created rooms' ids as long[]; proper implementation: a ShowcaseRoomEntity ordering table with a set endpoint when the client's write path is traced. +Now served by `RoomsController.RoomsShowcase` on both `showcase/{accountId:long}` and `roomserver/showcase/{accountId:long}` (the issuing client NLDBPDCNNCF carries the `roomserver/` prefix in this deployment). + +Verb GET is the ordinal 0 moved into rdx before the dispatch call at `NLDBPDCNNCF.txt:4494`, with the literal at ISIL 021 `:4484`; no cmov, so GET only. The response is a **bare JSON array of Int64 room ids** — the issuing method returns `Task>` (`NLDBPDCNNCF.txt:4422`). Room objects are a client-side projection: `IBEOONPEELF.EJPNHLIJNPM` (`IBEOONPEELF.txt:8952`) resolves the ids through the room cache into `FGLDKEJLAKB>`, and the cache entry `b__0` is typed `Task>` (`IBEOONPEELF_NestedType_HHDHJPIJBJB.txt:14`). + +Backing data: `showcase/{0}` is the only showcase literal in the binary — the client reads the list but never writes it — so curation is stored in the existing player-settings table under the key `rooms:showcase` (CSV of room ids), writable via `PUT /settings/v1/{accountId}` with no new schema. Curated ids are re-validated against Rooms on each read (archived/privatised rooms drop out) and returned in the curated order; a player who never curated one falls back to the rooms they created, ordered by HotScore/VisitCount/UpdatedAt. Public rooms only, except the owner viewing their own profile. + +Handler: `DorkNet.Server\Controllers\API\Rooms\V2\RoomsController.cs` (`RoomsShowcase`) ##### `GET unity_assets/{0}/{1}/{2}` — SHAPE_MISMATCH (degraded) @@ -2869,11 +2871,15 @@ Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:720` **Fix.** Return Ok(new { Success = true, Message = string.Empty }). -##### `POST remote-run/push-to-studio` — MISSING (none) +##### `POST remote-run/push-to-studio` — IMPLEMENTED -Not registered anywhere (grep). This is the Rec Room Studio dev-flow push (paired with the 'rrs.pushtodevice' notification topic); normal gameplay never issues it, so a 404 only affects a Studio-connected developer session. +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs` → `PushToStudio`, routed at both `remote-run/push-to-studio` and `roomserver/remote-run/push-to-studio`. -**Fix.** If Studio pairing is ever in scope: add POST remote-run/push-to-studio accepting FNAGBPCAGJD {SessionId, RoomId, SubRoomId, UnityAssetId, RoomData{Filename,Hash,OwnershipProof}, SubRoomData{...}, SavedByAccountId?} and returning CEELGOLBHIL {SessionId, RoomId, SubRoomId, UnityAssetId, RoomDataFilename, RoomDataHash, SubRoomDataFilename, SubRoomDataHash}; otherwise scope it out explicitly in docs. +Verb POST from EHIOLHBGODG.txt:411-417 (route literal into r9, `Move rdx, 2` = HTTPMethods.Post, no cmov). Body is a RAW JSON document — :433 passes the serialized DTO to `BNDIAONDFFF.FJLLPHFOOJJ` (RawJsonForm, `application/json`) — bound through `FormOrJsonModelBinder`. Request FNAGBPCAGJD keys are literal in the generated serializer OFMNNCMPEPA.txt:535-698: `SessionId, RoomId, SubRoomId, UnityAssetId, RoomData, SubRoomData, SavedByAccountId`; each blob ref is `{Filename, Hash, OwnershipProof}` (FLELPHJDLNG.txt:255/274/290). Response is a single CEELGOLBHIL — eight keys, literal in its reader PPHLKNGCGOE.txt:599-786: `SessionId, RoomId, SubRoomId, UnityAssetId, RoomDataFilename, RoomDataHash, SubRoomDataFilename, SubRoomDataHash`; RoomId/SubRoomId are `Nullable` (CEELGOLBHIL.txt:25/51) and the two blob refs are FLATTENED (OwnershipProof is not echoed). A non-2xx or malformed reply shows the client's "Failed to push to Rec Room Studio" toast (EHIOLHBGODG.txt:445). + +This is the Studio remote-run handoff, a sibling of the normal save commit, not a replacement for it (client-side: OGPDOMCNIFM.txt:271 `rooms/{id}/subrooms/{id}/data` vs :449 push) — so the server does NOT touch `RoomEntity.CurrentDataBlobName`. It authorizes the caller like a room save (creator / admin / accepted CoOwner), requires both pushed filenames to resolve to real `RoomDataBlobEntity` rows (the client uploads them immediately beforehand), stamps the sub-room save blob with its `RoomId`/`SubRoomId` so it shows up in that sub-room's `datahistory`, records the pushed `UnityAssetId` on the `RoomSceneEntity`, writes `SessionId` into `RoomEntity.StudioSessionId`, and keeps the full push under `remoterun:{SessionId}` in `PlayerSettingEntity`. `IsRoomLinkedToRecRoomStudio` is intentionally left alone (it changes in-room MakerPen UI and the client has no unlink call). + +**Still out of scope:** live relay of the details to a second logged-in session. The client's receive side (`EHIOLHBGODG.MCPBJLHFOBC(CEELGOLBHIL)` → the `Action` subscribed at MBOJJFBIAGE.txt:366/978) has no call site anywhere in the IsilDump, so the notification id carrying it cannot be established from the binary; `PushNotificationId` gets no invented member for it. ### Storefront, purchases and subscriptions @@ -2963,9 +2969,11 @@ Handler: `DorkNet.Server/Controllers/API/Inventions/InventionsController.cs:345` **Fix.** Accept the form field (e.g. [FromForm] long inventionId with [FromQuery] fallback) and return { Status = , Invention = ToWire(i), InventionVersion = }. -##### `POST subscription/{accountId:int}` — MISSING (breaks-gameplay) +##### `POST subscription/{accountId:int}` — FIXED (was MISSING, breaks-gameplay) -No handler accepts POST or DELETE on subscription/{accountId} anywhere in the server (verified by grepping all HttpPost/HttpDelete attributes under subscription/: only /subscription/v1/* POSTs exist, whose literal 'v1' segment cannot match a numeric account id; the follow-graph mutations live at api/playersubscriptions/v1/subscribe/{targetId} which the 2023 client never calls). The client's subscribe (POST, form field roomId:Int64, omitted when -1) and unsubscribe (DELETE, no body) both 404 → 'Failed to subscribe to {0}' / 'Failed to unsubscribe from {0}' toasts; creator-club subscribing is entirely broken from the 2023 client. +Implemented in `DorkNet.Server/Controllers/API/Subscriptions/SubscriptionsController.cs`: `[HttpPost("/subscription/{accountId:long}")]` binds the optional `roomId` form field through `FormOrJsonModelBinder` and `[HttpDelete("/subscription/{accountId:long}")]` takes no body. Both write/remove the canonical player→player `Subscriptions` row AND mirror into `ClubSubscriptions` for the target's creator club (oldest club they own, the same resolution `subscription/details` reports as `ClubId`) so the client's post-write refresh of `subscription/mine/member` actually shows the subscription. Both are idempotent and return a bare 200 (the client parses no body). `roomId` is attribution only — no column exists for it, so it is logged rather than persisted. + +Original finding: No handler accepts POST or DELETE on subscription/{accountId} anywhere in the server (verified by grepping all HttpPost/HttpDelete attributes under subscription/: only /subscription/v1/* POSTs exist, whose literal 'v1' segment cannot match a numeric account id; the follow-graph mutations live at api/playersubscriptions/v1/subscribe/{targetId} which the 2023 client never calls). The client's subscribe (POST, form field roomId:Int64, omitted when -1) and unsubscribe (DELETE, no body) both 404 → 'Failed to subscribe to {0}' / 'Failed to unsubscribe from {0}' toasts; creator-club subscribing is entirely broken from the 2023 client. **Fix.** Add to PlayerSubscriptionsController (or ClubsController): [HttpPost("/subscription/{accountId:long}")] reading optional form field roomId and creating the subscription row (same semantics as Subscribe), and [HttpDelete("/subscription/{accountId:long}")] removing it. Return 200 with empty/any body (client ignores it, then refreshes via subscription/mine/member). Keep the row source consistent with what subscription/mine/member reads (ClubSubscriptions vs Subscriptions — currently mine/member reads ClubSubscriptions while the playersubscriptions endpoints write Subscriptions; the new handlers must write the table mine/member reads, or the subscribe will never show up). @@ -3089,13 +3097,13 @@ Handler: `DorkNet.Server/Controllers/API/PlayerSubscriptions/PlayerSubscriptions **Fix.** Join each top AccountId to its creator club (db.Clubs by CreatorPlayerId) and add ClubId/clubId to the per-row dictionary. -##### `POST api/AppIntegrity/v1/iosproducts` — VERB_MISMATCH (none) +##### `POST api/AppIntegrity/v1/iosproducts` — FIXED (was VERB_MISMATCH) -Server registers GET only; client POSTs a JSON product array and expects {Success:Boolean,Message:String} (PHMHCPEMABG). A POST would 405, and even the GET body (list of {ProductId,Price,CurrencyType,Name}) is not the expected shape. iOS-only StoreKit check — never fired by the PC build. +Server registered GET only; the client POSTs a raw JSON product array (verb `rdx=2` at GAAPALMODOL.txt:106, route literal :104, raw-JSON body via BNDIAONDFFF.FJLLPHFOOJJ :138) and expects {Success:Boolean,Message:String} (PHMHCPEMABG.txt:3/23, keys GBPDOLJBABB.txt:191-218). The POST 405'd, and the GET body (list of {ProductId,Price,CurrencyType,Name}) is not the expected shape either. iOS-only StoreKit report — never fired by the PC build. -Handler: `DorkNet.Server/Controllers/API/AppIntegrity/AppIntegrityController.cs:13` +Handler: `DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs` (POST) — the pre-existing GET at `DorkNet.Server/Controllers/API/AppIntegrity/AppIntegrityController.cs:13` is left in place; the two verb constraints are disjoint so they do not collide. -**Fix.** In AppIntegrityController.cs change to [HttpPost("api/AppIntegrity/v1/iosproducts")], read (and ignore) the JSON body, return Ok(new { Success = true, Message = "" }). +**Fixed.** The POST reads the top-level array of {Name:String,Price:Single,ProductId:String} (GAAPALMODOL/KOLPHINEHDD, key table KAONMCEONPF.txt:255-306) and stores the reported StoreKit catalogue as the caller's `appintegrity:iosproducts` PlayerSettings row (`{productId}={price}` pairs, entries dropped once the 1024-char column budget is spent), replacing any earlier report. Returns {Success:true, Message:""}; a malformed body returns Success=false with Message `malformed_product_list`. ### Progression, quests, leaderboards and misc @@ -3332,7 +3340,7 @@ DESERIALIZATION: every DTO has a generated formatter that registers each JSON ke KEY 2023-vs-2020 DELTA: api/config/v2 reader (CONDPLKKBPI via CDEDNEPPPND) reads ONLY LevelProgressionMaps/DailyObjectives/ServerMaintenance/AutoMicMutingConfig/StorefrontConfig/RoomKeyConfig/RoomCurrencyConfig/ShareBaseUrl. The 2020-era keys DorkNet also sends (MessageOfTheDay, CdnBaseUri, PhotonConfig, MatchmakingParams, ConfigTable, ServiceUrls) are ignored by the 2023 client. New nested key: LevelProgressionMaps[].GiftDropId (Int32?). -SERVER GAPS FOUND (routes the 2023 client can hit that DorkNet does not register, per the server's reflected route table): (1) POST actionlink, GET actionlink/{code}, POST actionlink/{code}/consume — Link host; (2) PUT datalink, GET datalink/{code} — Link host; (3) POST feed/query — Discovery host (the existing GET "feed" is api/photos/v1/feed, unrelated); (4) GET announcements/mine and GET announcements/subscription/mine — Clubs host; (5) POST announcements/club/{clubId} (create at collection URL — ClubsController only registers POST at .../{announcementId}); (6) POST api/testcasemanagement/v1/testcase/{id}/comment. Also verify announcements/v2/*/unread handlers honor ?sendAnnouncements=true by embedding Announcements arrays. +SERVER GAPS FOUND (routes the 2023 client can hit that DorkNet does not register, per the server's reflected route table): (1) POST actionlink, GET actionlink/{code}, POST actionlink/{code}/consume — Link host [NOW FIXED, LinkController.cs]; (2) PUT datalink, GET datalink/{code} — Link host [NOW FIXED, LinkController.cs]; (3) POST feed/query — Discovery host (the existing GET "feed" is api/photos/v1/feed, unrelated); (4) GET announcements/mine and GET announcements/subscription/mine — Clubs host; (5) POST announcements/club/{clubId} (create at collection URL — ClubsController only registers POST at .../{announcementId}); (6) POST api/testcasemanagement/v1/testcase/{id}/comment. Also verify announcements/v2/*/unread handlers honor ?sendAnnouncements=true by embedding Announcements arrays. SERVER SHAPE MISMATCHES (working but lossy): api/config/v1/azurespeech — client key is "Key", server sends "SubscriptionKey" (client gets null Key); api/config/v1/amplitude — client also reads RudderStackKey/UseRudderStack/StatSigKey; api/config/v1/backtrace — client's 9 keys (ANRThresholdMs, CaptureNativeCrashes, FilterType, LogLineCount, MessageCount, MessageRegex, ReportBudget, SampleRate, VersionRegex) are mostly absent from the server response (only SampleRate/ReportBudget present); all fields silently default today. @@ -3445,17 +3453,13 @@ Handler: `DorkNet.Server\Controllers\Clubs\ClubsController.cs:737-742` **Fix.** Make AnnouncementsForClub return one object: Ok(new { ClubId = clubId, LastReadAnnouncementId = , Announcements = rows.OrderByDescending(CreatedAt).Select(ToWireAnnouncement) }) — always an object, never an array, LastReadAnnouncementId always a number. -##### `GET announcements/mine` — MISSING (degraded) +##### `GET announcements/mine` — FIXED -Not registered (server-routes.json has no announcements/mine; ClubsController's only 'mine' announcement routes are the v2 unread pair). Client expects a bare JSON array of JDPPAFLFNBD announcement objects for all of the player's own clubs; 404 rejects the promise and the aggregated club-announcements surface on the watch fails. +`ClubsController.AnnouncementsMine` → `ClubService.AnnouncementsForMemberClubsAsync`. Flat `List` of `ToWireAnnouncement` rows, newest first — no per-club envelope and no unread filter (the issuing method is `FGLDKEJLAKB> IOCFHCFLIMJ()` at IKMMOCKDKAF.txt:996, described in-binary as "get all announcements for clubs I'm in"). Membership rows carrying the pending (128) or ban (256) marker are excluded so a pending/banned account never sees a club's board, and disbanded clubs are re-filtered on State. -**Fix.** Add [HttpGet("/announcements/mine")] [Authorize] in ClubsController returning a flat array of ToWireAnnouncement rows for every club the caller is a member of (reuse ClubService queries), newest first. +##### `GET announcements/subscription/mine` — FIXED -##### `GET announcements/subscription/mine` — MISSING (degraded) - -Same as announcements/mine but scoped to clubs the player is subscribed to; not registered, 404s. - -**Fix.** Add [HttpGet("/announcements/subscription/mine")] [Authorize] in ClubsController returning a flat array of ToWireAnnouncement rows for subscribed clubs (clubs.UnreadSubscriptionAsync's underlying subscription query, without the unread filter). +`ClubsController.AnnouncementsSubscriptionMine` → `ClubService.AnnouncementsForSubscribedClubsAsync`: same flat shape (`IIOMOLFOLPD` at IKMMOCKDKAF.txt:1083, route literal :1162), scoped to `ClubSubscriptions` rather than memberships. ##### `GET announcements/v2/mine/unread (+ announcements/v2/subscription/mine/unread, with and without ?sendAnnouncements=true)` — SHAPE_MISMATCH (degraded) @@ -3465,35 +3469,35 @@ Handler: `DorkNet.Server\Controllers\Clubs\ClubsController.cs:151-167 (rows buil **Fix.** In ClubsController.GroupByClub emit LastAnnouncementId = ordered.First().Id and LastReadAnnouncementId = (a number, never null). Key casing is already fine (tri-cased reader). -##### `GET actionlink/{code}` — MISSING (degraded) +##### `GET actionlink/{code}` — FIXED -No actionlink route exists anywhere in the server (repo-wide grep; PageviewController.cs:28-32 explicitly documents actionlink/* as out-of-scope). Client expects {CreatorPlayerId:Int32, Data:String, IsValid:Boolean} (NBAFGFFIOLK). Looking up a share/promo/invite code 404s. +Was unrouted (PageviewController.cs:28-32 documented actionlink/* as out-of-scope). Now `DorkNet.Server\Controllers\Link\LinkController.cs` — `[HttpGet("/actionlink/{code}")]`, `[AllowAnonymous]` because `ActionCode.ModifyPreAuthLaunchTarget` resolves an inbound deep link during BootSequence, before the player holds a token. Verb from CKBKHENHCAN.txt:793 (`045 Move rdx, 0`), host 11 at :798. Response is the camelCase anonymous object `{creatorPlayerId, data, isValid}`, which the tri-cased generated formatter DPIECEMDKPL accepts (:267/:278/:286 CreatorPlayerId, :294/:302 Data, :310/:318/:326 IsValid). Unknown/expired codes answer 200 with `IsValid:false` (never 404) so the promise resolves. Reading a code does not spend a use. -**Fix.** Add an ActionLinkController (Link host): GET /actionlink/{code} returning {CreatorPlayerId, Data, IsValid} from a new action-link table (IsValid:false for unknown codes rather than 404, so the client gets a parseable body). +##### `POST actionlink` — FIXED -##### `POST actionlink` — MISSING (degraded) +Now `LinkController.ActionLinkCreate` — `[HttpPost("/actionlink")]`, `[Authorize]`. Verb from CKBKHENHCAN.txt:1226 (`106 Move rdx, 2`), host `105 Move r8, 11` at :1225. Form fields bound through `FormOrJsonModelBinder`: data (:1234), validHours (:1249), maxCount (:1261), codeType (:1273), extraDataId (:1287). Return type is `FGLDKEJLAKB` (:860) so the body is a bare JSON string — emitted with `Content(JsonSerializer.Serialize(code), "application/json")`, because `Ok(string)` goes out as unquoted text/plain via StringOutputFormatter. -Create-code endpoint absent. Client sends url-encoded form fields data/validHours/maxCount/codeType/extraDataId (maxCount and extraDataId omitted when null) and deserializes a BARE JSON string (the generated code). 404 breaks creating share/invite codes. +Codes are 8 characters from the unambiguous alphabet `23456789ABCDEFGHJKLMNPQRSTUVWXYZ` (no O/0/I/1 — players re-type these into ActionCodeConsumptionModel), reserved with a global uniqueness check before insert. -**Fix.** Same ActionLinkController: POST /actionlink reading the five form fields, generating a short unique code, persisting {code, data, creatorPlayerId, expiry=now+validHours, maxCount, codeType, extraDataId}, and returning the code serialized as a JSON string (e.g. return Ok(code) with string result → "ABC123"). +##### `POST actionlink/{code}/consume` — FIXED -##### `POST actionlink/{code}/consume` — MISSING (degraded) +Now `LinkController.ActionLinkConsume` — `[HttpPost("/actionlink/{code}/consume")]`, `[AllowAnonymous]` (can fire from the pre-auth launch-target path). Verb from CKBKHENHCAN.txt:1675 (`077 Move rdx, 2`); path built by `String.Concat("actionlink/", WebUtility.UrlEncode(code), "/consume")` at :1665-1669. Form fields: id (:1684), validHours (:1692), codeType (:1720), newPlayer (:1732), newInstall (:1745). -Redeem endpoint absent. Client POSTs form fields id/validHours/codeType/newPlayer/newInstall to actionlink/{urlencoded code}/consume and expects {CreatorPlayerId, Data, IsValid}. 404 breaks code redemption (entered codes / deeplinks). +Semantics established from the call sites: `validHours` is the creating ActionCode's `Configuration.AutoRenewHours`, loaded immediately before dispatch (`043 Call Configuration.get_AutoRenewHours`, RoomCode_NestedType_IBLIEDEHMPI.txt:168; ClubCode_NestedType_EAGCMJOEANN.txt:78) and null when AutoRenew is off — so a present value renews the code's expiry instead of letting it age out. `codeType` is verified against the stored type (CBKKGBIJHAL: Unknown=-1, Friend=0, Referral=1, Meetup=2, Club=3, PlayerEvent=4, Room=5, Influencer=6, Photo=7; RoomCode passes 5 at IBLIEDEHMPI.txt:175, ClubCode passes 3 at EAGCMJOEANN.txt:85) so a room payload cannot be handed to the club handler. Expiry and maxCount are enforced; a successful redeem increments the use counter. -**Fix.** Same ActionLinkController: POST /actionlink/{code}/consume — look up the code, enforce expiry/maxCount, return {CreatorPlayerId, Data, IsValid} (IsValid:false when invalid/expired). +Referral codes are the only type with no other server touchpoint — ReferralCode has no HTTP routes of its own and `IncentivizedReferralsController` counts `referral:credited:{inviterId}:{inviteeId}` rows that nothing wrote. Consuming a Referral code as a signed-in `newPlayer` now writes that row (on the invitee, matching the `externalinvite:redeemed:*` convention), so incentivized-referral progress is no longer permanently zero. -##### `GET datalink/{code}` — MISSING (degraded) +##### `GET datalink/{code}` — FIXED -Absent. Client expects {Data:String} (LBBKBMPAEIO via IKBPJACLIHH, tri-cased). 404 breaks fetching share payload blobs. +Now `DorkNet.Server\Controllers\Link\LinkController.cs` — `[HttpGet("/datalink/{code}")]`, `[AllowAnonymous]` (resolved from a cold-boot deep link). Verb from CKBKHENHCAN.txt:352 (`045 Move rdx, 0`), literal `"datalink/"` at :345. Response is `{data}`; the generated formatter IKBPJACLIHH registers a single member Data (:139) plus the camelCase alias (:150). An unknown code returns an empty Data rather than 404 so the strict reader still gets an object. -**Fix.** Add DataLinkController (Link host): GET /datalink/{code} returning {Data:"..."} from a datalink table. +##### `PUT datalink` — FIXED -##### `PUT datalink` — MISSING (degraded) +Now `LinkController.DataLinkCreate` — `[HttpPut("/datalink")]`, `[Authorize]`. Verb from CKBKHENHCAN.txt:560 (`033 Move rdx, 3` = PUT), literal `"datalink"` at :558, sole form field `data` at :569, bound through `FormOrJsonModelBinder`. Return type `FGLDKEJLAKB` (:419) — bare JSON string, emitted with `Content(JsonSerializer.Serialize(code), "application/json")`. Codes here are 10 characters (nobody types a datalink code; it only travels inside a URL). -Absent. Client PUTs form field data=String and deserializes a BARE JSON string (the generated code). 404 breaks storing share payloads (share flows that pair with datalink/{code}). +##### Link-host storage note -**Fix.** Same DataLinkController: PUT /datalink reading form field 'data', persisting under a generated short code, returning the code as a JSON string. +All five link routes persist in `PlayerSettings` — the server's existing general-purpose per-player key/value table already used for ban-appeal codes and external friend invites — under `actionlink:{CODE}` / `datalink:{CODE}` keys owned by the creator's row, so codes survive restarts without a new table. Action-link rows pack `{d:data, e:expiry, m:maxCount, u:uses, t:codeType, x:extraDataId}` into the single Value column with one-character keys; because `PlayerSettingEntity.Value` is `[MaxLength(1024)]` (a real `varchar(1024)` on Postgres), both create routes reject over-long payloads with a logged 400 instead of blowing up at SaveChanges. ##### `POST api/testcasemanagement/v1/testcase/{id}/status` — SHAPE_MISMATCH (degraded)