diff --git a/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs b/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs index ab59376..d04d854 100644 --- a/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs +++ b/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs @@ -17,7 +17,9 @@ namespace DorkNet.Server.Controllers.API.CustomAvatarItems; public class CustomAvatarItemsController( DorkNetDbContext db, LevelService level, - DomainConfig domain) : ControllerBase + DomainConfig domain, + IObjectStorage storage, + ILogger logger) : ControllerBase { [HttpGet] [AllowAnonymous] @@ -47,6 +49,71 @@ public class CustomAvatarItemsController( return Ok(result.Select(ToWire)); } + /// POST api/customAvatarItems/v1 — publish a shirt from the + /// in-game designer. The collection route was GET-only, so every + /// create 405'd and the designer's "publish" button could never work. + /// + /// The 2023-03-21 client sends multipart/form-data (verb ordinal + /// rdx=2=POST at RecNet.Runtime/MPBLNLMCEDL.txt:1352-1354): a named + /// param "metadata" carrying JsonConvert.SerializeObject of the + /// DCKDCBDHDKI request object, then TWO file parts, "thumbnailImage" + /// and "design", both with filename "file.bin" + /// (:1362-1390). DCKDCBDHDKI's field order is pinned by the arg-store + /// sequence at :1096-1133 — +0x10 Name, +0x18 Description, +0x20 Price, + /// +0x24 BaseAvatarItemId(int?), +0x30 Color ("#RRGGBB", built via + /// ColorUtility.ToHtmlStringRGB at :1118-1131), +0x38 the permission + /// enum we store as ItemType. The metadata JSON KEY names live in + /// attribute metadata the dump doesn't render, so every field is read + /// through an alias list rather than a single guessed key. + [HttpPost] + [Authorize] + [Consumes("multipart/form-data", "application/x-www-form-urlencoded", "application/json")] + public async Task Create() + { + var pid = this.RequireCurrentPlayerId(); + var req = await ReadDesignRequestAsync(); + + var name = (req.Name ?? string.Empty).Trim(); + if (name.Length < 3) return BadRequest(new { error = "name_too_short" }); + if (name.Length > 128) name = name[..128]; + var description = (req.Description ?? string.Empty).Trim(); + if (description.Length > 1024) description = description[..1024]; + + var item = new CustomAvatarItemEntity + { + CreatorPlayerId = pid, + Name = name, + Description = description, + Price = Math.Max(MinPublicItemPrice, req.Price ?? MinPublicItemPrice), + ItemType = req.ItemType ?? 0, + BaseAvatarItemId = req.BaseAvatarItemId ?? 0, + Color = Trim(req.Color, 64), + // No boolean lives in DCKDCBDHDKI, so a freshly created item is + // private until the permission enum (ItemType) says otherwise. + IsPublic = req.IsPublic ?? false, + }; + + // "thumbnailImage" → ImageName (card art), "design" → AssetName (the + // shirt texture itself). Fall back to positional order for any client + // that names the parts differently. + var (thumbnail, asset) = ReadItemFileParts(); + if (thumbnail is not null) + item.ImageName = await StoreImageBlobAsync(pid, thumbnail, "custom item thumbnail"); + if (asset is not null) + item.AssetName = await StoreImageBlobAsync(pid, asset, "custom item design"); + if (item.ImageName.Length == 0) item.ImageName = Trim(req.ImageName, 256); + if (item.AssetName.Length == 0) item.AssetName = Trim(req.AssetName, 256); + + db.CustomAvatarItems.Add(item); + await db.SaveChangesAsync(); + await EnsureOwnershipAsync(pid, item.Id); + + logger.LogInformation( + "[customAvatarItems] created {PublicId} by {Pid} name='{Name}' image={Image} asset={Asset}", + item.PublicId, pid, item.Name, item.ImageName, item.AssetName); + return Ok(ToWire(item)); + } + [HttpGet("{id:guid}")] [AllowAnonymous] public async Task Get(Guid id) @@ -57,12 +124,101 @@ public class CustomAvatarItemsController( return Ok(ToWire(item)); } + /// PUT api/customAvatarItems/v1/{id} — edit an existing item's + /// metadata. Only GET was registered on the id route, so the details + /// screen's Save button 405'd ("Unable to update custom avatar item"). + /// + /// The client builds the URL with String.Format("{0}/v1/{1}") over + /// "api/customAvatarItems" and issues verb ordinal 3 = PUT + /// (RecNet.Runtime/MPBLNLMCEDL.txt:1899-1913). The body is RAW JSON — + /// JsonConvert.SerializeObject of MOBHOFPIIIJ handed to + /// BNDIAONDFFF.FJLLPHFOOJJ at :1890-1920 — NOT form-urlencoded, so this + /// reads the body itself rather than model-binding it. MOBHOFPIIIJ + /// carries exactly four properties whose accessors are shared with + /// NOAAFNFJPFB (MOBHOFPIIIJ.txt:3-77): Name, Description, Price(int?) + /// and the permission enum stored here as ItemType. Every field is + /// optional — absent members leave the stored value alone. + [HttpPut("{id:guid}")] + [Authorize] + public async Task Update(Guid id) + { + var pid = this.RequireCurrentPlayerId(); + var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id); + if (item is null) return NotFound(); + if (item.CreatorPlayerId != pid) return Forbid(); + + var req = await ReadDesignRequestAsync(); + if (req.Name is { } rawName) + { + var name = rawName.Trim(); + if (name.Length < 3) return BadRequest(new { error = "name_too_short" }); + item.Name = name.Length > 128 ? name[..128] : name; + } + if (req.Description is { } rawDescription) + { + var description = rawDescription.Trim(); + item.Description = description.Length > 1024 ? description[..1024] : description; + } + if (req.Price is { } price) item.Price = Math.Max(MinPublicItemPrice, price); + if (req.ItemType is { } itemType) item.ItemType = itemType; + if (req.BaseAvatarItemId is { } baseItemId) item.BaseAvatarItemId = baseItemId; + if (req.Color is { } color) item.Color = Trim(color, 64); + if (req.ImageName is { } imageName) item.ImageName = Trim(imageName, 256); + if (req.AssetName is { } assetName) item.AssetName = Trim(assetName, 256); + if (req.IsPublic is { } isPublic) item.IsPublic = isPublic; + item.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWire(item)); + } + + /// DELETE api/customAvatarItems/v1/{id} — verb ordinal 4 on the + /// same String.Format("{0}/v1/{1}") URL + /// (RecNet.Runtime/MPBLNLMCEDL.txt:2188-2199); the client is + /// fire-and-forget, so the missing route made deleted shirts silently + /// reappear on the next list refresh. + /// + /// has no IsDeleted column (unlike + /// UgcPurchasableEntity), and adding one is a schema migration, so this + /// is a real delete: the ownership rows go first so nobody keeps the + /// item in their wardrobe, then the item row itself. + [HttpDelete("{id:guid}")] + [Authorize] + public async Task Delete(Guid id) + { + var pid = this.RequireCurrentPlayerId(); + var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id); + if (item is null) return Ok(); + if (item.CreatorPlayerId != pid) return Forbid(); + + var ownership = await db.CustomAvatarItemOwnership + .Where(o => o.CustomAvatarItemId == item.Id) + .ToListAsync(); + if (ownership.Count > 0) db.CustomAvatarItemOwnership.RemoveRange(ownership); + db.CustomAvatarItems.Remove(item); + await db.SaveChangesAsync(); + logger.LogInformation( + "[customAvatarItems] deleted {PublicId} by {Pid} ({Owners} ownership rows removed)", + id, pid, ownership.Count); + return Ok(); + } + + /// GET/POST api/customAvatarItems/v1/bulk — resolve the custom + /// items other players are wearing. The guid-list parameter key the + /// client uses is the literal "customAvatarItemIds" + /// (RecNet.Runtime/MPBLNLMCEDL_NestedType_KKEBBAAGHAM.txt:232-244, POST + /// ordinal rdx=2), which no read path here honoured, and the old + /// [FromBody] BulkRequest parameter made ASP.NET reject a + /// form-urlencoded body with 415 before the handler ran. Result: an + /// empty array, remote players' shirts never rendered, and the client + /// re-queried the same ids forever — the same failure the ugcPurchasables + /// bulk endpoint hit. The binding is gone; + /// now reads the key from the JSON body, the form and the query. [HttpGet("bulk")] [HttpPost("bulk")] [AllowAnonymous] - public async Task Bulk([FromBody] BulkRequest? body) + public async Task Bulk() { - var ids = await ReadIdsAsync(body); + var ids = await ReadIdsAsync(); if (ids.Count == 0) return Ok(Array.Empty()); var rows = await db.CustomAvatarItems .Where(i => ids.Contains(i.PublicId) && (i.IsPublic || i.CreatorPlayerId == this.CurrentPlayerId())) @@ -222,9 +378,126 @@ public class CustomAvatarItemsController( [AllowAnonymous] public IActionResult IsRenderingEnabled() => Content("true", "application/json"); + /// The client DTO (IHLMDLKOLNB) is an object with exactly one + /// Int32 property, but its accessor name (GFGCCNBDKAK) appears nowhere + /// else in the dump, so — unlike Price/BaseAvatarItemId/Color, which are + /// pinned by accessors shared with NOAAFNFJPFB — the JSON key is NOT + /// recoverable. All four spellings below are UNPROVEN hedges; Newtonsoft + /// ignores the ones that don't match, and a total miss falls back to the + /// DTO's static default (IHLMDLKOLNB.txt:75-117) rather than throwing. [HttpGet("minPriceForPublicItem")] [AllowAnonymous] - public IActionResult MinPriceForPublicItem() => Ok(new { MinPrice = 100, Price = 100 }); + public IActionResult MinPriceForPublicItem() => Ok(new + { + MinPrice = MinPublicItemPrice, + Price = MinPublicItemPrice, + MinimumPrice = MinPublicItemPrice, + MinPriceForPublicItem = MinPublicItemPrice, + }); + + /// GET api/customAvatarItems/v1/design — hand back the caller's + /// saved in-progress shirt design. Verb ordinal 0 = GET + /// (RecNet.Runtime/MPBLNLMCEDL.txt:4782-4783); only POST was registered, + /// so the designer logged "Unable to get custom avatar design info" every + /// time it opened. + /// + /// The response DTO NCMMAGDIBND is {Int32 @0x10, Int32? @0x14, String + /// @0x20, String @0x28} (NCMMAGDIBND.txt:3-145). The obfuscator's name + /// map is per-property-name rather than per-member, so those four + /// accessors identify the properties: IDELLAGIFAE/MOKDHHGDMJB is the same + /// pair used for BaseAvatarItemId on GIOBJIGEMNG and DCKDCBDHDKI, + /// GJEFKPNEFGI is the Color set from ColorUtility.ToHtmlStringRGB, and + /// DPJMMBLGKGF / BADCCGPNLMN are the leading int and the first string of + /// NOAAFNFJPFB — the creator id and the image name. Hence + /// {CreatorPlayerId, BaseAvatarItemId, ImageName, Color}. The extra keys + /// are inert aliases. + /// + /// Returns an empty design rather than 404 when nothing is saved so the + /// designer opens clean instead of showing the error toast. + [HttpGet("design")] + [Authorize] + public async Task GetDesign() + { + var pid = this.RequireCurrentPlayerId(); + var design = await LoadDesignAsync(pid) ?? new StoredDesign(); + return Ok(new + { + CreatorPlayerId = (int)pid, + CreatorId = (int)pid, + design.BaseAvatarItemId, + design.ImageName, + ImageUrl = string.IsNullOrWhiteSpace(design.ImageName) + ? string.Empty + : $"https://{domain.Sub("cdn")}/{design.ImageName}", + design.Color, + }); + } + + /// PUT api/customAvatarItems/v1/design — save the in-progress + /// shirt texture. The route only accepted POST, so the designer could + /// never persist a design ("Unable to save custom avatar design"). + /// + /// Verb ordinal 3 = PUT with a multipart body: named param + /// "metadata" plus a file part "design" with filename + /// "file.bin" + /// (RecNet.Runtime/MPBLNLMCEDL_NestedType_OPEGIAOIHMA.txt:200-227). The + /// metadata object GIOBJIGEMNG holds just two properties — a + /// Nullable<Int32> base avatar item id at +0x10 and the "#RRGGBB" + /// colour string at +0x18 (GIOBJIGEMNG.txt:3-43), written from the + /// FKCJDHHJAFF(texture, color, baseItemId) args at MPBLNLMCEDL.txt:4652-4654. + /// The client's return type is status-only (LDGADANDBIO), so a bare 200 + /// is the whole contract. + [HttpPut("design")] + [Authorize] + [Consumes("multipart/form-data", "application/x-www-form-urlencoded", "application/json")] + public async Task SaveDesign() + { + var pid = this.RequireCurrentPlayerId(); + var req = await ReadDesignRequestAsync(); + var existing = await LoadDesignAsync(pid); + + var design = new StoredDesign + { + BaseAvatarItemId = req.BaseAvatarItemId ?? existing?.BaseAvatarItemId, + Color = Trim(req.Color, 64) is { Length: > 0 } color ? color : existing?.Color ?? string.Empty, + ImageName = existing?.ImageName ?? string.Empty, + }; + + // The texture arrives as the "design" part; accept any single file + // part as a fallback for a client that names it differently. + var file = Request.HasFormContentType + ? Request.Form.Files.GetFile("design") ?? Request.Form.Files.FirstOrDefault() + : null; + if (file is not null && file.Length > 0) + { + var bytes = await ReadFileBytesAsync(file); + var blobName = DesignImageBlobName(pid); + await PutBlobAsync(blobName, bytes, SniffImageContentType(bytes), "custom avatar design"); + design.ImageName = blobName; + } + + await SaveDesignAsync(pid, design); + return Ok(); + } + + /// DELETE api/customAvatarItems/v1/design — discard the saved + /// design. Verb ordinal 4 on the same literal + /// (RecNet.Runtime/MPBLNLMCEDL.txt:4862-4864); without it the client + /// logged "Unable to delete custom avatar item design". + /// + /// has no delete operation, so the record is + /// tombstoned (a Deleted marker that reads + /// as "no design") and the texture object is truncated to zero bytes so + /// the pixels really are gone. + [HttpDelete("design")] + [Authorize] + public async Task DeleteDesign() + { + var pid = this.RequireCurrentPlayerId(); + await SaveDesignAsync(pid, new StoredDesign { Deleted = true }); + await PutBlobAsync(DesignImageBlobName(pid), Array.Empty(), "image/png", "custom avatar design"); + return Ok(); + } [HttpPost("design")] [Authorize] @@ -287,6 +560,16 @@ public class CustomAvatarItemsController( if (await db.CustomAvatarItemOwnership.AnyAsync(o => o.PlayerId == pid && o.CustomAvatarItemId == item.Id)) return Ok(new { Success = true, AlreadyOwned = true, Item = ToWire(item) }); + // requestedPrice is baked into the URL the client formats — + // "{0}/v1/{1}/purchase/?requestedPrice={2}" + // (RecNet.Runtime/MPBLNLMCEDL.txt:5084). It is the price the player was + // SHOWN, so a value below the real price means the listing moved under + // them (or the request was tampered with); reject instead of silently + // charging more than the confirmation dialog said. + var requestedPrice = int.TryParse(Request.Query["requestedPrice"].FirstOrDefault(), out var rp) ? rp : (int?)null; + if (requestedPrice is { } shown && shown < item.Price) + return Ok(new { Success = false, Error = "price_changed", Price = item.Price, Item = ToWire(item) }); + var balance = await level.GetBalanceAsync(pid, 2); if (balance < item.Price) return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance }); @@ -315,64 +598,311 @@ public class CustomAvatarItemsController( await db.SaveChangesAsync(); } - private async Task> ReadIdsAsync(BulkRequest? body) + /// Collect the bulk request's guid list. The 2023 client's key is + /// "customAvatarItemIds" (MPBLNLMCEDL_NestedType_KKEBBAAGHAM.txt:242); + /// older callers use ids/itemIds. Which transport the request builder picks + /// for a POST collection param isn't decidable from ISIL, so all three are + /// read: raw JSON body, form fields and query pairs. The body is read + /// manually — a [FromBody] parameter would 415 a form-encoded POST before + /// the handler ran. + private async Task> ReadIdsAsync() { var result = new List(); - if (body?.Ids is { Count: > 0 }) - result.AddRange(body.Ids); - if (body?.ItemIds is { Count: > 0 }) - result.AddRange(body.ItemIds); + void AddCsv(string? value) + { + foreach (var part in (value ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + if (Guid.TryParse(part, out var id)) result.Add(id); + } + + if ((Request.ContentLength ?? 0) > 0 + && Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) + { + try + { + using var doc = await JsonDocument.ParseAsync(Request.Body); + var root = doc.RootElement; + // Either a bare guid array or an object carrying one under any + // of the accepted key spellings. + if (root.ValueKind == JsonValueKind.Array) + { + AddJsonGuids(root, result); + } + else if (root.ValueKind == JsonValueKind.Object) + { + foreach (var prop in root.EnumerateObject()) + { + if (!IdListKeys.Contains(prop.Name)) continue; + if (prop.Value.ValueKind == JsonValueKind.Array) AddJsonGuids(prop.Value, result); + else if (prop.Value.ValueKind == JsonValueKind.String) AddCsv(prop.Value.GetString()); + } + } + } + catch (JsonException) + { + } + } foreach (var pair in Request.Query) foreach (var value in pair.Value) - foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - if (Guid.TryParse(part, out var id)) result.Add(id); + AddCsv(value); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); - foreach (var key in new[] { "ids", "Ids", "itemIds", "ItemIds" }) - foreach (var value in form[key]) - foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - if (Guid.TryParse(part, out var id)) result.Add(id); + foreach (var field in form) + { + if (!IdListKeys.Contains(field.Key)) continue; + foreach (var value in field.Value) AddCsv(value); + } } return result.Distinct().Take(200).ToList(); } + private static void AddJsonGuids(JsonElement array, List into) + { + foreach (var el in array.EnumerateArray()) + if (el.ValueKind == JsonValueKind.String && Guid.TryParse(el.GetString(), out var id)) + into.Add(id); + } + + /// Accepted spellings of the bulk guid-list key. Case-insensitive + /// set so "customAvatarItemIds" / "CustomAvatarItemIds" both land. + private static readonly HashSet IdListKeys = new(StringComparer.OrdinalIgnoreCase) + { + "customAvatarItemIds", "ids", "itemIds", + }; + + /// Read the fields of a create / update / design-save request. + /// Three encodings have to work: the designer's multipart bodies wrap + /// everything in a single "metadata" form field holding JSON + /// (MPBLNLMCEDL.txt:1362 and MPBLNLMCEDL_NestedType_OPEGIAOIHMA.txt:210), + /// PUT {id} sends a raw JSON body, and older/admin callers post flat form + /// fields. The JSON key names are not in the dump — they live in attribute + /// metadata Cpp2IL doesn't render — so each field is matched against an + /// alias list rather than one guessed spelling. Only the field ORDER is + /// proven (from the arg-store sequence); the names are inferred. private async Task ReadDesignRequestAsync() { if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); + var metadata = form["metadata"].FirstOrDefault() ?? form["Metadata"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(metadata)) + return ParseDesignJson(metadata); + + string? Field(params string[] keys) + { + foreach (var key in keys) + if (form[key].FirstOrDefault() is { } v) return v; + return null; + } + return new DesignRequest { - Id = Guid.TryParse(form["id"].FirstOrDefault() ?? form["Id"].FirstOrDefault(), out var id) ? id : null, - Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(), - Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(), - Price = int.TryParse(form["price"].FirstOrDefault() ?? form["Price"].FirstOrDefault(), out var price) ? price : null, - ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var itemType) ? itemType : null, - BaseAvatarItemId = int.TryParse(form["baseAvatarItemId"].FirstOrDefault() ?? form["BaseAvatarItemId"].FirstOrDefault(), out var baseId) ? baseId : null, - Color = form["color"].FirstOrDefault() ?? form["Color"].FirstOrDefault(), - ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(), - AssetName = form["assetName"].FirstOrDefault() ?? form["AssetName"].FirstOrDefault(), - IsPublic = bool.TryParse(form["isPublic"].FirstOrDefault() ?? form["IsPublic"].FirstOrDefault(), out var pub) ? pub : null, + Id = Guid.TryParse(Field("id", "Id"), out var id) ? id : null, + Name = Field("name", "Name"), + Description = Field("description", "Description"), + Price = int.TryParse(Field("price", "Price"), out var price) ? price : null, + ItemType = int.TryParse(Field("itemType", "ItemType", "permission", "Permission"), out var itemType) ? itemType : null, + BaseAvatarItemId = int.TryParse(Field("baseAvatarItemId", "BaseAvatarItemId"), out var baseId) ? baseId : null, + Color = Field("color", "Color"), + ImageName = Field("imageName", "ImageName"), + AssetName = Field("assetName", "AssetName"), + IsPublic = bool.TryParse(Field("isPublic", "IsPublic"), out var pub) ? pub : null, }; } + using var reader = new StreamReader(Request.Body); + return ParseDesignJson(await reader.ReadToEndAsync()); + } + + /// Lenient JSON → . Members that are + /// absent stay null so callers can tell "not sent" from "sent empty" — + /// PUT {id} relies on that to leave untouched fields alone. + private static DesignRequest ParseDesignJson(string json) + { + var req = new DesignRequest(); + if (string.IsNullOrWhiteSpace(json)) return req; try { - return await JsonSerializer.DeserializeAsync( - Request.Body, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) - ?? new DesignRequest(); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return req; + foreach (var prop in doc.RootElement.EnumerateObject()) + { + var value = prop.Value; + switch (prop.Name.ToLowerInvariant()) + { + case "id" or "customavataritemid": + if (value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out var id)) req.Id = id; + break; + case "name" or "itemname" or "title": + req.Name = AsString(value); + break; + case "description" or "desc": + req.Description = AsString(value); + break; + case "price": + req.Price = AsInt(value); + break; + // The enum at DCKDCBDHDKI+0x38 / NOAAFNFJPFB+0x3C is the + // item's permission (the 06.21 dump names its config row + // CustomAvatarItemPermissionFriendlyNameMapping); this + // schema calls that column ItemType, hence both aliases. + case "itemtype" or "type" or "permission" or "avataritemtype": + req.ItemType = AsInt(value); + break; + case "baseavataritemid" or "baseitemid" or "avataritemid": + req.BaseAvatarItemId = AsInt(value); + break; + case "color" or "colorhex" or "tint": + req.Color = AsString(value); + break; + case "imagename" or "thumbnail" or "thumbnailimage": + req.ImageName = AsString(value); + break; + case "assetname" or "design" or "designname": + req.AssetName = AsString(value); + break; + case "ispublic" or "public": + req.IsPublic = value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => bool.TryParse(value.GetString(), out var b) ? b : null, + _ => null, + }; + break; + } + } } catch (JsonException) { - return new DesignRequest(); + } + + return req; + } + + private static string? AsString(JsonElement value) => value.ValueKind switch + { + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.ToString(), + _ => null, + }; + + private static int? AsInt(JsonElement value) => value.ValueKind switch + { + JsonValueKind.Number => value.TryGetInt32(out var n) ? n : null, + JsonValueKind.String => int.TryParse(value.GetString(), out var s) ? s : null, + _ => null, + }; + + /// Pick out the create request's two file parts. Named lookup + /// first ("thumbnailImage" / "design", both sent with filename + /// "file.bin" — MPBLNLMCEDL.txt:1372-1390), then positional order as a + /// fallback. + private (IFormFile? Thumbnail, IFormFile? Asset) ReadItemFileParts() + { + if (!Request.HasFormContentType) return (null, null); + var files = Request.Form.Files; + if (files.Count == 0) return (null, null); + var thumbnail = files.GetFile("thumbnailImage") ?? files.GetFile("thumbnail"); + var asset = files.GetFile("design") ?? files.GetFile("asset"); + if (thumbnail is null && asset is null) + { + thumbnail = files[0]; + asset = files.Count > 1 ? files[1] : null; + } + + return (thumbnail?.Length > 0 ? thumbnail : null, asset?.Length > 0 ? asset : null); + } + + /// Persist one uploaded image part and return its BlobName. Same + /// shape as the images controller: S3 (or the disk fallback) holds the + /// bytes, a RoomDataBlobs row keeps the uploader audit trail, and the + /// cdn serves it straight off the BlobName. + private async Task StoreImageBlobAsync(long playerId, IFormFile file, string what) + { + var bytes = await ReadFileBytesAsync(file); + var contentType = SniffImageContentType(bytes); + var blobName = $"img_p{playerId}_{Guid.NewGuid():N}.{(contentType == "image/jpeg" ? "jpg" : "png")}"; + await PutBlobAsync(blobName, bytes, contentType, what); + db.RoomDataBlobs.Add(new RoomDataBlobEntity + { + RoomId = 0, + BlobName = blobName, + UploadedByPlayerId = playerId, + UploadedAt = DateTime.UtcNow, + ReferencedFilenamesCsv = string.Empty, + }); + return blobName; + } + + private static async Task ReadFileBytesAsync(IFormFile file) + { + using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + return ms.ToArray(); + } + + /// The parts are sent with filename "file.bin" and no usable + /// content type, so sniff the magic bytes instead of trusting either. + private static string SniffImageContentType(byte[] bytes) => + bytes.Length >= 4 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 ? "image/png" + : bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF ? "image/jpeg" + : "image/png"; + + private async Task PutBlobAsync(string blobName, byte[] bytes, string contentType, string what) + { + try + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + var (bucket, key) = BlobRouter.Route(blobName); + await storage.PutAsync(bucket, key, bytes, contentType, timeout.Token); + } + catch (Exception ex) + { + logger.LogError(ex, "[customAvatarItems] storing {What} blob {Blob} failed", what, blobName); } } + // ── In-progress shirt design ──────────────────────────────────────────── + // The designer's draft (base item + colour + texture) has no table in this + // schema and adding one is a migration, so it lives in object storage + // under two deterministic per-player blob names: overwrite-in-place on + // save, tombstoned on delete. + private static string DesignMetaBlobName(long playerId) => $"caidesign_p{playerId}.json"; + + private static string DesignImageBlobName(long playerId) => $"caidesign_p{playerId}.png"; + + private async Task LoadDesignAsync(long playerId) + { + try + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + var (bucket, key) = BlobRouter.Route(DesignMetaBlobName(playerId)); + var bytes = await storage.GetAsync(bucket, key, timeout.Token); + if (bytes is null || bytes.Length == 0) return null; + var design = JsonSerializer.Deserialize( + bytes, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return design is null || design.Deleted ? null : design; + } + catch (Exception ex) when (ex is JsonException or IOException) + { + logger.LogWarning(ex, "[customAvatarItems] unreadable saved design for player {Pid}", playerId); + return null; + } + } + + private Task SaveDesignAsync(long playerId, StoredDesign design) => + PutBlobAsync( + DesignMetaBlobName(playerId), + JsonSerializer.SerializeToUtf8Bytes(design), + "application/json", + "custom avatar design record"); + private object ToWire(CustomAvatarItemEntity item) => new { Id = item.PublicId, diff --git a/DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs b/DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs index 386c3b4..142ee2b 100644 --- a/DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs +++ b/DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs @@ -13,73 +13,123 @@ public class ExternalFriendInviteController(DorkNetDbContext db) : ControllerBas { private long Me => this.RequireCurrentPlayerId(); + /// Key prefix for "player X redeemed one of player Y's invites". + /// Row layout is PlayerId = the invitee, + /// Key = externalinvite:redeemed:{kind}:{inviterAccountId}, + /// Value = redemption timestamp. The referrer endpoints below read + /// these rows; the invite-claim path is what writes them. + private const string RedeemedPrefix = "externalinvite:redeemed:"; + + /// Records an out-of-game friend invite aimed at a platform (Steam, + /// Oculus, …) account. + /// + /// The client only ever sends the single form field platformId + /// (RecNet.Runtime/OMHDDLFIPLP.txt:876) — platform keeps its default + /// so the older callers that do send it still bind. [HttpPost("api/externalfriendinvite/v1/createplatforminvite")] public async Task CreatePlatformInvite([FromForm] int platform = 0, [FromForm] string? platformId = null) { - var code = Guid.NewGuid().ToString("N")[..10]; - var key = $"externalinvite:platform:{code}"; + var code = NewInviteCode(); db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = Me, - Key = key, + Key = $"externalinvite:platform:{code}", Value = $"{platform}|{platformId ?? string.Empty}|{DateTime.UtcNow:O}", }); await db.SaveChangesAsync(); var url = $"{Request.Scheme}://{Request.Host}/invite/{code}"; - return Ok(new { InviteCode = code, InviteUrl = url, Platform = platform, PlatformId = platformId ?? string.Empty }); + var result = Success(); + result["InviteCode"] = code; + result["InviteUrl"] = url; + result["Platform"] = platform; + result["PlatformId"] = platformId ?? string.Empty; + return Ok(result); } + /// Records an SMS friend invite. + /// + /// Form fields are phoneNumber, friendCode and + /// senderName (OMHDDLFIPLP.txt:497/505/515) — there is no "message" + /// field, so the friend code that the text is supposed to carry has to be + /// persisted from friendCode or it is lost. [HttpPost("api/externalfriendinvite/v1/sendtextmessageinvite")] - public async Task SendTextMessageInvite([FromForm] string? phoneNumber, [FromForm] string? message) + public async Task SendTextMessageInvite( + [FromForm] string? phoneNumber, + [FromForm] string? friendCode, + [FromForm] string? senderName) { - var code = Guid.NewGuid().ToString("N")[..10]; + var code = NewInviteCode(); db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = Me, Key = $"externalinvite:text:{code}", - Value = $"{phoneNumber ?? string.Empty}|{message ?? string.Empty}|{DateTime.UtcNow:O}", + Value = $"{phoneNumber ?? string.Empty}|{friendCode ?? string.Empty}|{senderName ?? string.Empty}|{DateTime.UtcNow:O}", }); await db.SaveChangesAsync(); - return Ok(new { Success = true, InviteCode = code, PhoneNumber = phoneNumber ?? string.Empty }); + var result = Success(); + result["InviteCode"] = code; + result["PhoneNumber"] = phoneNumber ?? string.Empty; + return Ok(result); } - /// Accounts who joined via this player's platform invites. + /// Ids of the accounts that referred the caller through a platform + /// invite. /// /// The response is a bare array of account ids — the issuing method is /// FGLDKEJLAKB<List<System.Int32>> POJMMCCLNCB() - /// (RecNet.Runtime/OMHDDLFIPLP.txt:923). Returning objects - /// ({InviteCode, Kind, Value, CreatedAt}) made Json.NET throw as soon - /// as any referrer existed, so the referral screen broke for exactly the - /// players who had referred someone. + /// (RecNet.Runtime/OMHDDLFIPLP.txt:923-996, body-less POST). Returning + /// objects ({InviteCode, Kind, Value, CreatedAt}) made the strict + /// Int32 reader throw as soon as any row existed, which faulted + /// SessionManager.CheckForAndHandlePlatformFriendOrTextMessageReferrers + /// (SessionManager.txt:31439) during the login bootstrap. [HttpGet("api/externalfriendinvite/v1/getplatformreferrers")] [HttpPost("api/externalfriendinvite/v1/getplatformreferrers")] public async Task GetPlatformReferrers() - => Ok(await ReferrerIdsAsync("externalinvite:platform:")); + => Ok(await ReferrerIdsAsync("platform")); /// Same bare-id-array contract as - /// , for text-message invites. + /// (OMHDDLFIPLP.txt:998-1071), for + /// text-message invites. [HttpGet("api/externalfriendinvite/v1/gettextmessagereferrers")] [HttpPost("api/externalfriendinvite/v1/gettextmessagereferrers")] public async Task GetTextMessageReferrers() - => Ok(await ReferrerIdsAsync("externalinvite:text:")); + => Ok(await ReferrerIdsAsync("text")); - /// Account ids recorded against the caller's invites of the given - /// kind. The redeeming account id is stored as the second pipe-delimited - /// field once an invite is claimed; unclaimed invites contribute nothing. - private async Task> ReferrerIdsAsync(string keyPrefix) + /// Account ids recorded as having referred the caller with an + /// invite of the given kind. The inviter id is the trailing key segment of + /// the redemption row, so no id can leak in from an invite's own payload + /// (a numeric platformId used to be misread as an account id). + private async Task> ReferrerIdsAsync(string kind) { - var rows = await db.PlayerSettings - .Where(s => s.PlayerId == Me && s.Key.StartsWith(keyPrefix)) + var prefix = RedeemedPrefix + kind + ":"; + var keys = await db.PlayerSettings + .Where(s => s.PlayerId == Me && s.Key.StartsWith(prefix)) .OrderByDescending(s => s.Id) - .Select(s => s.Value) + .Select(s => s.Key) .ToListAsync(); - return rows - .Select(v => v.Split('|')) - .Select(parts => int.TryParse(parts.ElementAtOrDefault(1), out var id) ? id : 0) + return keys + .Select(k => int.TryParse(k[prefix.Length..], out var id) ? id : 0) .Where(id => id > 0) .Distinct() .ToList(); } + private static string NewInviteCode() => Guid.NewGuid().ToString("N")[..10]; + + /// Both write routes await FGLDKEJLAKB<DJMHAFPGLLN> and + /// hand it to BFDGEKKNAPB.ExpandRecNetResult, whose lambda pops the + /// Error string as an error toast whenever Success is false + /// (BFDGEKKNAPB_NestedType___c.txt:61 <ExpandRecNetResult>b__2_0). + /// Utf8Json leaves absent members at their default, so a response without + /// these two keys reads as Success=false and every successful invite raised + /// an empty error toast. Key names are literal from the generated formatter + /// (LMCCNLLHLCJ.txt:191 "Success", :210 "Error"; it also accepts the + /// camelCase aliases at :202/:218, and skips members it does not know, so + /// the per-route extras below are inert). + private static Dictionary Success() => new(StringComparer.Ordinal) + { + ["Success"] = true, + ["Error"] = string.Empty, + }; } diff --git a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs index 00d29f1..6448d04 100644 --- a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs +++ b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs @@ -41,8 +41,6 @@ public class InventionsController( // ── Browse ─────────────────────────────────────────────────────────── [HttpGet("api/inventions/v1/featured")] - [HttpGet("api/inventions/v1/featureddormskins")] - [HttpGet("api/inventions/v1/toptoday")] [HttpGet("api/inventions/v3/popular")] public async Task Popular([FromQuery] int take = 50) { @@ -55,6 +53,82 @@ public class InventionsController( return Ok(rows.Select(ToWire)); } + /// GET api/inventions/v1/toptoday — the client fires + /// this as its own no-arg shelf query + /// (OEGFNFEAAGO_NestedType___c_NestedType___GetTopInventionsToday_b__43_0_d.txt:147, + /// verb 0) and shows it NEXT TO the featured shelf, so aliasing it onto + /// the all-time popular query rendered the same twelve tiles twice. + /// Ranked by cheers received in the last 24h, then backfilled with + /// all-time popular so a quiet day still fills the shelf. + [HttpGet("api/inventions/v1/toptoday")] + public async Task TopToday([FromQuery] int take = 50) + { + take = Math.Clamp(take, 1, 100); + var since = DateTime.UtcNow.AddDays(-1); + var hot = await db.Cheers + .Where(c => c.TargetInventionId > 0 && c.CheeredAt >= since) + .GroupBy(c => c.TargetInventionId) + .Select(g => new { InventionId = g.Key, Score = g.Count() }) + .OrderByDescending(x => x.Score) + .Take(take) + .ToListAsync(); + + var order = hot.Select(h => h.InventionId).ToList(); + var hits = order.Count == 0 + ? new List() + : await db.Inventions + .Where(i => !i.IsDeleted && i.IsPublished && order.Contains(i.Id)) + .ToListAsync(); + + var ranked = order + .Select(id => hits.FirstOrDefault(r => r.Id == id)) + .Where(r => r is not null) + .Select(r => r!) + .ToList(); + + if (ranked.Count < take) + { + var have = ranked.Select(r => r.Id).ToList(); + var filler = await db.Inventions + .Where(i => !i.IsDeleted && i.IsPublished && !have.Contains(i.Id)) + .OrderByDescending(i => i.CheerCount) + .Take(take - ranked.Count) + .ToListAsync(); + ranked.AddRange(filler); + } + return Ok(ranked.Select(ToWire)); + } + + /// GET api/inventions/v1/featureddormskins — separate + /// shelf in the client + /// (OEGFNFEAAGO_NestedType___c_NestedType___GetFeaturedDormSkins_b__45_0_d.txt:147, + /// verb 0, no params) which previously returned generic popular + /// inventions. has no dorm-skin column, so + /// the marker lives in — see + /// . The filter runs in memory because + /// TagsCsv is a packed list no provider can split. + [HttpGet("api/inventions/v1/featureddormskins")] + public async Task FeaturedDormSkins([FromQuery] int take = 50) + { + take = Math.Clamp(take, 1, 100); + var rows = await db.Inventions + .Where(i => !i.IsDeleted && i.IsPublished && i.TagsCsv != "") + .OrderByDescending(i => i.CheerCount) + .Take(500) + .ToListAsync(); + return Ok(rows.Where(i => IsDormSkinTags(i.TagsCsv)).Take(take).Select(ToWire)); + } + + /// Dorm-skin marker. There is no IsDormSkin column on + /// , so a tag decides: any tag that reads + /// "dormskin" / "dorm skin" (case- and space-insensitive). + private static bool IsDormSkinTags(string? tagsCsv) => + !string.IsNullOrEmpty(tagsCsv) + && tagsCsv + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Any(t => t.Replace(" ", string.Empty) + .Equals("dormskin", StringComparison.OrdinalIgnoreCase)); + [HttpGet("api/inventions/v3/saved")] [Authorize] public async Task Saved([FromQuery] int take = 50) @@ -80,10 +154,20 @@ public class InventionsController( [Authorize] public Task Mine() => Saved(100); + /// The 2023 client sends value + skip + take on every store + /// search (OEGFNFEAAGO.txt:10805/10820/10832 — three AFGEDDANEKP pairs + /// on a verb-0 request). Ignoring skip/take pinned every page to the + /// first 50 rows, so scrolling the store re-showed the same tiles. [HttpGet("api/inventions/v1/search")] [HttpGet("api/inventions/v2/search")] - public async Task Search([FromQuery] string value = "") + public async Task Search( + [FromQuery] string value = "", + [FromQuery] int skip = 0, + [FromQuery] int take = 100) { + skip = Math.Max(0, skip); + take = Math.Clamp(take, 1, 200); + if (string.IsNullOrWhiteSpace(value)) return Ok(Array.Empty()); @@ -112,7 +196,8 @@ public class InventionsController( .Where(i => !i.IsDeleted && i.IsPublished && creators.Contains(i.CreatorPlayerId)) .OrderByDescending(i => i.CheerCount) - .Take(50) + .Skip(skip) + .Take(take) .ToListAsync(); return Ok(byCreator.Select(ToWire)); } @@ -123,36 +208,135 @@ public class InventionsController( && (i.Name.ToLower().Contains(v) || i.TagsCsv.ToLower().Contains(v))) .OrderByDescending(i => i.CheerCount) - .Take(50) + .Skip(skip) + .Take(take) .ToListAsync(); return Ok(rows.Select(ToWire)); } - public sealed record InventionBatchRequest(List InventionIds); + // ── Id-list endpoints (runtime GET/POST) ───────────────────────────── + // + // The 2023 client picks the verb at RUNTIME for every id-list route: + // ALHIJCJOLCB.JIECAFGCODK (IsilDump/RecNet.Runtime/ALHIJCJOLCB.txt:3198) + // is `count < limit ? GET : POST` with limit=100. On a non-GET the + // key/value pairs move out of the query string and into an + // HTTPUrlEncodedForm body — BNDIAONDFFF.txt:2971-3010 branches on the + // verb (GET → "?a=b&…", otherwise → HTTPUrlEncodedForm). So the POST + // actions must NOT declare a complex parameter, or [ApiController] + // infers [FromBody] and answers 415 before the handler runs; we read + // query + form by hand instead. + // + // Field names are literal: + // "id" → v2/batch (OEGFNFEAAGO_NestedType_LANMKGKDNDC.txt:228) + // "id" → fromcreators (…CDILMPCKMEO…GetInventionsByCreators_b__0_d.txt:330) + // "ids" → dormskinsfromids (OEGFNFEAAGO.txt:5913) - [HttpPost("api/inventions/v1/batch")] - [HttpPost("api/inventions/v2/batch")] - [HttpPost("api/inventions/v1/dormskinsfromids")] - public async Task Batch([FromBody] InventionBatchRequest req) + /// Body shape for non-2023 callers, which still send a JSON + /// {"InventionIds":[…]} envelope rather than repeated fields. + private sealed class InventionIdsBody { - return await BatchIdsAsync(req?.InventionIds ?? []); + public List? InventionIds { get; set; } + public List? Ids { get; set; } + public List? Id { get; set; } } + private static readonly System.Text.Json.JsonSerializerOptions IdsJson = + new() { PropertyNameCaseInsensitive = true }; + + /// Gather ids from the query string, the url-encoded form and + /// (as a fallback) a JSON id envelope. See the block comment above for + /// why all three have to be accepted on one route. + private async Task> CollectIdsAsync(params string[] keys) + { + var ids = new List(); + + void Take(string? raw) + { + if (string.IsNullOrEmpty(raw)) return; + foreach (var part in raw.Split( + ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (long.TryParse(part, out var id) && id > 0) ids.Add(id); + } + } + + var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null; + foreach (var key in keys) + { + foreach (var v in Request.Query[key]) Take(v); + if (form is not null) + foreach (var v in form[key]) Take(v); + } + + if (ids.Count == 0 && form is null + && (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) ?? false)) + { + try + { + var body = await System.Text.Json.JsonSerializer + .DeserializeAsync(Request.Body, IdsJson); + foreach (var list in new[] { body?.InventionIds, body?.Ids, body?.Id }) + { + if (list is not null) ids.AddRange(list.Where(id => id > 0)); + } + } + catch (System.Text.Json.JsonException) + { + // Not an id envelope — treat as "no ids" rather than 400. + } + } + + return ids.Distinct().Take(200).ToList(); + } + + /// Read one scalar field from the query string, falling back to + /// the url-encoded form body (POST variants — see the block comment + /// above). + private async Task ReadFieldAsync(string key) + { + var fromQuery = Request.Query[key].FirstOrDefault(); + if (!string.IsNullOrEmpty(fromQuery)) return fromQuery; + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + var fromForm = form[key].FirstOrDefault(); + if (!string.IsNullOrEmpty(fromForm)) return fromForm; + } + return null; + } + + private async Task ReadIntFieldAsync(string key) + => int.TryParse(await ReadFieldAsync(key), out var v) ? v : null; + + private async Task ReadLongFieldAsync(string key) + => long.TryParse(await ReadFieldAsync(key), out var v) ? v : null; + [HttpGet("api/inventions/v1/batch")] [HttpGet("api/inventions/v2/batch")] - [HttpGet("api/inventions/v1/dormskinsfromids")] - public async Task BatchFromQuery() - { - var ids = Request.Query - .SelectMany(q => q.Value) - .SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - .Select(v => long.TryParse(v, out var id) ? id : 0L) - .Where(id => id > 0) - .Distinct() - .Take(200) - .ToList(); + [HttpPost("api/inventions/v1/batch")] + [HttpPost("api/inventions/v2/batch")] + public async Task Batch() + => await BatchIdsAsync(await CollectIdsAsync("id", "ids", "InventionIds")); - return await BatchIdsAsync(ids); + /// api/inventions/v1/dormskinsfromids — the client + /// deserialises a BARE List<Int64> here, not a list of + /// invention objects: the continuation at OEGFNFEAAGO.txt:5941 is typed + /// FGLDKEJLAKB<List`1<Int64>> and is then projected by a + /// Func<List<Int64>, List<Int64>> (:5966). The reply is the + /// SUBSET of the posted ids that are dorm skins; aliasing this onto the + /// generic batch handler fed objects to a number reader and killed + /// dorm-skin filtering outright. + [HttpGet("api/inventions/v1/dormskinsfromids")] + [HttpPost("api/inventions/v1/dormskinsfromids")] + public async Task DormSkinsFromIds() + { + var ids = await CollectIdsAsync("ids", "id", "InventionIds"); + if (ids.Count == 0) return Ok(Array.Empty()); + var rows = await db.Inventions + .Where(i => !i.IsDeleted && ids.Contains(i.Id)) + .Select(i => new { i.Id, i.TagsCsv }) + .ToListAsync(); + return Ok(rows.Where(r => IsDormSkinTags(r.TagsCsv)).Select(r => r.Id).ToList()); } private async Task BatchIdsAsync(IReadOnlyCollection requestedIds) @@ -167,22 +351,24 @@ public class InventionsController( } [HttpGet("api/inventions/v1/fromcreators")] + [HttpPost("api/inventions/v1/fromcreators")] public async Task FromCreators() { - var creatorIds = Request.Query - .SelectMany(q => q.Value) - .SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - .Select(v => long.TryParse(v, out var id) ? id : 0L) - .Where(id => id > 0) - .Distinct() - .Take(200) - .ToList(); - + // Only the literal "id" key carries creator ids. The previous + // SelectMany-over-every-query-field also swallowed the client's + // "skip"/"take" pairs, so take=100 quietly added player 100's + // inventions to the shelf. + var creatorIds = await CollectIdsAsync("id"); if (creatorIds.Count == 0) return Ok(Array.Empty()); + + var skip = Math.Max(0, await ReadIntFieldAsync("skip") ?? 0); + var take = Math.Clamp(await ReadIntFieldAsync("take") ?? 100, 1, 200); + var rows = await db.Inventions .Where(i => !i.IsDeleted && i.IsPublished && creatorIds.Contains(i.CreatorPlayerId)) .OrderByDescending(i => i.UpdatedAt) - .Take(200) + .Skip(skip) + .Take(take) .ToListAsync(); return Ok(rows.Select(ToWire)); } diff --git a/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs b/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs index 73ae682..b68edd1 100644 --- a/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs +++ b/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.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; using System.Text.Json; @@ -59,10 +60,11 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con [HttpGet("events/{eventId:long}")] public async Task EventInstances(long eventId) { - var eventKey = eventId.ToString(System.Globalization.CultureInfo.InvariantCulture); + var eventKey = eventId.ToString(CultureInfo.InvariantCulture); + // NOT filtered by PlayerId: Instances is the whole event's placement set (the hunt map), + // CollectionRecords is only what the caller has picked up. var rows = await db.Keepsakes - .Where(k => k.PlayerId == Me - && k.Category == "event" + .Where(k => k.Category == "event" && (k.EventKey == eventKey || k.EventKey == $"event:{eventKey}" || k.EventKey == $"event/{eventKey}" @@ -71,12 +73,18 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con .OrderByDescending(k => k.EarnedAt) .ToListAsync(); + // RecNet.KeepsakeProgressionEventInstancesDTO has exactly two members, both + // List (= {Int64, List}) - dump.cs:1234798-1234810, + // ctor(List, List) in + // IsilDump/RecNet.Runtime/RecNet/KeepsakeProgressionEventInstancesDTO.txt:14. + // Emitting the flat keepsake rows here left RoomId=0 / KeepsakeInstanceIds=null per entry. + // 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. return Ok(new { - KeepsakeProgressionEventId = eventId, - Instances = rows.Select(ToWire), - CollectionRecords = Array.Empty(), - KeepsakeProgressionEventIds = new[] { eventId }, + Instances = GroupInstanceIdsByRoom(rows), + CollectionRecords = GroupInstanceIdsByRoom(rows.Where(r => r.PlayerId == Me)), }); } @@ -118,6 +126,31 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con public async Task Create() { var req = await ReadRequestAsync(); + + // 2023 client: FGLDKEJLAKB OPPHCOJHEJI(HMECHOKOCBB category) + // (NCCLEJPIABA.txt:1146) POSTs a RecNet.AddKeepsakeInstanceRequest + // {Int64 RoomId, Int64? SubRoomId, Int32 KeepsakeCategory} - dump.cs:1234509-1234525, + // field offsets 0x10/0x18/0x28 in AddKeepsakeInstanceRequest.txt - and deserialises the + // response as a BARE Guid, not an object. The request is refused client-side for offline + // rooms ("Cannot add keepsakes in offline rooms", NCCLEJPIABA.txt:1533), so RoomId is + // always a real room here; its presence is what selects this path over the legacy DTO. + if (req.RoomId is > 0) + { + var roomId = req.RoomId.Value; + if (!await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId)) return NotFound(); + + var instanceId = Guid.NewGuid(); + db.Keepsakes.Add(new KeepsakeEntity + { + PlayerId = Me, + Category = "room", + EventKey = BuildRoomInstanceKey(roomId, req.SubRoomId, req.KeepsakeCategory ?? 0, instanceId), + EarnedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + return Ok(instanceId); + } + var key = Trim(req.EventKey, 128); if (key.Length == 0) key = $"manual:{Guid.NewGuid():N}"; var category = Trim(req.Category, 64); @@ -167,6 +200,9 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con var form = await Request.ReadFormAsync(); return new KeepsakeRequest { + RoomId = long.TryParse(form["RoomId"].FirstOrDefault() ?? form["roomId"].FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var formRoom) ? formRoom : null, + SubRoomId = long.TryParse(form["SubRoomId"].FirstOrDefault() ?? form["subRoomId"].FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var formSubRoom) ? formSubRoom : null, + KeepsakeCategory = int.TryParse(form["KeepsakeCategory"].FirstOrDefault() ?? form["keepsakeCategory"].FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var formCategory) ? formCategory : null, Category = form["category"].FirstOrDefault() ?? form["Category"].FirstOrDefault(), EventKey = form["eventKey"].FirstOrDefault() ?? form["EventKey"].FirstOrDefault(), Title = form["title"].FirstOrDefault() ?? form["Title"].FirstOrDefault(), @@ -176,12 +212,28 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con }; } + // The 2023 client sends a RAW JSON body, not a form: NCCLEJPIABA.txt:1458-1465 serialises the + // whole AddKeepsakeInstanceRequest in one call and hands the string to BNDIAONDFFF's body + // setter. Read it as a document rather than binding a DTO so the numeric KeepsakeCategory and + // the legacy string Category can share one wire slot, and so key-name aliases are cheap. try { - return await JsonSerializer.DeserializeAsync( - Request.Body, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) - ?? new KeepsakeRequest(); + using var doc = await JsonDocument.ParseAsync(Request.Body); + var root = doc.RootElement; + return new KeepsakeRequest + { + RoomId = ReadInt64(root, "RoomId"), + SubRoomId = ReadInt64(root, "SubRoomId"), + // "Category" is listed as an alias only for the numeric form; when it carries the + // legacy string ("event"/"room") ReadInt64 declines it and ReadString picks it up. + KeepsakeCategory = (int?)ReadInt64(root, "KeepsakeCategory", "KeepsakeCategoryConfigId", "Category"), + Category = ReadString(root, "Category"), + EventKey = ReadString(root, "EventKey"), + Title = ReadString(root, "Title"), + Description = ReadString(root, "Description"), + ImageName = ReadString(root, "ImageName"), + EarnedAt = DateTime.TryParse(ReadString(root, "EarnedAt"), out var earnedAt) ? earnedAt : null, + }; } catch (JsonException) { @@ -189,6 +241,39 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con } } + private static bool TryFindMember(JsonElement root, string[] names, out JsonElement value) + { + if (root.ValueKind == JsonValueKind.Object) + { + foreach (var prop in root.EnumerateObject()) + { + foreach (var name in names) + { + if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase)) + { + value = prop.Value; + return true; + } + } + } + } + + value = default; + return false; + } + + private static long? ReadInt64(JsonElement root, params string[] names) + { + if (!TryFindMember(root, names, out var el)) return null; + if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var number)) return number; + if (el.ValueKind == JsonValueKind.String + && long.TryParse(el.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) return parsed; + return null; + } + + private static string? ReadString(JsonElement root, params string[] names) + => TryFindMember(root, names, out var el) && el.ValueKind == JsonValueKind.String ? el.GetString() : null; + private object ToWire(KeepsakeEntity row) => new { row.Id, @@ -207,16 +292,67 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con var placedByAccountId = row.PlayerId is > 0 and <= int.MaxValue ? (int)row.PlayerId : 0; + var parsed = TryParseInstanceKey(row.EventKey, out var keyRoomId, out var subRoomId, out var categoryConfigId, out var instanceId); return new { - KeepsakeInstanceId = StableKeepsakeInstanceId(row.Id).ToString("D"), - KeepsakeCategoryConfigId = CategoryId(row.Category), + KeepsakeInstanceId = (parsed ? instanceId : StableKeepsakeInstanceId(row.Id)).ToString("D"), + // KeepsakeCategory is the visual config id (enum PIHCLHIKEPH: Explore=0, GreenPowerCore=1, + // Present=2, PurplePowerCore=3, ... - dump.cs:1199524), NOT our account/event/room bucket, + // so replay what the client sent; CategoryId() is only the fallback for legacy rows. + KeepsakeCategoryConfigId = parsed ? categoryConfigId : CategoryId(row.Category), PlacedByAccountId = placedByAccountId, - RoomId = roomId, - SubRoomId = (long?)null, + RoomId = parsed ? keyRoomId : roomId, + SubRoomId = subRoomId, }; } + private static List GroupInstanceIdsByRoom(IEnumerable rows) => rows + .Select(row => TryParseInstanceKey(row.EventKey, out var roomId, out _, out _, out var instanceId) + ? (RoomId: roomId, InstanceId: instanceId) + : (RoomId: 0L, InstanceId: StableKeepsakeInstanceId(row.Id))) + .GroupBy(x => x.RoomId) + .Select(group => (object)new + { + RoomId = group.Key, + KeepsakeInstanceIds = group.Select(x => x.InstanceId).Distinct().Select(id => id.ToString("D")).ToList(), + }) + .ToList(); + + // KeepsakeEntity has no room/sub-room/instance-id columns, so a placed instance is encoded into + // the existing 128-char EventKey. Layouts: + // room:{roomId}:{subRoomId|-}:{categoryConfigId}:{instanceGuid:N} (client placements) + // event:{eventId}:{roomId}:{subRoomId|-}:{categoryConfigId}:{instanceGuid:N} + // Both stay inside the LIKE prefixes the room/event queries above already use. Legacy short keys + // ("room:{roomId}", "event:{eventId}", "first-login") fail to parse and fall back to the + // MD5-derived instance id, which is what those rows were already reported with. + private static string BuildRoomInstanceKey(long roomId, long? subRoomId, int categoryConfigId, Guid instanceId) + { + var room = roomId.ToString(CultureInfo.InvariantCulture); + var subRoom = subRoomId?.ToString(CultureInfo.InvariantCulture) ?? "-"; + var category = categoryConfigId.ToString(CultureInfo.InvariantCulture); + return $"room:{room}:{subRoom}:{category}:{instanceId:N}"; + } + + private static bool TryParseInstanceKey(string? eventKey, out long roomId, out long? subRoomId, out int categoryConfigId, out Guid instanceId) + { + roomId = 0; + subRoomId = null; + categoryConfigId = 0; + instanceId = Guid.Empty; + + var parts = (eventKey ?? string.Empty).Split(':'); + int i; + if (parts.Length == 5 && string.Equals(parts[0], "room", StringComparison.OrdinalIgnoreCase)) i = 1; + else if (parts.Length == 6 && string.Equals(parts[0], "event", StringComparison.OrdinalIgnoreCase)) i = 2; + else return false; + + if (!Guid.TryParseExact(parts[i + 3], "N", out instanceId)) return false; + if (!long.TryParse(parts[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out roomId)) return false; + if (long.TryParse(parts[i + 1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var sub)) subRoomId = sub; + if (!int.TryParse(parts[i + 2], NumberStyles.Integer, CultureInfo.InvariantCulture, out categoryConfigId)) categoryConfigId = 0; + return true; + } + private static int CategoryId(string? category) => string.Equals(category, "event", StringComparison.OrdinalIgnoreCase) ? 1 : string.Equals(category, "room", StringComparison.OrdinalIgnoreCase) ? 2 : @@ -236,6 +372,11 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con public sealed class KeepsakeRequest { + // RecNet.AddKeepsakeInstanceRequest (2023 client). + public long? RoomId { get; set; } + public long? SubRoomId { get; set; } + public int? KeepsakeCategory { get; set; } + public string? Category { get; set; } public string? EventKey { get; set; } public string? Title { get; set; } diff --git a/DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs b/DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs index 2a41142..1e5648e 100644 --- a/DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs +++ b/DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs @@ -37,20 +37,30 @@ public class PlaylistsController( // ── Create / delete ────────────────────────────────────────────── - public sealed class CreatePlaylistRequest - { - public string? Name { get; set; } - public string? Description { get; set; } - public string? ImageName { get; set; } - public string? Tags { get; set; } - } - + /// POST /playlists — create. The 2023-03-21 client posts a + /// form with a single field name (verb 2 at + /// NLDBPDCNNCF.txt:11139-11140, field literal in + /// NLDBPDCNNCF_NestedType_NEECJOENHPI.txt — its only + /// Move rdx, "…" is "name"). The old non-optional + /// [FromBody] parameter made [ApiController] reject the + /// form content type with 415 before the action ran, so creating a + /// playlist from the watch was impossible. Fields are read from form, + /// query or JSON body so JSON callers (admin UI) keep working. [HttpPost("/playlists")] [Authorize] - public async Task Create([FromBody] CreatePlaylistRequest req) + public async Task Create() { - if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest(new { error = "missing_name" }); - var created = await playlists.CreateAsync(Me, req.Name, req.Description, req.ImageName, req.Tags); + var name = await ReadFieldAsync("name", "Name"); + if (string.IsNullOrWhiteSpace(name)) return BadRequest(new { error = "missing_name" }); + + var description = await ReadFieldAsync("description", "Description"); + var imageName = await ReadFieldAsync("imageName", "ImageName"); + var tags = await ReadFieldsAsync("tag", "Tag", "tags", "Tags"); + var tagsCsv = tags.Count > 0 + ? string.Join(',', tags.Distinct(StringComparer.OrdinalIgnoreCase)) + : null; + + var created = await playlists.CreateAsync(Me, name.Trim(), description, imageName, tagsCsv); return await BuildDetailsResponseAsync(created.Id); } @@ -68,11 +78,6 @@ public class PlaylistsController( // ── Per-field mutations (each returns BMFAGMFKODA) ─────────────── - public sealed class StringFieldRequest { public string? Value { get; set; } } - public sealed class IntFieldRequest { public int? Value { get; set; } } - public sealed class BoolFieldRequest { public bool? Value { get; set; } } - public sealed class TagsRequest { public string? Tags { get; set; } public List? TagsList { get; set; } } - // The 2023-03-21 client sends these as form-urlencoded PUTs. A // non-optional [FromBody] parameter made [ApiController] demand JSON and // reject every one with 415 before the action ran, so renaming, re-imaging, @@ -248,6 +253,32 @@ public class PlaylistsController( return found; } + /// KMKPEOGJDFK union entry with the per-playlist settings this + /// controller persists spliced back in. + /// still emits + /// hardcoded defaults for exactly the fields the client mutates here + /// (Accessibility=1, WarningMask=0, + /// SupportsLevelVoting=false, Supports*=true), so a creator + /// who published or restricted a playlist got the default echoed straight + /// back and the watch's toggle snapped to its old position. Key names are + /// the client's own setter fields — + /// NLDBPDCNNCF_NestedType_{BHAHEJIEIMF,PJFBAKCHIHI,GNCDFBNCADM,AIDDFKACPLN}.txt + /// — matched to the PascalCase read keys at + /// MKAMHOIHOJK.txt:516-612. + private static IDictionary UnionEntry(PlaylistEntity p) + { + var entry = RoomsController.BuildPlaylistUnionEntry(p); + entry["Accessibility"] = p.Accessibility; + entry["WarningMask"] = p.WarningMask; + entry["CustomWarning"] = p.CustomWarning; + entry["SupportsLevelVoting"] = p.SupportsLevelVoting; + entry["SupportsJuniors"] = p.SupportsJuniors; + entry["SupportsScreens"] = p.SupportsScreens; + entry["SupportsTeleportVR"] = p.SupportsTeleportVR; + entry["SupportsWalkVR"] = p.SupportsWalkVR; + return entry; + } + private async Task ApplyMutation(long playlistId, Action mutator) { try @@ -329,7 +360,7 @@ public class PlaylistsController( public async Task CreatedByMe() { var rows = await playlists.CreatedByAsync(Me); - return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList()); + return Ok(rows.Select(UnionEntry).ToList()); } [HttpGet("/playlists/cheeredby/me")] @@ -337,7 +368,7 @@ public class PlaylistsController( public async Task CheeredByMe() { var rows = await playlists.CheeredByAsync(Me); - return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList()); + return Ok(rows.Select(UnionEntry).ToList()); } [HttpGet("/playlists/favoritedby/me")] @@ -345,36 +376,122 @@ public class PlaylistsController( public async Task FavoritedByMe() { var rows = await playlists.FavoritedByAsync(Me); - return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList()); + return Ok(rows.Select(UnionEntry).ToList()); } - /// GET /playlists/visitedby/me — we don't track - /// per-player playlist visits yet (visits are tracked at the Room - /// level via RoomVisitEntity). Return empty so the watch's - /// playlists tab renders cleanly; populated once we add a - /// PlaylistVisitEntity or derive from member-room visits. + /// GET /playlists/visitedby/me — bare + /// List<CNIPGJIIFJF> (return type at + /// NLDBPDCNNCF.txt:4312, URL at :4355). + /// + /// We have no PlaylistVisitEntity, but a playlist is nothing but an + /// ordered set of rooms, so "visited" is derivable exactly: the player + /// has visited a playlist iff they've visited any of its member rooms. + /// Ordered by the most recent member-room visit so the tab reads as a + /// history list. Previously hardcoded to an empty array, which left the + /// watch's recently-visited playlists tab permanently blank. + /// + /// Aggregation happens in memory rather than in SQL: a single player's + /// RoomVisits row set is small, and this keeps the query provider-neutral + /// (SQLite in dev, Postgres in prod). [HttpGet("/playlists/visitedby/me")] [Authorize] - public IActionResult VisitedByMe() => Ok(Array.Empty()); + public async Task VisitedByMe() + { + var me = Me; + + var visits = await db.RoomVisits + .Where(v => v.PlayerId == me) + .Select(v => new { v.RoomId, v.LastVisitAt }) + .ToListAsync(); + if (visits.Count == 0) return Ok(Array.Empty()); + + var visitedRoomIds = visits.Select(v => v.RoomId).Distinct().ToList(); + var links = await db.PlaylistRooms + .Where(l => visitedRoomIds.Contains(l.RoomId)) + .Select(l => new { l.PlaylistId, l.RoomId }) + .ToListAsync(); + if (links.Count == 0) return Ok(Array.Empty()); + + var lastVisitByRoom = visits + .GroupBy(v => v.RoomId) + .ToDictionary(g => g.Key, g => g.Max(v => v.LastVisitAt)); + + var orderedIds = links + .GroupBy(l => l.PlaylistId) + .Select(g => new { PlaylistId = g.Key, LastVisitAt = g.Max(l => lastVisitByRoom[l.RoomId]) }) + .OrderByDescending(x => x.LastVisitAt) + .Select(x => x.PlaylistId) + .ToList(); + + // BulkAsync preserves caller order, so the recency sort survives. + var rows = await playlists.BulkAsync(orderedIds); + return Ok(rows.Select(UnionEntry).ToList()); + } // ── Bulk lookup ────────────────────────────────────────────────── - /// GET /playlists/bulk?id=A&id=B — bulk-by-id - /// lookup for the watch's playlist cache warmup. Bare list of - /// KMKPEOGJDFK entries, one per resolved id (missing ids - /// silently dropped). + /// + /// playlists/bulk — bulk hydration for the watch's playlist cache. + /// The client has TWO overloads on this one URL, and both pick their verb + /// at runtime: + /// + /// by id — NLDBPDCNNCF.txt:3408-3597; repeated field + /// id (NLDBPDCNNCF_NestedType_IOAECIJDKDD.txt:64); + /// Move rsi, 2 then cmovl esi,ecx against 100 at + /// :3579-3581 — GET below 100 ids, POST at 100+. + /// by name — NLDBPDCNNCF.txt:3624-3813; repeated field + /// name (NLDBPDCNNCF_NestedType_MOBALIODDDF.txt:64); same + /// cmov against 64 at :3794-3797 — GET below 64 names, POST at + /// 64+. + /// + /// GET-only meant 405 on every large cache warmup, and query-id-only meant + /// the by-name overload silently resolved nothing. Both verbs and both key + /// sets are accepted here; the response is a bare + /// List<CNIPGJIIFJF> either way (return type at + /// NLDBPDCNNCF.txt:3408), missing entries silently dropped. + /// [HttpGet("/playlists/bulk")] + [HttpPost("/playlists/bulk")] public async Task Bulk() { - var ids = Request.Query["id"].Concat(Request.Query["ids"]) - .SelectMany(v => (v ?? string.Empty).Split(',', + var ids = (await ReadFieldsAsync("id", "ids", "Id", "Ids")) + .SelectMany(v => v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .Select(s => long.TryParse(s, out var n) ? n : 0) .Where(n => n != 0) .ToList(); - if (ids.Count == 0) return Ok(Array.Empty()); - var rows = await playlists.BulkAsync(ids); - return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList()); + + // Names are NOT comma-split — the client repeats the field once per + // name, and playlist names may legally contain commas. + var names = (await ReadFieldsAsync("name", "names", "Name", "Names")) + .Select(v => v.Trim()) + .Where(v => v.Length > 0) + .ToList(); + + if (ids.Count == 0 && names.Count == 0) return Ok(Array.Empty()); + + var results = new List(); + var seen = new HashSet(); + + if (ids.Count > 0) + foreach (var p in await playlists.BulkAsync(ids)) + if (seen.Add(p.Id)) results.Add(p); + + if (names.Count > 0) + { + var lowered = names.Select(s => s.ToLowerInvariant()).Distinct().ToList(); + var rows = await db.Playlists + .Where(e => lowered.Contains(e.Name.ToLower())) + .ToListAsync(); + + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var r in rows) byName.TryAdd(r.Name, r); + + foreach (var wanted in names) + if (byName.TryGetValue(wanted, out var hit) && seen.Add(hit.Id)) results.Add(hit); + } + + return Ok(results.Select(UnionEntry).ToList()); } // ── Detail-response builder (BMFAGMFKODA shape) ───────────────── @@ -420,7 +537,7 @@ public class PlaylistsController( .Select(t => (object)new { Type = 0, Tag = t }) .ToList(); - var result = RoomsController.BuildPlaylistUnionEntry(p); + var result = UnionEntry(p); result["Rooms"] = roomsWire; result["Tags"] = tagsWire; return Ok(result); diff --git a/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs b/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs index f4fa153..f367967 100644 --- a/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs +++ b/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs @@ -31,8 +31,13 @@ namespace DorkNet.Server.Controllers.API.RoomConsumables; /// Purchase requests carry the client-generated inventory concurrency /// token: { ConcurrencyCodes: { CurrentConcurrencyCode, /// NewConcurrencyCode }, ExpectedPriceAndCurrency: { Price, CurrencyId } }. -/// The purchase response has no inventory item — the client applies -/// NewConcurrencyCode locally, so the server must store it verbatim. +/// The server must store NewConcurrencyCode verbatim, because the client +/// adopts the code it sent regardless of what comes back — anything else +/// makes the next consume fail with ConcurrencyCodeMismatch. +/// +/// Both purchase responses derive from the same base DTO (OKHNOPLBOFP), +/// whose sole field OperationResult is the object +/// { Status, InventoryItem } — see TokenResponse below. /// [ApiController] public class RoomConsumablesController(DorkNetDbContext db, LevelService level) : ControllerBase @@ -54,7 +59,12 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level) public const int DescriptionTooLong = 27; public const int ConcurrencyCodeMismatch = 32; public const int PlayerDoesNotOwnConsumable = 33; + public const int PurchaseFailed = 35; public const int RoomNotFound = 36; + public const int RequestedPriceDoesNotMatch = 38; + public const int RequestedCurrencyDoesNotMatch = 39; + public const int ConsumableCannotBePurchasedWithRoomCurrency = 40; + public const int ConsumableCannotBePurchasedWithTokens = 41; } /// Token-purchase OperationResult (client enum CABBDKFODEC). @@ -223,10 +233,10 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level) return StatusBody(Status.Success); } - /// POST api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens + /// PUT api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens /// — buy one stack unit with tokens. Response is - /// { OperationResult, BalanceUpdateResult, TokenBalanceResponse: - /// { CurrencyType, Balance, Platform } }. + /// { OperationResult: { Status, InventoryItem }, BalanceUpdateResult, + /// TokenBalanceResponse: { CurrencyType, Balance, Platform } }. // The 2023-03-21 client PUTs this (verb literal 3 = PUT, at the route // construction in RecNet.Runtime/DCFKEFHJAGC.txt instrs 097-108), so a // POST-only mapping returned 405 and token-priced consumables were @@ -238,19 +248,25 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level) { var me = this.RequireCurrentPlayerId(); var item = await FindAsync(publicId); - if (item is null) return NotFound(); + // Failures answer 200 + the DTO rather than a bare status code: the + // client only ever parses this route's body through HJOKFJKKEJM, so a + // 404/400 surfaces as the opaque "Failed to purchase room consumable". + if (item is null) return Ok(TokenResponse(null, Status.RoomConsumableNotFound, 0)); var body = await ReadBodyAsync(); - var (price, currencyId, hasPrice) = ReadPriceAndCurrency(body, "ExpectedPriceAndCurrency"); + var (price, _, hasPrice) = ReadPriceAndCurrency(body, "ExpectedPriceAndCurrency"); var newCode = ReadConcurrency(body).NewCode; var balance = await level.GetBalanceAsync(me, TokenCurrencyType); // A token purchase is only valid for a token-priced item at the // price the client showed the buyer. - if (item.CurrencyId is not null || !hasPrice || price != item.Price) - return Ok(TokenResponse(TokenOp.RequestedPriceDoesNotMatch, balance)); + if (item.CurrencyId is not null) + return Ok(TokenResponse(null, Status.ConsumableCannotBePurchasedWithTokens, balance)); + if (!hasPrice || price != item.Price) + return Ok(TokenResponse( + TokenOp.RequestedPriceDoesNotMatch, Status.RequestedPriceDoesNotMatch, balance)); if (balance < item.Price) - return Ok(TokenResponse(TokenOp.NotEnoughCredit, balance)); + return Ok(TokenResponse(TokenOp.NotEnoughCredit, Status.PurchaseFailed, balance)); var newBalance = item.Price == 0 ? balance @@ -259,15 +275,16 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level) await level.GrantCurrencyAsync(item.CreatorPlayerId, TokenCurrencyType, item.Price, $"sellRoomConsumable:{item.PublicId}"); - await AddToInventoryAsync(me, item, newCode); + var own = await AddToInventoryAsync(me, item, newCode); await db.SaveChangesAsync(); - return Ok(TokenResponse(TokenOp.OK, newBalance)); + return Ok(TokenResponse(TokenOp.OK, Status.Success, newBalance, ToInventoryWire(own, item))); } - /// POST api/roomconsumables/v1/roomconsumable/{id}/purchase/currency - /// — buy with a room currency. Response is { OperationResult, - /// BalanceUpdateResult, CurrencyBalanceResponse: { AccountId, - /// CurrencyId, Balance, ModifiedAt } }. + /// PUT api/roomconsumables/v1/roomconsumable/{id}/purchase/currency + /// — buy with a room currency. Response is + /// { OperationResult: { Status, InventoryItem }, BalanceUpdateResult, + /// CurrencyBalanceResponse: { AccountId, CurrencyId, Balance, + /// ModifiedAt } }. // PUT per the client (verb literal 3 at DCFKEFHJAGC.txt instrs 112-123); // POST-only returned 405 and currency-priced consumables were unbuyable. [HttpPut("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/currency")] @@ -277,37 +294,51 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level) { var me = this.RequireCurrentPlayerId(); var item = await FindAsync(publicId); - if (item is null) return NotFound(); - if (item.CurrencyId is not Guid currencyPublicId) return BadRequest("not_currency_priced"); + // As with the token route, every rejection is a 200 + DTO carrying a + // real RoomConsumableStatus; the old BadRequest("price_mismatch") + // bodies were not DTOs at all, so the client could only report the + // generic purchase failure. + if (item is null) + return Ok(CurrencyResponse(null, Status.RoomConsumableNotFound, me, Guid.Empty, 0)); + if (item.CurrencyId is not Guid currencyPublicId) + return Ok(CurrencyResponse( + null, Status.ConsumableCannotBePurchasedWithRoomCurrency, me, Guid.Empty, 0)); var currency = await db.RoomCurrencies .FirstOrDefaultAsync(c => c.PublicId == currencyPublicId && !c.IsDeleted); - if (currency is null) return BadRequest("currency_not_found"); + if (currency is null) + return Ok(CurrencyResponse(null, Status.CurrencyNotFound, me, currencyPublicId, 0)); var body = await ReadBodyAsync(); var (price, requestedCurrency, hasPrice) = ReadPriceAndCurrency(body, "ExpectedPriceAndCurrency"); var newCode = ReadConcurrency(body).NewCode; - if (!hasPrice || price != item.Price - || (requestedCurrency is Guid reqCur && reqCur != currencyPublicId)) - return BadRequest("price_mismatch"); var balanceRow = await db.RoomCurrencyBalances .FirstOrDefaultAsync(b => b.PlayerId == me && b.RoomCurrencyId == currency.Id); var balance = balanceRow?.Balance ?? 0; + + if (!hasPrice || price != item.Price) + return Ok(CurrencyResponse( + null, Status.RequestedPriceDoesNotMatch, me, currencyPublicId, balance)); + if (requestedCurrency is Guid reqCur && reqCur != currencyPublicId) + return Ok(CurrencyResponse( + null, Status.RequestedCurrencyDoesNotMatch, me, currencyPublicId, balance)); if (balance < item.Price) - return Ok(CurrencyResponse(CurrencyOp.NotEnoughCredit, me, currencyPublicId, balance)); + return Ok(CurrencyResponse( + CurrencyOp.NotEnoughCredit, Status.PurchaseFailed, me, currencyPublicId, balance)); if (item.Price > 0) { balanceRow!.Balance -= item.Price; balanceRow.UpdatedAt = DateTime.UtcNow; } - await AddToInventoryAsync(me, item, newCode); + var own = await AddToInventoryAsync(me, item, newCode); await db.SaveChangesAsync(); - return Ok(CurrencyResponse(CurrencyOp.Success, me, currencyPublicId, balanceRow?.Balance ?? 0)); + return Ok(CurrencyResponse(CurrencyOp.Success, Status.Success, me, currencyPublicId, + balanceRow?.Balance ?? 0, ToInventoryWire(own, item))); } - /// POST api/roomconsumables/v1/roomConsumable/{id}/consume + /// PUT api/roomconsumables/v1/roomConsumable/{id}/consume /// — spend one unit. Body is { CurrentConcurrencyCode, /// NewConcurrencyCode }; response { Status, InventoryItem }. /// On a code mismatch the current row is returned so the client can @@ -347,7 +378,10 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level) private async Task FindAsync(Guid publicId) => await db.RoomConsumables.FirstOrDefaultAsync(c => c.PublicId == publicId && !c.IsDeleted); - private async Task AddToInventoryAsync(long playerId, RoomConsumableEntity item, Guid? newCode) + /// Grant one unit and hand back the row, so the purchase + /// responses can embed it as OperationResult.InventoryItem. + private async Task AddToInventoryAsync( + long playerId, RoomConsumableEntity item, Guid? newCode) { var own = await db.RoomConsumableOwnership .FirstOrDefaultAsync(o => o.PlayerId == playerId && o.RoomConsumableId == item.Id); @@ -357,11 +391,12 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level) db.RoomConsumableOwnership.Add(own); } own.Count += 1; - // The purchase response carries no inventory row, so the client - // adopts the NewConcurrencyCode it sent — store it verbatim or - // the next consume hits ConcurrencyCodeMismatch. + // The client adopts the NewConcurrencyCode it sent regardless of what + // the response echoes back — store it verbatim or the next consume + // hits ConcurrencyCodeMismatch. if (newCode is Guid code && code != Guid.Empty) own.ConcurrencyCode = code; own.ModifiedAt = DateTime.UtcNow; + return own; } private async Task CanManageRoomAsync(RoomEntity room, long playerId) @@ -391,10 +426,32 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level) Consumable = item is null ? null : ToDescWire(item), }; - private static object TokenResponse(int operationResult, long balance) => new + /// Token-purchase response (client type MJKFEHPIDME, formatter + /// HJOKFJKKEJM). + /// + /// OperationResult is an OBJECT, not a scalar. MJKFEHPIDME only + /// declares BalanceUpdateResult (+24) and TokenBalanceResponse (+32) + /// itself (MJKFEHPIDME.txt:3,23); OperationResult (+16) comes from its + /// base OKHNOPLBOFP, whose single field is typed BGHBAILNNJJ + /// (OKHNOPLBOFP.txt:3) — the same { Status, InventoryItem } pair + /// the consume route returns (BGHBAILNNJJ.txt:3,83). HJOKFJKKEJM's key + /// map puts "OperationResult" at index 2 (HJOKFJKKEJM.txt:330) and its + /// reader resolves index 2 through JECENNBIMEI<BGHBAILNNJJ> + /// (HJOKFJKKEJM.txt:1104). A bare int there made the generated reader + /// hit a number where it verifies '{', faulting the whole request with + /// "Failed to purchase room consumable" (DCFKEFHJAGC.txt:8294) — so + /// purchases were dead even once the PUT verb was accepted. + /// + /// The scalar operation enum belongs in BalanceUpdateResult + /// (Nullable<CABBDKFODEC>, index 0) — that is what the client turns + /// into the "Not enough …" / "The price for this … has changed" toast + /// (DCFKEFHJAGC.txt:12735-12760). It is null when the balance was never + /// touched. + private static object TokenResponse( + int? balanceUpdateResult, int status, long balance, object? inventoryItem = null) => new { - OperationResult = operationResult, - BalanceUpdateResult = operationResult, + OperationResult = new { Status = status, InventoryItem = inventoryItem }, + BalanceUpdateResult = balanceUpdateResult, TokenBalanceResponse = new { CurrencyType = TokenCurrencyType, @@ -403,10 +460,20 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level) }, }; - private static object CurrencyResponse(int operationResult, long playerId, Guid currencyId, long balance) => new + /// Currency-purchase response (client type DEILBLCDNEA, + /// formatter IDBFKCFEAHM). Same base type as the token response, so + /// OperationResult is the same { Status, InventoryItem } + /// object; index 2 resolves through JECENNBIMEI<BGHBAILNNJJ> + /// (IDBFKCFEAHM.txt:330 key map, :1161 reader). BalanceUpdateResult is + /// Nullable<GACBLALELBP> — an enum with only Success=0 and + /// NotEnoughCredit=1, so every other failure has to be reported through + /// OperationResult.Status and leave this null. + private static object CurrencyResponse( + int? balanceUpdateResult, int status, long playerId, Guid currencyId, long balance, + object? inventoryItem = null) => new { - OperationResult = operationResult, - BalanceUpdateResult = operationResult, + OperationResult = new { Status = status, InventoryItem = inventoryItem }, + BalanceUpdateResult = balanceUpdateResult, CurrencyBalanceResponse = new { AccountId = (int)playerId, diff --git a/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs b/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs index 3ce04b2..e67f675 100644 --- a/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs +++ b/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs @@ -45,7 +45,11 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : Name = name, Description = Trim(ReadString(fields, "description", "Description"), 512), ImageName = Trim(ReadString(fields, "imageName", "ImageName"), 256), - DailyLimit = Math.Max(0, ReadInt(fields, "limit", "Limit", "dailyLimit", "DailyLimit") ?? 0), + // "Limit" is an Int64 on the wire — the client boxes a System.Int64 + // immediately before pushing the param (MIHKAJFMIPE.txt:757-768). + // ReadInt would fail to parse anything above int.MaxValue and quietly + // store 0, so parse as long and clamp into the int column. + DailyLimit = ClampToInt(ReadLong(fields, "limit", "Limit", "dailyLimit", "DailyLimit") ?? 0), }; db.RoomCurrencies.Add(currency); await db.SaveChangesAsync(); @@ -65,7 +69,8 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : if (ReadString(fields, "name", "Name") is { } name) currency.Name = Trim(name, 64); if (ReadString(fields, "description", "Description") is { } description) currency.Description = Trim(description, 512); if (ReadString(fields, "imageName", "ImageName") is { } imageName) currency.ImageName = Trim(imageName, 256); - if (ReadInt(fields, "limit", "Limit", "dailyLimit", "DailyLimit") is int dailyLimit) currency.DailyLimit = Math.Max(0, dailyLimit); + // Int64 on the wire (MIHKAJFMIPE.txt:983 block, same boxing as createCurrency). + if (ReadLong(fields, "limit", "Limit", "dailyLimit", "DailyLimit") is long dailyLimit) currency.DailyLimit = ClampToInt(dailyLimit); currency.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(ToWire(currency)); @@ -121,24 +126,96 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : })); } + /// Bulk currency award — the CircuitsV2 "award currency" chip. + /// + /// The client body is a BARE JSON ARRAY, not an object: the issuing method is + /// FGLDKEJLAKB<List<KBPKAAIDCIJ>> FGBCMAINCLD(List<KOEIABFLEPM>) + /// (MIHKAJFMIPE.txt:1073); it POSTs (verb 2, MIHKAJFMIPE.txt:1262) to the route + /// at MIHKAJFMIPE.txt:1260 and pushes the serialised list straight into the + /// body via BNDIAONDFFF.FJLLPHFOOJJ (MIHKAJFMIPE.txt:1284). Every + /// element carries its OWN CurrencyId/RecipientId/Amount/TransactionId + /// (request formatter GFKKOADFJKC.txt:331-398) — this is NOT "one currency, + /// one amount, many players", which is what the old handler assumed. The old + /// handler also went through , which only walks + /// an OBJECT root, so an array body produced zero fields and every award + /// 404'd. + /// + /// A per-element failure is reported in-band rather than as an HTTP error so + /// one bad row cannot void a whole batch — the response formatter + /// (JCPFCOEEELK.txt:371-454) reads exactly AccountId/CurrencyId/Success/ + /// Error/Response, and the nested Response (GAGIJHJGFJD.txt:395-486) reads + /// AccountId/CurrencyId/Balance/AmountAwarded/AwardedAt. [HttpPost("api/roomcurrencies/v1/awardCurrency/bulk")] + [HttpPut("api/roomcurrencies/v1/awardCurrency/bulk")] public async Task AwardCurrencyBulk() { - var fields = await ReadFieldsAsync(); - var currency = await FindCurrencyAsync(fields); - if (currency is null) return NotFound(); - var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId); - if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid(); + // The body stream is single-read: pull the raw text once and hand it to + // the field reader for the legacy fallback below. + var body = await ReadJsonBodyAsync(); + var awards = ParseAwards(body); - var playerIds = ReadLongList(fields, "playerIds", "PlayerIds", "accountIds", "AccountIds"); - var amount = ReadLong(fields, "amount", "Amount") ?? 0; - if (playerIds.Count == 0 || amount == 0) return BadRequest("missing_awards"); + if (awards.Count == 0) + { + // Legacy DorkNet/admin shape: one currency + playerIds + a single amount. + var fields = await ReadFieldsAsync(body); + var legacyCurrency = await FindCurrencyAsync(fields); + var legacyAmount = ReadLong(fields, "amount", "Amount") ?? 0; + if (legacyCurrency is not null && legacyAmount != 0) + foreach (var playerId in ReadLongList(fields, "playerIds", "PlayerIds", "accountIds", "AccountIds")) + awards.Add(new AwardRequest(Guid.Empty, playerId, legacyAmount, legacyCurrency.PublicId)); + } + + if (awards.Count == 0) return BadRequest("missing_awards"); var results = new List(); - foreach (var playerId in playerIds) + var currencyCache = new Dictionary(); + var manageCache = new Dictionary(); + var awardedAt = DateTime.UtcNow; + + foreach (var award in awards) { - var balance = await AddBalanceAsync(playerId, currency.Id, amount); - results.Add(new { PlayerId = (int)playerId, RoomCurrencyId = currency.PublicId, Balance = balance }); + if (!currencyCache.TryGetValue(award.CurrencyId, out var currency)) + { + currency = await db.RoomCurrencies + .FirstOrDefaultAsync(c => c.PublicId == award.CurrencyId && !c.IsDeleted); + currencyCache[award.CurrencyId] = currency; + } + + if (currency is null) + { + results.Add(AwardFailure(award, "currency_not_found")); + continue; + } + + if (!manageCache.TryGetValue(currency.RoomId, out var canManage)) + { + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId); + canManage = room is not null && await CanManageRoomAsync(room, Me); + manageCache[currency.RoomId] = canManage; + } + + if (!canManage) + { + results.Add(AwardFailure(award, "forbidden")); + continue; + } + + var balance = await AddBalanceAsync(award.RecipientId, currency.Id, award.Amount); + results.Add(new + { + AccountId = (int)award.RecipientId, + CurrencyId = currency.PublicId, + Success = true, + Error = (string?)null, + Response = new + { + AccountId = (int)award.RecipientId, + CurrencyId = currency.PublicId, + Balance = balance, + AmountAwarded = award.Amount, + AwardedAt = awardedAt, + }, + }); } await db.SaveChangesAsync(); @@ -158,8 +235,13 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : { RoomCurrencyId = currency.Id, Name = Trim(ReadString(fields, "name", "Name") ?? currency.Name, 64), - Amount = Math.Max(1, ReadInt(fields, "amount", "Amount") ?? 1), - Price = Math.Max(0, ReadInt(fields, "price", "Price") ?? 0), + // Amount/Price are Int64 on the wire (MIHKAJFMIPE.txt:2351-2374 boxes + // System.Int64 for both) — parse long, clamp into the int columns. + Amount = Math.Max(1, ClampToInt(ReadLong(fields, "amount", "Amount") ?? 1)), + Price = ClampToInt(ReadLong(fields, "price", "Price") ?? 0), + // The client never sends currencyType for an offer (its params are + // CurrencyId/Name/Amount/Price/Order, MIHKAJFMIPE.txt:2336-2383); the + // default 2 = tokens is what the v2/purchase path debits. CurrencyType = Math.Max(0, ReadInt(fields, "currencyType", "CurrencyType") ?? 2), }; db.RoomCurrencyPurchaseOffers.Add(offer); @@ -179,8 +261,9 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid(); if (ReadString(fields, "name", "Name") is { } name) offer.Name = Trim(name, 64); - if (ReadInt(fields, "amount", "Amount") is int amount) offer.Amount = Math.Max(1, amount); - if (ReadInt(fields, "price", "Price") is int price) offer.Price = Math.Max(0, price); + // Int64 on the wire, as in createPurchaseOffer. + if (ReadLong(fields, "amount", "Amount") is long amount) offer.Amount = Math.Max(1, ClampToInt(amount)); + if (ReadLong(fields, "price", "Price") is long price) offer.Price = ClampToInt(price); if (ReadInt(fields, "currencyType", "CurrencyType") is int currencyType) offer.CurrencyType = Math.Max(0, currencyType); offer.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); @@ -204,6 +287,10 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : return Ok(ToOfferWire(offer, currency)); } + /// Both verbs are mapped on purpose: the client picks GET or POST at + /// runtime from the id count (ALHIJCJOLCB.JIECAFGCODK(ids, 100), + /// MIHKAJFMIPE_NestedType_OBLJADOFIED.txt:210-212) — GET under 100 ids, POST + /// at 100+. [HttpPost("api/roomcurrencies/v1/getPurchaseOffersBatch")] [HttpGet("api/roomcurrencies/v1/getPurchaseOffersBatch")] [AllowAnonymous] @@ -211,7 +298,11 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : { var fields = await ReadFieldsAsync(); var offerIds = ReadGuidList(fields, "purchaseOfferIds", "PurchaseOfferIds", "offerIds", "OfferIds"); - var currencyIds = ReadGuidList(fields, "roomCurrencyIds", "RoomCurrencyIds", "currencyIds", "CurrencyIds"); + // The client calls the list param plainly "ids" + // (MIHKAJFMIPE_NestedType_OBLJADOFIED.txt:228) and it holds ROOM CURRENCY + // ids — the response is grouped per currency. Without this alias both + // filters stayed empty and the handler dumped 200 arbitrary offers. + var currencyIds = ReadGuidList(fields, "ids", "Ids", "roomCurrencyIds", "RoomCurrencyIds", "currencyIds", "CurrencyIds"); var query = db.RoomCurrencyPurchaseOffers .Where(o => !o.IsDeleted) @@ -221,7 +312,23 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : if (currencyIds.Count > 0) query = query.Where(x => currencyIds.Contains(x.c.PublicId)); var rows = await query.Take(200).ToListAsync(); - return Ok(rows.Select(x => ToOfferWire(x.o, x.c))); + + // The response is GROUPED: List<{CurrencyId, PurchaseOffers}> (formatter + // LKBGCOELLIN.txt:215-258 reads only those two keys). A flat offer list + // deserialised into rows with CurrencyId=Guid.Empty and + // PurchaseOffers=null, so the "This Room" pack list was always empty. + var groups = rows + .GroupBy(x => x.c.PublicId) + .ToDictionary(g => g.Key, g => g.Select(x => ToOfferWire(x.o, x.c)).ToList()); + + // Answer for every requested currency, including ones with no offers, so + // the client can cache a definite "no packs" instead of retrying. + var keys = currencyIds.Count > 0 ? currencyIds : groups.Keys.ToList(); + return Ok(keys.Select(id => new + { + CurrencyId = id, + PurchaseOffers = groups.TryGetValue(id, out var offers) ? offers : new List(), + })); } [HttpPost("api/roomCurrencies/v2/purchase")] @@ -233,9 +340,28 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : if (offer is null) return NotFound(); var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId); var pid = Me; + + // The client states the price and amount it agreed to — the request pushes + // PurchaseOfferId (Guid), RequestedAmount (Int64) and RequestedPrice + // (Int64) in that order (DCFKEFHJAGC.txt:7810, :7822, :7834). Treat them + // as a price agreement so an offer edited mid-purchase cannot overcharge. + var requestedPrice = ReadLong(fields, "requestedPrice", "RequestedPrice"); + var requestedAmount = ReadLong(fields, "requestedAmount", "RequestedAmount"); + if ((requestedPrice is long rp && rp != offer.Price) + || (requestedAmount is long ra && ra != offer.Amount)) + return Conflict(new { Error = "offer_changed" }); + var balance = await level.GetBalanceAsync(pid, offer.CurrencyType); + + // The success DTO has room for exactly CurrencyBalanceResponse and + // TokenBalanceResponse (formatter KNGMIABFJHK.txt:215/242) — there is no + // Success flag to carry a failure — so failures must go out as non-2xx and + // take the client's own error path ("Failed to purchase currency offer", + // DCFKEFHJAGC.txt:7850). A 200 carrying {Success=false,...} deserialised + // to two null balances and looked like a successful, free purchase. if (balance < offer.Price) - return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance, offer.CurrencyType }); + return Conflict(new { Error = "insufficient_funds" }); + var newBalance = offer.Price == 0 ? balance : await level.GrantCurrencyAsync(pid, offer.CurrencyType, -offer.Price, $"roomCurrency:{offer.PublicId}"); @@ -243,11 +369,22 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : await db.SaveChangesAsync(); return Ok(new { - Success = true, - Balance = newBalance, - offer.CurrencyType, - RoomCurrencyId = currency.PublicId, - RoomCurrencyBalance = customBalance, + // Nested shapes: DPNLANKOHON.txt:331-398 {AccountId, CurrencyId, + // Balance, ModifiedAt} and PECGEJAAMHB.txt:255-298 {Balance, + // CurrencyType, Platform}. + CurrencyBalanceResponse = new + { + AccountId = (int)pid, + CurrencyId = currency.PublicId, + Balance = customBalance, + ModifiedAt = DateTime.UtcNow, + }, + TokenBalanceResponse = new + { + Balance = newBalance, + offer.CurrencyType, + Platform = 0, + }, }); } @@ -281,8 +418,14 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : private async Task AddBalanceAsync(long playerId, long currencyId, long amount) { - var row = await db.RoomCurrencyBalances - .FirstOrDefaultAsync(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId); + // Check the change tracker first: a bulk award can name the same + // (player, currency) pair twice, and an unsaved row is invisible to a + // fresh query — without this the second hit would insert a duplicate + // balance row and lose the first award. + var row = db.RoomCurrencyBalances.Local + .FirstOrDefault(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId) + ?? await db.RoomCurrencyBalances + .FirstOrDefaultAsync(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId); if (row is null) { row = new RoomCurrencyBalanceEntity { PlayerId = playerId, RoomCurrencyId = currencyId }; @@ -302,7 +445,11 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : return await db.Players.Where(p => p.Id == playerId).Select(p => p.IsAdmin).FirstOrDefaultAsync(); } - private async Task> ReadFieldsAsync() + /// Pre-read request body. The body stream can only be + /// consumed once, so a caller that already had to look at the raw text + /// ( needs to see its ARRAY root) hands it back + /// in here instead of letting this re-read Request.Body. + private async Task> ReadFieldsAsync(string? jsonBody = null) { var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var pair in Request.Query) @@ -313,34 +460,133 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : var form = await Request.ReadFormAsync(); foreach (var pair in form) fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty; + return fields; } - else if ((Request.ContentLength ?? 0) > 0 - && Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) + + var raw = jsonBody ?? await ReadJsonBodyAsync(); + if (string.IsNullOrWhiteSpace(raw)) return fields; + + try { - try + using var doc = JsonDocument.Parse(raw); + if (doc.RootElement.ValueKind == JsonValueKind.Object) { - using var doc = await JsonDocument.ParseAsync(Request.Body); - if (doc.RootElement.ValueKind == JsonValueKind.Object) + foreach (var prop in doc.RootElement.EnumerateObject()) { - foreach (var prop in doc.RootElement.EnumerateObject()) + fields[prop.Name] = prop.Value.ValueKind switch { - fields[prop.Name] = prop.Value.ValueKind switch - { - JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())), - JsonValueKind.String => prop.Value.GetString() ?? string.Empty, - _ => prop.Value.GetRawText(), - }; - } + JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())), + JsonValueKind.String => prop.Value.GetString() ?? string.Empty, + _ => prop.Value.GetRawText(), + }; } } - catch (JsonException) - { - } + } + catch (JsonException) + { } return fields; } + private async Task ReadJsonBodyAsync() + { + if ((Request.ContentLength ?? 0) <= 0) return null; + if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) != true) return null; + using var reader = new StreamReader(Request.Body, leaveOpen: true); + return await reader.ReadToEndAsync(); + } + + /// One element of the bulk-award array + /// (request formatter GFKKOADFJKC.txt:331-398: CurrencyId, RecipientId, + /// Amount, TransactionId). TransactionId is the client's idempotency token; + /// DorkNet has no transaction ledger to dedupe against, so it is parsed and + /// echoed nowhere — the response formatter does not read it back either. + private sealed record AwardRequest(Guid TransactionId, long RecipientId, long Amount, Guid CurrencyId); + + /// Parses the bare JSON array the client posts. Also tolerates an + /// {"awards":[...]} envelope for DorkNet's own tooling. + private static List ParseAwards(string? body) + { + var awards = new List(); + if (string.IsNullOrWhiteSpace(body)) return awards; + + try + { + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + if (root.ValueKind == JsonValueKind.Object) + { + if (JsonProp(root, "awards", "Awards") is { ValueKind: JsonValueKind.Array } envelope) + root = envelope; + else + return awards; + } + + if (root.ValueKind != JsonValueKind.Array) return awards; + foreach (var element in root.EnumerateArray()) + if (ToAward(element) is { } award) + awards.Add(award); + } + catch (JsonException) + { + } + + return awards; + } + + private static AwardRequest? ToAward(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) return null; + if (JsonGuid(element, "CurrencyId", "currencyId", "RoomCurrencyId", "roomCurrencyId") is not Guid currencyId) + return null; + if (JsonLong(element, "RecipientId", "recipientId", "AccountId", "accountId", "PlayerId", "playerId") is not long recipientId + || recipientId <= 0) + return null; + if (JsonLong(element, "Amount", "amount") is not long amount || amount == 0) return null; + return new AwardRequest( + JsonGuid(element, "TransactionId", "transactionId") ?? Guid.Empty, + recipientId, + amount, + currencyId); + } + + private static object AwardFailure(AwardRequest award, string error) => new + { + AccountId = (int)award.RecipientId, + CurrencyId = award.CurrencyId, + Success = false, + Error = error, + Response = (object?)null, + }; + + // JsonDocument property lookup is case-sensitive, so every alias is listed in + // both casings the client could plausibly emit. + private static JsonElement? JsonProp(JsonElement element, params string[] names) + { + foreach (var name in names) + if (element.TryGetProperty(name, out var value) && value.ValueKind != JsonValueKind.Null) + return value; + return null; + } + + private static Guid? JsonGuid(JsonElement element, params string[] names) => + JsonProp(element, names) is { ValueKind: JsonValueKind.String } value + && Guid.TryParse(value.GetString(), out var guid) + ? guid + : null; + + private static long? JsonLong(JsonElement element, params string[] names) => JsonProp(element, names) switch + { + { ValueKind: JsonValueKind.Number } number => number.TryGetInt64(out var value) ? value : null, + { ValueKind: JsonValueKind.String } text => long.TryParse(text.GetString(), out var value) ? value : null, + _ => null, + }; + + /// Several wire fields are Int64 while the backing columns are int; + /// clamp instead of failing the parse and silently storing 0. + private static int ClampToInt(long value) => (int)Math.Clamp(value, 0, int.MaxValue); + private static string? ReadString(Dictionary fields, params string[] names) { foreach (var name in names) @@ -417,15 +663,23 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : currency.UpdatedAt, }; - /// Purchase offer as the client READS it (formatter AALDCAANEMM): - /// CurrencyPurchaseOfferId, CurrencyId, Name, - /// CurrencyAmount, Price, CurrencyType, - /// CreatedAt, ModifiedAt. Legacy aliases retained as in - /// . + /// Purchase offer as the client READS it (formatter + /// AALDCAANEMM.txt:499-638): exactly CurrencyPurchaseOfferId, + /// CurrencyId, Order, Name, CurrencyAmount, + /// Price, ModifiedAt. CurrencyType/CreatedAt are + /// not read by the client and are kept only for DorkNet's admin UI, as are + /// the legacy aliases (see ). + /// + /// Order (Int32, sent by the client on createPurchaseOffer — + /// MIHKAJFMIPE.txt:2379-2383) has no column on + /// RoomCurrencyPurchaseOfferEntity yet, so it always reports 0 and the + /// pack list falls back to insertion order. Persisting it needs a schema + /// change in Data/Entities/RoomEconomyEntities.cs. private static object ToOfferWire(RoomCurrencyPurchaseOfferEntity offer, RoomCurrencyEntity currency) => new { CurrencyPurchaseOfferId = offer.PublicId, CurrencyId = currency.PublicId, + Order = 0, offer.Name, CurrencyAmount = offer.Amount, offer.Price, diff --git a/DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs b/DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs index 3538515..02ab254 100644 --- a/DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs +++ b/DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs @@ -274,6 +274,14 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase RoomKey = key is null ? null : ToWire(key), }; + /// The 2023-03-21 room-key formatter reads nine members — + /// RoomKeyId, ReplicationId, RoomId, Name, Description, Price, + /// PurchaseCurrencyId, CreatedAt, ImageName + /// (RecNet.Runtime/ANHGLIFGACE.txt:627-814 name table, :829-993 reader). + /// CreatedAt was missing and silently defaulted to DateTime.MinValue in the + /// key ring. PurchaseCurrencyId (Nullable<Guid>) and ImageName have no + /// column on RoomKeyEntity yet, so they are still omitted rather than + /// emitted as permanent nulls — both are absent-tolerant on the client. public static object ToWire(RoomKeyEntity key) => new { RoomKeyId = key.Id, @@ -284,6 +292,7 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase key.Name, key.Description, key.Price, + key.CreatedAt, }; private static RoomKeyStatus ValidateText(string? name, string? description, int price) @@ -328,50 +337,82 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase return ids.Distinct().Take(200).ToList(); } + /// Read a room-key write request. + /// + /// The 2023-03-21 client puts every scalar of create/update in the QUERY + /// STRING, not in a body: BNDIAONDFFF.AFGEDDANEKP only appends to the + /// param dictionary at field +0x20 (BNDIAONDFFF.txt:492-506), and the send + /// path joins that dictionary with "&" and glues it onto the URL + /// behind "?" (BNDIAONDFFF.txt:3196, 3238, 3248-3251). So + /// POST api/roomkeys/v1/create and + /// PUT api/roomkeys/v1/update* arrive with an EMPTY body — reading + /// form/JSON only left Name null and RoomId 0, i.e. every create answered + /// NameTooShort and every edit DoesNotExist. + /// + /// Query wins, form second, and a JSON body is only parsed when neither + /// carried anything (older clients and the admin UI still post JSON). private async Task ReadBodyAsync() where T : new() { - if (Request.HasFormContentType) + var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null; + + string? Field(params string[] names) { - var form = await Request.ReadFormAsync(); - if (typeof(T) == typeof(NewRoomKeyRequest)) + foreach (var name in names) + { + var q = Request.Query[name].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(q)) return q; + if (form is not null) + { + var f = form[name].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(f)) return f; + } + } + return null; + } + + if (typeof(T) == typeof(NewRoomKeyRequest)) + { + var roomId = Field("roomId", "RoomId"); + var name = Field("name", "Name"); + var description = Field("description", "Description"); + var price = Field("price", "Price"); + if (roomId is not null || name is not null || description is not null || price is not null) { object req = new NewRoomKeyRequest { - RoomId = long.TryParse(form["roomId"].FirstOrDefault() - ?? form["RoomId"].FirstOrDefault(), out var roomId) - ? roomId - : 0, - Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(), - Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(), - Price = int.TryParse(form["price"].FirstOrDefault() - ?? form["Price"].FirstOrDefault(), out var price) - ? price - : 0, + RoomId = long.TryParse(roomId, out var parsedRoomId) ? parsedRoomId : 0, + Name = name, + Description = description, + Price = int.TryParse(price, out var parsedPrice) ? parsedPrice : 0, }; return (T)req; } - - if (typeof(T) == typeof(UpdateRoomKeyRequest)) + } + else if (typeof(T) == typeof(UpdateRoomKeyRequest)) + { + // updateName sends only Name, updatePrice only Price, etc. — every + // field the client omitted stays null here and the handler keeps the + // stored value (AHMBBJNANBP.txt:1300-1345, 1550-1559, 1763-1772, + // 2018-2043). + var roomKeyId = Field("roomKeyId", "RoomKeyId"); + var name = Field("name", "Name"); + var description = Field("description", "Description"); + var price = Field("price", "Price"); + if (roomKeyId is not null || name is not null || description is not null || price is not null) { object req = new UpdateRoomKeyRequest { - RoomKeyId = long.TryParse(form["roomKeyId"].FirstOrDefault() - ?? form["RoomKeyId"].FirstOrDefault(), out var roomKeyId) - ? roomKeyId - : 0, - Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(), - Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(), - Price = int.TryParse(form["price"].FirstOrDefault() - ?? form["Price"].FirstOrDefault(), out var price) - ? price - : null, + RoomKeyId = long.TryParse(roomKeyId, out var parsedKeyId) ? parsedKeyId : 0, + Name = name, + Description = description, + Price = int.TryParse(price, out var parsedPrice) ? parsedPrice : null, }; return (T)req; } - - return new T(); } + if (form is not null) return new T(); + try { return await JsonSerializer.DeserializeAsync(Request.Body, JsonOptions) ?? new T(); diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 3e46185..c01a713 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -53,14 +53,48 @@ public class RoomsController( [FromQuery] string? tags) => Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList()); + /// GET rooms/hot — the room-server "hot rooms" list. + /// Response is the PAGED wrapper: the 2023 client's issuing method + /// NLDBPDCNNCF.FANOAMOPHBE returns Task<FFCPFGBNLHN> + /// (NLDBPDCNNCF.txt:2865 + literal at :2983) and FFCPFGBNLHN's reader + /// requires Results + TotalResults + /// (PJPBJKLDMNA.txt ISIL 040/059). + // The roomserver/ twin is mandatory: FANOAMOPHBE lives on the same + // NLDBPDCNNCF client whose requests all arrive with the roomserver/ + // prefix in this deployment (docs/recroom-2023-room-save.md:83-93), so + // the bare-only registration 404'd for the 2023 client. [HttpGet("rooms/hot")] + [HttpGet("roomserver/rooms/hot")] public async Task HotRoomServer( - [FromQuery] string? tag, - [FromQuery] string? tags, [FromQuery] int skip = 0, [FromQuery] int take = 100) { - var all = await rooms.HotAsync(tag ?? tags, take: 200); + // The tag filter is a REPEATED query field (`tag=a&tag=b`) — the + // client's IFLHPHIPFNC closure adds one entry per selected chip — + // so a `[FromQuery] string? tag` binding silently dropped every tag + // past the first. Read the raw collection and union the per-tag hits. + var tagFilters = Request.Query["tag"] + .Concat(Request.Query["tags"]) + .SelectMany(v => (v ?? string.Empty).Split(',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + List all; + if (tagFilters.Count == 0) + { + all = await rooms.HotAsync(null, take: 200); + } + else + { + var merged = new List(); + var seen = new HashSet(); + foreach (var filter in tagFilters) + foreach (var room in await rooms.HotAsync(filter, take: 200)) + if (seen.Add(room.Id)) merged.Add(room); + all = merged.OrderByDescending(r => r.HotScore).ToList(); + } + var page = all .Skip(Math.Max(skip, 0)) .Take(Math.Clamp(take, 1, 100)) @@ -151,16 +185,37 @@ public class RoomsController( return Ok(await BuildRoomServerListAsync(rows)); } + /// GET rooms/magic_door — the Magic Door destination. + /// The response is a WRAPPER, not the bare room: the client's + /// MOOCMPOIIGM returns Task<GAPJAGDNFEO> + /// (NLDBPDCNNCF.txt:4503) and that reader requires the keys + /// RefreshesAt, RefreshIntervalMinutes and Room + /// (MIFGPMGEMEK.txt ISIL 046/073/097). Returning the room object + /// directly left Room null and MagicDoorManager with no + /// destination. The partySize query param is accepted and + /// ignored — we have no party-size-aware curation. [HttpGet("rooms/magic_door")] [HttpGet("roomserver/rooms/magic_door")] - public async Task MagicDoor() + public async Task MagicDoor([FromQuery] int? partySize) { var room = await PublicRoomQuery() .OrderByDescending(r => r.HotScore) .ThenBy(r => r.Id) .FirstOrDefaultAsync(); if (room is null) return NotFound(); - return Ok((await BuildRoomServerListAsync(new[] { room })).First()); + + // The door re-rolls its destination on the client every + // RefreshIntervalMinutes; RefreshesAt is when the current pick + // expires. Keep the two consistent so the client's local timer + // and its next fetch line up. + const int refreshIntervalMinutes = 10; + return Ok(new + { + Room = (await BuildRoomServerListAsync(new[] { room })).First(), + RefreshesAt = DateTime.UtcNow.AddMinutes(refreshIntervalMinutes) + .ToString("yyyy-MM-ddTHH:mm:ssZ"), + RefreshIntervalMinutes = refreshIntervalMinutes, + }); } /// @@ -263,8 +318,14 @@ public class RoomsController( /// Unlike the api/rooms/v* variants (flat List<Room>), /// this one's client contract is a PAGED wrapper object /// { Results, TotalResults } (IDLBPALJJDJ : PagedResult<Room>); - /// returning a bare array crashes the watch's key-based reader. + /// returning a bare array crashes the watch's key-based reader. + /// Confirmed for 2023 too: NLDBPDCNNCF.MOBJJDBNBMF returns + /// Task<FFCPFGBNLHN> (NLDBPDCNNCF.txt:2733) whose reader + /// requires Results/TotalResults (PJPBJKLDMNA.txt ISIL 040/059). + // roomserver/ twin: the 2023 caller is on NLDBPDCNNCF, whose requests all + // carry the roomserver/ prefix (docs/recroom-2023-room-save.md:83-93). [HttpGet("rooms/search")] + [HttpGet("roomserver/rooms/search")] public async Task SearchRoomServer( [FromQuery(Name = "query")] string? query, [FromQuery(Name = "value")] string? value, @@ -414,6 +475,46 @@ public class RoomsController( unityAssetVersion: unityAssetVersion)); } + /// DELETE rooms/{roomId} — the owner's "delete room" + /// button. Client method NLDBPDCNNCF.FPDBNHOHIAO formats + /// "rooms/{0}" and moves verb ordinal 4 (= DELETE) into r8 + /// (NLDBPDCNNCF.txt ISIL 021/030 around :4897); its return type is the + /// body-less promise LDGADANDBIO (CAOCJPMJMDB.txt:339), so the + /// response payload is never parsed. + /// + /// The only DELETE for rooms/{id} in the codebase lived on + /// AdminController, which is rooted at api/admin/v1 — the game + /// path therefore 405'd on the bare route and 404'd on the roomserver/ + /// one, so owners could not delete their rooms at all. + /// + /// Soft-archive (State = 1) exactly like + /// AdminController.DeleteRoom: a hard delete would orphan the + /// room's saves, visits, bookmarks and role rows. Archived rooms drop + /// out of every browse/search query (all of which filter + /// State == 0). + [HttpDelete("rooms/{roomId:long}")] + [HttpDelete("roomserver/rooms/{roomId:long}")] + [Authorize] + public async Task DeleteRoom(long roomId) + { + var me = this.RequireCurrentPlayerId(); + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); + if (room is null) return NotFound(); + if (room.CreatorPlayerId != me) + { + // Co-owners (Role 0) can delete too — same gate the rename path uses. + var coOwner = await db.RoomRoles.AnyAsync(rr => + rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && rr.Role == 0); + if (!coOwner) return Forbid(); + } + + room.State = 1; // Archived + room.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + logger.LogInformation("[room-delete] room={Room} by={By}", roomId, me); + return Ok(new { Result = 0, room.Id, room.State }); + } + // ── RoomDetails (the boot-critical one) ───────────────────────────── // // /v4/details/{roomId} is hit *during* the matchmaking goto flow. @@ -2511,8 +2612,13 @@ public class RoomsController( /// GET api/rooms/v1/agRoomIds — list of every /// seeded AG / RR-Original room id. Used by the watch to bias the /// room browser toward official rooms first. + // Bare array of Int64 — the 2023 issuing method GBONEKLGFFK returns + // Task> (NLDBPDCNNCF.txt:1886, literal at :1929). The + // roomserver/ twin is what that client actually requests (same prefix + // rule as rooms/base, docs/recroom-2023-room-save.md:83-93). [HttpGet("api/rooms/v1/agRoomIds")] [HttpGet("rooms/rro_ids")] + [HttpGet("roomserver/rooms/rro_ids")] public async Task AgRoomIds() => Ok(await rooms.AgRoomIdsAsync()); diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs index d0c5983..3c68e06 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs @@ -244,6 +244,19 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase .ToListAsync(); return Ok(rows.Select(b => new { + // The 2023 banned-players reader (IBHAKOOKEEE) has EXACTLY three + // members — AccountId (Int32), BannedByAccountId (Int32?) and + // BanStartTime (DateTime) — registered at + // IsilDump/RecNet.Runtime/ECEBBLBCFKO.txt:279 / :306 / :330 (the + // property types come from IBHAKOOKEEE.txt:3/:83/:103). None of + // them overlapped the legacy Id/RoomId/BannedPlayerId keys, so + // the in-room "banned players" list rendered all-zero rows. + // AccountId is Int32 on the wire, so clamp the long id. + AccountId = ToClientAccountId(b.BannedPlayerId), + BannedByAccountId = ToClientAccountId(b.BannedByPlayerId), + BanStartTime = b.CreatedAt, + // Legacy keys kept for the 2020.12 watch — both readers ignore + // members they don't know. b.Id, b.RoomId, b.BannedPlayerId, @@ -255,28 +268,47 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase })); } + /// Account ids are Int64 server-side but Int32 in the 2023 + /// wire DTOs (IBHAKOOKEEE.AccountId is System.Int32). Clamp rather than + /// wrap so an out-of-range id is obviously wrong instead of aliasing + /// onto a real account. + private static int ToClientAccountId(long accountId) + => accountId > int.MaxValue + ? int.MaxValue + : accountId < int.MinValue + ? int.MinValue + : (int)accountId; + // ── Report ─────────────────────────────────────────────────────────── - public sealed class RoomReportRequest - { - public long RoomId { get; set; } - public int Category { get; set; } - public string? Message { get; set; } - } - + // The 2023 client posts the room report as x-www-form-urlencoded with + // PascalCase keys RoomId (Int64) / RoomKeyId (Int64?) / Details (string) / + // ReportCategory (Int32) — IsilDump/RecNet.Runtime/IBEOONPEELF.txt:23471 + // ("api/rooms/v2/report"), verb byte 2 = POST at :23473, field names at + // :23486 / :23499 / :23506 / :23521. A [FromBody] parameter makes + // ASP.NET reject the form POST with 415 BEFORE the handler runs (the same + // mechanism documented for clone at :963-968 below), so reports were never + // persisted and the client logged "Failed to report room" + // (IBEOONPEELF.txt:23537). Read form-or-JSON manually and keep the older + // Category/Message names as aliases for the 2020.12 watch. [HttpPost("api/rooms/v2/report")] - public async Task Report([FromBody] RoomReportRequest req) + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task Report() { - var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId); + var payload = await ReadPayloadAsync(); + var roomId = LongValue(payload, "RoomId", "RoomKeyId") ?? 0; + if (roomId <= 0) return BadRequest(new { error = "missing_room" }); + + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); - var msg = req.Message ?? string.Empty; + var msg = RawValue(payload, "Details", "Message") ?? string.Empty; db.Reports.Add(new ReportEntity { ReporterPlayerId = Me, TargetPlayerId = room.CreatorPlayerId, - TargetRoomId = req.RoomId, - RoomId = req.RoomId, - Category = req.Category, + TargetRoomId = roomId, + RoomId = roomId, + Category = IntValue(payload, "ReportCategory", "Category") ?? 0, Message = msg[..Math.Min(1000, msg.Length)], }); await db.SaveChangesAsync(); @@ -433,53 +465,98 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase }; } - // ── Bare-path per-field mutations (2020.12 watch URLs) ───────────── + // ── Bare-path per-field mutations (2020.12 watch + 2023 roomserver) ── + // + // Both clients emit per-field mutation URLs like + // PUT rooms/{id}/description rather than POST api/rooms/v2/modify with + // one field set. Each handler below shares the same RequireOwnedRoomAsync + // gate as /modify and returns the FULL room-details object via + // ApplyAndReturn (see the note there). + // + // Every handler reads the body itself instead of taking a [FromBody] + // parameter: both clients send x-www-form-urlencoded, and under + // [ApiController] a [FromBody] parameter makes ASP.NET reject a form body + // with 415 BEFORE the handler runs (mechanism documented at the clone + // handler below). // - // The 2020.12 client emits per-field mutation URLs like - // POST rooms/{id}/description rather than POST api/rooms/v2/modify - // with one field set. Each handler below shares the same - // RequireOwnedRoomAsync gate as /modify and returns the same - // ToWireRoom shape so the watch's CreateModifyRoomResponse - // deserializer reads cleanly. - - public sealed class BareDescriptionRequest { public string? Description { get; set; } } - public sealed class BareImageRequest { public string? ImageName { get; set; } } - public sealed class BareTagsRequest { public string? Tags { get; set; } public List? TagsList { get; set; } } - public sealed class BareIntRequest { public int? Value { get; set; } } - public sealed class BareBoolRequest { public bool? Value { get; set; } } - public sealed class BareWarningRequest { public int? RoomWarningMask { get; set; } public string? CustomRoomWarning { get; set; } } - // Each per-field route registers BOTH POST and PUT — the 2020.12 // watch's request-builder wraps the HTTP method inside opaque // BPHGKAEDBPE helpers; the existing /name handler at // RoomsController.cs:1455-1456 set the precedent of "register both // because the ISIL is opaque". Same pattern here so the bind never - // 405s. + // 405s. (The 2023 client is unambiguous: verb byte 3 = PUT, e.g. + // IsilDump/RecNet.Runtime/NLDBPDCNNCF.txt:5369.) + // + // …and both bare and roomserver/-prefixed paths, because the 2023 + // in-room roomserver client (NLDBPDCNNCF) issues all of these under the + // roomserver host prefix — a bare-only registration 404s there. + /// PUT rooms/{id}/description — form key + /// description (NLDBPDCNNCF/HBKMCDLDHHO:57). [HttpPost("rooms/{roomId:long}/description")] [HttpPut("rooms/{roomId:long}/description")] - public async Task BareDescription(long roomId, - [FromBody] BareDescriptionRequest? body, - [FromForm(Name = "Description")] string? form) => - await ApplyAndReturn(roomId, r => r.Description = body?.Description ?? form ?? r.Description); + [HttpPost("roomserver/rooms/{roomId:long}/description")] + [HttpPut("roomserver/rooms/{roomId:long}/description")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareDescription(long roomId) + { + // RawValue, not the whitespace-skipping reader: clearing the + // description sends the key with an empty value. + var payload = await ReadPayloadAsync(); + var value = RawValue(payload, "description", "value"); + return await ApplyAndReturn(roomId, r => + { + if (value is not null) r.Description = value; + }); + } + /// PUT rooms/{id}/image — form key imageName + /// (NLDBPDCNNCF/MCDHHIBPJPD:57). [HttpPost("rooms/{roomId:long}/image")] [HttpPut("rooms/{roomId:long}/image")] - public async Task BareImage(long roomId, - [FromBody] BareImageRequest? body, - [FromForm(Name = "ImageName")] string? form) => - await ApplyAndReturn(roomId, r => r.ImageName = body?.ImageName ?? form ?? r.ImageName); + [HttpPost("roomserver/rooms/{roomId:long}/image")] + [HttpPut("roomserver/rooms/{roomId:long}/image")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareImage(long roomId) + { + var payload = await ReadPayloadAsync(); + var value = RawValue(payload, "imageName", "image", "name", "value"); + return await ApplyAndReturn(roomId, r => + { + if (value is not null) r.ImageName = value; + }); + } + /// PUT rooms/{id}/tags. The 2023 client sends the tag + /// lists as REPEATED form keys — autoTag then tag + /// (NLDBPDCNNCF/FOPHALMJKGA:70 and :77, both fed from + /// IReadOnlyList<string> parameters, NLDBPDCNNCF.txt:5543) — + /// NOT a single CSV field, so the old Tags binding read nothing + /// and the edit was a silent no-op. [HttpPost("rooms/{roomId:long}/tags")] [HttpPut("rooms/{roomId:long}/tags")] - public async Task BareTags(long roomId, - [FromBody] BareTagsRequest? body, - [FromForm(Name = "Tags")] string? form) + [HttpPost("roomserver/rooms/{roomId:long}/tags")] + [HttpPut("roomserver/rooms/{roomId:long}/tags")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareTags(long roomId) { - var csv = body?.Tags - ?? (body?.TagsList is { Count: > 0 } list ? string.Join(',', list) : null) - ?? form; - return await ApplyAndReturn(roomId, r => r.TagsCsv = csv ?? r.TagsCsv); + var payload = await ReadPayloadAsync(); + var tags = Values(payload, "autoTag", "tag"); + // 2020.12 watch / admin tooling: one comma-separated "Tags" field. + var csv = Value(payload, "tags"); + if (csv is not null) + tags.AddRange(csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + + // Only rewrite when the request actually carried a tag field — + // otherwise a stray/emptied request would silently wipe the room's + // tags. Sending the keys with no values IS how the client clears + // them, so key presence (not value count) is the signal. + var carriesTags = payload.ContainsKey("autoTag") || payload.ContainsKey("tag") || payload.ContainsKey("tags"); + var joined = string.Join(',', tags.Distinct(StringComparer.OrdinalIgnoreCase)); + return await ApplyAndReturn(roomId, r => + { + if (carriesTags) r.TagsCsv = joined; + }); } [HttpPost("rooms/{roomId:long}/accessibility")] @@ -681,6 +758,129 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase return null; } + // ── Multi-field payload reader ────────────────────────────────────── + // + // The single-key Read*Async helpers above each parse Request.Body, and a + // request body can only be consumed once — calling two of them in the + // same handler silently loses every field after the first. Handlers that + // need several fields (report / modify / restrictions / warning / bans / + // loadscreen / promo_external) read the payload ONCE into the map below. + // + // It is a key → values map because the 2023 client encodes list + // parameters as REPEATED form keys (tags sends tag=…&tag=…, + // bans sends id=…&id=…). Keys are matched case-insensitively, which is + // what lets one lookup serve both the 2023 camelCase names and the + // 2020.12 PascalCase ones. + + private async Task>> ReadPayloadAsync() + { + var map = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + void Add(string key, string? value) + { + if (string.IsNullOrEmpty(key) || value is null) return; + if (!map.TryGetValue(key, out var list)) map[key] = list = new List(); + list.Add(value); + } + + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var key in form.Keys) + foreach (var value in form[key]) + Add(key, value); + } + else if ((Request.ContentLength ?? 0) > 0) + { + try + { + using var doc = await JsonDocument.ParseAsync(Request.Body); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (prop.Value.ValueKind == JsonValueKind.Array) + foreach (var item in prop.Value.EnumerateArray()) + Add(prop.Name, JsonScalar(item)); + else + Add(prop.Name, JsonScalar(prop.Value)); + } + } + } + catch (JsonException) + { + // Non-JSON / truncated body — fall through to the query string. + } + } + + // Query string is the last-resort source (a few of these fields ride + // on the URL for the 2020.12 watch). Never let it shadow a body field. + foreach (var key in Request.Query.Keys) + { + if (map.ContainsKey(key)) continue; + foreach (var value in Request.Query[key]) + Add(key, value); + } + + return map; + } + + private static string? JsonScalar(JsonElement element) => element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => element.ToString(), + _ => null, + }; + + /// First NON-EMPTY value for any of . + private static string? Value(Dictionary> map, params string[] keys) + { + foreach (var key in keys) + { + if (!map.TryGetValue(key, out var list)) continue; + foreach (var value in list) + if (!string.IsNullOrWhiteSpace(value)) return value; + } + return null; + } + + /// First value for any of INCLUDING the + /// empty string. Text fields (description, customWarning) are cleared by + /// sending the key with an empty value, so the non-empty filter in + /// would turn "clear" into a no-op. + private static string? RawValue(Dictionary> map, params string[] keys) + { + foreach (var key in keys) + if (map.TryGetValue(key, out var list) && list.Count > 0) + return list[0]; + return null; + } + + /// Every non-empty value across , in key + /// order. Keys that differ only by case are visited once (the map is + /// case-insensitive, so they'd otherwise duplicate the same list). + private static List Values(Dictionary> map, params string[] keys) + { + var result = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var key in keys) + { + if (!seen.Add(key)) continue; + if (!map.TryGetValue(key, out var list)) continue; + result.AddRange(list.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v.Trim())); + } + return result; + } + + private static int? IntValue(Dictionary> map, params string[] keys) + => int.TryParse(Value(map, keys), out var value) ? value : null; + + private static long? LongValue(Dictionary> map, params string[] keys) + => long.TryParse(Value(map, keys), out var value) ? value : null; + + private static bool? BoolValue(Dictionary> map, params string[] keys) + => TryParseBool(Value(map, keys), out var value) ? value : null; + private static bool TryParseBool(string? raw, out bool value) { if (bool.TryParse(raw, out value)) return true; @@ -726,47 +926,91 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase return json.Length <= maxChars ? json : "[]"; } + /// PUT rooms/{id}/cloning — form key + /// cloningAllowed (NLDBPDCNNCF/ECMHAPMBNMA:67). [HttpPost("rooms/{roomId:long}/cloning")] [HttpPut("rooms/{roomId:long}/cloning")] - public async Task BareCloning(long roomId, [FromBody] BareBoolRequest? body, - [FromForm(Name = "CloningAllowed")] bool? form) => - await ApplyAndReturn(roomId, r => + [HttpPost("roomserver/rooms/{roomId:long}/cloning")] + [HttpPut("roomserver/rooms/{roomId:long}/cloning")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareCloning(long roomId) + { + var value = await ReadBareBoolAsync("cloningAllowed", "CloningAllowed", "value", "Value"); + return await ApplyAndReturn(roomId, r => { - var v = body?.Value ?? form; - if (v is bool vv) r.CloningAllowed = vv; + if (value is bool v) r.CloningAllowed = v; }); + } + /// PUT rooms/{id}/automute. The form key is + /// disable, NOT DisableMicAutoMute + /// (NLDBPDCNNCF/MAICLJKDBOB:67, from + /// ChangeRoomMicAutoMute(bool)) — true means "mic auto-mute is + /// disabled", so it maps straight onto DisableMicAutoMute. [HttpPost("rooms/{roomId:long}/automute")] [HttpPut("rooms/{roomId:long}/automute")] - public async Task BareAutomute(long roomId, [FromBody] BareBoolRequest? body, - [FromForm(Name = "DisableMicAutoMute")] bool? form) => - await ApplyAndReturn(roomId, r => + [HttpPost("roomserver/rooms/{roomId:long}/automute")] + [HttpPut("roomserver/rooms/{roomId:long}/automute")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareAutomute(long roomId) + { + var value = await ReadBareBoolAsync("disable", "Disable", "DisableMicAutoMute", "value", "Value"); + return await ApplyAndReturn(roomId, r => { - var v = body?.Value ?? form; - if (v is bool vv) r.DisableMicAutoMute = vv; + if (value is bool v) r.DisableMicAutoMute = v; }); + } - /// rooms/{id}/restrictions — junior / age-related - /// restriction toggle. Body bool maps to AllowsJuniors (true = no - /// restriction, false = juniors restricted). + /// PUT rooms/{id}/restrictions — the "who can play here" + /// panel. The 2023 client sends FOUR booleans in one request: + /// supportsScreens, supportsWalkVR, + /// supportsTeleportVR, supportsJuniors + /// (NLDBPDCNNCF/GNCDFBNCADM:26/:39/:52/:65, matching the four-bool + /// signature at NLDBPDCNNCF.txt:6025). The old single-bool + /// AllowsJuniors binding dropped three of the four settings. [HttpPost("rooms/{roomId:long}/restrictions")] [HttpPut("rooms/{roomId:long}/restrictions")] - public async Task BareRestrictions(long roomId, [FromBody] BareBoolRequest? body, - [FromForm(Name = "AllowsJuniors")] bool? form) => - await ApplyAndReturn(roomId, r => + [HttpPost("roomserver/rooms/{roomId:long}/restrictions")] + [HttpPut("roomserver/rooms/{roomId:long}/restrictions")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareRestrictions(long roomId) + { + var payload = await ReadPayloadAsync(); + var screens = BoolValue(payload, "supportsScreens"); + var walkVR = BoolValue(payload, "supportsWalkVR"); + var teleportVR = BoolValue(payload, "supportsTeleportVR"); + // 2020.12 watch sent a bare AllowsJuniors/Value instead. + var juniors = BoolValue(payload, "supportsJuniors", "allowsJuniors", "value"); + return await ApplyAndReturn(roomId, r => { - var v = body?.Value ?? form; - if (v is bool vv) r.AllowsJuniors = vv; + if (screens is bool s) r.SupportsScreens = s; + if (walkVR is bool w) r.SupportsWalkVR = w; + if (teleportVR is bool t) r.SupportsTeleportVR = t; + if (juniors is bool j) r.AllowsJuniors = j; }); + } + /// PUT rooms/{id}/warning — content warnings. Form keys + /// are warningMask (int) and customWarning (string) + /// (NLDBPDCNNCF/AIDDFKACPLN:80 and :89), not the RoomWarningMask / + /// CustomRoomWarning names the old [FromBody] DTO used. [HttpPost("rooms/{roomId:long}/warning")] [HttpPut("rooms/{roomId:long}/warning")] - public async Task BareWarning(long roomId, [FromBody] BareWarningRequest? body) => - await ApplyAndReturn(roomId, r => + [HttpPost("roomserver/rooms/{roomId:long}/warning")] + [HttpPut("roomserver/rooms/{roomId:long}/warning")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareWarning(long roomId) + { + var payload = await ReadPayloadAsync(); + var mask = IntValue(payload, "warningMask", "RoomWarningMask"); + // RawValue so clearing the custom warning (empty string) sticks. + var custom = RawValue(payload, "customWarning", "CustomRoomWarning"); + return await ApplyAndReturn(roomId, r => { - if (body?.RoomWarningMask is int m) r.RoomWarningMask = m; - if (body?.CustomRoomWarning is not null) r.CustomRoomWarning = body.CustomRoomWarning; + if (mask is int m) r.RoomWarningMask = m; + if (custom is not null) r.CustomRoomWarning = custom.Length > 512 ? custom[..512] : custom; }); + } [HttpPost("rooms/{roomId:long}/creator")] [HttpPut("rooms/{roomId:long}/creator")] @@ -831,6 +1075,14 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase }); } + /// PUT rooms/{id}/loadscreen — form keys + /// imageName / title / subtitle + /// (NLDBPDCNNCF/AFBGGBEMOGH:83/:93/:103, verb byte 3 = PUT at + /// NLDBPDCNNCF.txt:8519). Builds the exact three-member object the + /// client's load-screen reader wants — ImageName / Title / Subtitle + /// (EOIMIBBJBCB.txt:255/:282/:298) — and APPENDS it. The old code + /// serialised the whole form dict as one blob and REPLACED the list, so + /// a room could never hold more than one load screen. [HttpPost("rooms/{roomId:long}/loadscreen")] [HttpPut("rooms/{roomId:long}/loadscreen")] [HttpPost("roomserver/rooms/{roomId:long}/loadscreen")] @@ -838,16 +1090,66 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task BareLoadScreen(long roomId) { - var payload = await ReadJsonElementAsync("loadScreen", "LoadScreen", "value", "Value"); + var payload = await ReadPayloadAsync(); + var imageName = Value(payload, "imageName", "name", "value") ?? string.Empty; + if (string.IsNullOrWhiteSpace(imageName)) return BadRequest(new { error = "missing_image" }); + var title = RawValue(payload, "title") ?? string.Empty; + var subtitle = RawValue(payload, "subtitle") ?? string.Empty; return await ApplyAndReturn(roomId, r => { - if (payload is JsonElement p) - r.LoadScreensJson = p.ValueKind == JsonValueKind.Array - ? p.GetRawText() - : SerializeLimited(new[] { p }); + // Re-setting an existing image replaces that entry rather than + // stacking a duplicate. + var screens = ReadJsonElementList(r.LoadScreensJson) + .Where(s => !LoadScreenMatches(s, imageName)) + .ToList(); + screens.Add(JsonSerializer.SerializeToElement(new + { + ImageName = imageName, + Title = title, + Subtitle = subtitle, + })); + r.LoadScreensJson = SerializeLimited(screens); }); } + /// DELETE rooms/{id}/loadscreen — "remove room loading + /// screen" (NLDBPDCNNCF.txt:8677, verb byte 4 = DELETE at :8678), form + /// key imageName (NLDBPDCNNCF/DMOMLNEJHGE:61). Was not registered + /// at all, so removing a load screen 405'd. + [HttpDelete("rooms/{roomId:long}/loadscreen")] + [HttpDelete("roomserver/rooms/{roomId:long}/loadscreen")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareLoadScreenRemove(long roomId) + { + var imageName = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value") + ?? string.Empty; + if (string.IsNullOrWhiteSpace(imageName)) return BadRequest(new { error = "missing_image" }); + return await ApplyAndReturn(roomId, r => + { + var screens = ReadJsonElementList(r.LoadScreensJson) + .Where(s => !LoadScreenMatches(s, imageName)) + .ToList(); + r.LoadScreensJson = SerializeLimited(screens); + }); + } + + /// A stored load screen matches when its ImageName does — that + /// is the only identity the client sends on remove. + private static bool LoadScreenMatches(JsonElement item, string imageName) + { + if (item.ValueKind == JsonValueKind.String) + return string.Equals(item.GetString(), imageName, StringComparison.OrdinalIgnoreCase); + if (item.ValueKind != JsonValueKind.Object) return false; + foreach (var key in new[] { "ImageName", "imageName", "Name", "name" }) + { + if (item.TryGetProperty(key, out var prop) && + prop.ValueKind == JsonValueKind.String && + string.Equals(prop.GetString(), imageName, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + [HttpPost("rooms/{roomId:long}/promo_images")] [HttpPut("rooms/{roomId:long}/promo_images")] [HttpPost("roomserver/rooms/{roomId:long}/promo_images")] diff --git a/DorkNet.Server/Controllers/Match/MatchController.cs b/DorkNet.Server/Controllers/Match/MatchController.cs index 224009b..f651bc0 100644 --- a/DorkNet.Server/Controllers/Match/MatchController.cs +++ b/DorkNet.Server/Controllers/Match/MatchController.cs @@ -382,23 +382,49 @@ public class MatchController( [FromForm(Name = "roomCode")] string? roomCode, [FromForm(Name = "forceChange")] bool? forceChange) { - var requested = (roomCode ?? Request.Query["roomCode"].FirstOrDefault())?.Trim(); + // Same normalisation the join path applies (GoToController.GoToCode → + // RoomCodeService.Normalize), so a code typed with dashes/lowercase on + // the watch still matches what we persist. + var normalized = RoomCodeService.Normalize( + roomCode ?? Request.Query["roomCode"].FirstOrDefault()); - if (!string.IsNullOrEmpty(requested)) + if (normalized.Length > 0) { - var normalized = requested.ToUpperInvariant(); - var taken = await db.PrivateInstances - .AnyAsync(p => p.RoomCode == normalized && p.Id != instanceId); - if (taken && forceChange != true) - return Content(JsonSerializer.Serialize(RoomCodeService.Generate(instanceId)), "application/json"); + var pid = this.RequireCurrentPlayerId(); + var clash = await db.PrivateInstances + .Where(p => p.RoomCode == normalized && p.Id != instanceId) + .Select(p => new { p.Id, p.OwnerPlayerId }) + .FirstOrDefaultAsync(); + + // forceChange only lets a host reclaim a code from one of their OWN + // instances; another player's code is never stolen. On refusal the + // client just re-reads whatever code we echo back, so we fall + // through to the read path instead of erroring. + var blocked = clash is not null + && (clash.OwnerPlayerId != pid || forceChange != true); var row = await db.PrivateInstances.FirstOrDefaultAsync(p => p.Id == instanceId); - if (row is not null) + if (!blocked && row is not null) { + if (clash is not null) + { + // Release the caller's older claim first, as its own + // statement, so the two rows never hold the code at once. + await db.PrivateInstances + .Where(p => p.Id == clash.Id) + .ExecuteUpdateAsync(s => s.SetProperty(p => p.RoomCode, string.Empty)); + } row.RoomCode = normalized; await db.SaveChangesAsync(); + logger.LogInformation( + "[roomcode] player={Player} instance={Instance} code={Code} force={Force}", + pid, instanceId, normalized, forceChange == true); return Content(JsonSerializer.Serialize(normalized), "application/json"); } + + logger.LogWarning( + "[roomcode] player={Player} instance={Instance} refused code={Code} taken={Taken} known={Known}", + pid, instanceId, normalized, clash is not null, row is not null); } var stored = await db.PrivateInstances