feat: Enhance room management and moderation APIs

- Added support for multiple tag filters in HotRoomServer endpoint, allowing clients to query hot rooms with multiple tags.
- Introduced a new DeleteRoom endpoint for owners to soft-archive their rooms, ensuring room data integrity.
- Updated MagicDoor endpoint to return a structured response including refresh timing.
- Improved reporting functionality to handle form submissions correctly, ensuring room reports are persisted.
- Refactored per-field mutation endpoints to support both legacy and new client formats, accommodating changes in request structure.
- Implemented a multi-field payload reader to handle complex requests more efficiently.
- Enhanced logging for room code management to track player actions and conflicts.
This commit is contained in:
Alexa
2026-07-27 23:54:25 +03:00
parent 49df7393dc
commit 289072583e
11 changed files with 2156 additions and 336 deletions
@@ -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<CustomAvatarItemsController> logger) : ControllerBase
{
[HttpGet]
[AllowAnonymous]
@@ -47,6 +49,71 @@ public class CustomAvatarItemsController(
return Ok(result.Select(ToWire));
}
/// <summary>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 <c>multipart/form-data</c> (verb ordinal
/// rdx=2=POST at RecNet.Runtime/MPBLNLMCEDL.txt:1352-1354): a named
/// param <c>"metadata"</c> carrying JsonConvert.SerializeObject of the
/// DCKDCBDHDKI request object, then TWO file parts, <c>"thumbnailImage"</c>
/// and <c>"design"</c>, both with filename <c>"file.bin"</c>
/// (: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.</summary>
[HttpPost]
[Authorize]
[Consumes("multipart/form-data", "application/x-www-form-urlencoded", "application/json")]
public async Task<IActionResult> 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<IActionResult> Get(Guid id)
@@ -57,12 +124,101 @@ public class CustomAvatarItemsController(
return Ok(ToWire(item));
}
/// <summary>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.</summary>
[HttpPut("{id:guid}")]
[Authorize]
public async Task<IActionResult> 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));
}
/// <summary>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.
///
/// <see cref="CustomAvatarItemEntity"/> 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.</summary>
[HttpDelete("{id:guid}")]
[Authorize]
public async Task<IActionResult> 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();
}
/// <summary>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 <c>"customAvatarItemIds"</c>
/// (RecNet.Runtime/MPBLNLMCEDL_NestedType_KKEBBAAGHAM.txt:232-244, POST
/// ordinal rdx=2), which no read path here honoured, and the old
/// <c>[FromBody] BulkRequest</c> 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; <see cref="ReadIdsAsync"/>
/// now reads the key from the JSON body, the form and the query.</summary>
[HttpGet("bulk")]
[HttpPost("bulk")]
[AllowAnonymous]
public async Task<IActionResult> Bulk([FromBody] BulkRequest? body)
public async Task<IActionResult> Bulk()
{
var ids = await ReadIdsAsync(body);
var ids = await ReadIdsAsync();
if (ids.Count == 0) return Ok(Array.Empty<object>());
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");
/// <summary>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.</summary>
[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,
});
/// <summary>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.</summary>
[HttpGet("design")]
[Authorize]
public async Task<IActionResult> 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,
});
}
/// <summary>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
/// <c>"metadata"</c> plus a file part <c>"design"</c> with filename
/// <c>"file.bin"</c>
/// (RecNet.Runtime/MPBLNLMCEDL_NestedType_OPEGIAOIHMA.txt:200-227). The
/// metadata object GIOBJIGEMNG holds just two properties — a
/// Nullable&lt;Int32&gt; 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.</summary>
[HttpPut("design")]
[Authorize]
[Consumes("multipart/form-data", "application/x-www-form-urlencoded", "application/json")]
public async Task<IActionResult> 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();
}
/// <summary>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".
///
/// <see cref="IObjectStorage"/> has no delete operation, so the record is
/// tombstoned (a Deleted marker that <see cref="LoadDesignAsync"/> reads
/// as "no design") and the texture object is truncated to zero bytes so
/// the pixels really are gone.</summary>
[HttpDelete("design")]
[Authorize]
public async Task<IActionResult> DeleteDesign()
{
var pid = this.RequireCurrentPlayerId();
await SaveDesignAsync(pid, new StoredDesign { Deleted = true });
await PutBlobAsync(DesignImageBlobName(pid), Array.Empty<byte>(), "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<List<Guid>> ReadIdsAsync(BulkRequest? body)
/// <summary>Collect the bulk request's guid list. The 2023 client's key is
/// <c>"customAvatarItemIds"</c> (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.</summary>
private async Task<List<Guid>> ReadIdsAsync()
{
var result = new List<Guid>();
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<Guid> into)
{
foreach (var el in array.EnumerateArray())
if (el.ValueKind == JsonValueKind.String && Guid.TryParse(el.GetString(), out var id))
into.Add(id);
}
/// <summary>Accepted spellings of the bulk guid-list key. Case-insensitive
/// set so "customAvatarItemIds" / "CustomAvatarItemIds" both land.</summary>
private static readonly HashSet<string> IdListKeys = new(StringComparer.OrdinalIgnoreCase)
{
"customAvatarItemIds", "ids", "itemIds",
};
/// <summary>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 <c>"metadata"</c> 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.</summary>
private async Task<DesignRequest> 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());
}
/// <summary>Lenient JSON → <see cref="DesignRequest"/>. 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.</summary>
private static DesignRequest ParseDesignJson(string json)
{
var req = new DesignRequest();
if (string.IsNullOrWhiteSpace(json)) return req;
try
{
return await JsonSerializer.DeserializeAsync<DesignRequest>(
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,
};
/// <summary>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.</summary>
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);
}
/// <summary>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.</summary>
private async Task<string> 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<byte[]> ReadFileBytesAsync(IFormFile file)
{
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
return ms.ToArray();
}
/// <summary>The parts are sent with filename "file.bin" and no usable
/// content type, so sniff the magic bytes instead of trusting either.</summary>
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<StoredDesign?> 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<StoredDesign>(
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,
@@ -13,73 +13,123 @@ public class ExternalFriendInviteController(DorkNetDbContext db) : ControllerBas
{
private long Me => this.RequireCurrentPlayerId();
/// <summary>Key prefix for "player X redeemed one of player Y's invites".
/// Row layout is <c>PlayerId = the invitee</c>,
/// <c>Key = externalinvite:redeemed:{kind}:{inviterAccountId}</c>,
/// <c>Value = redemption timestamp</c>. The referrer endpoints below read
/// these rows; the invite-claim path is what writes them.</summary>
private const string RedeemedPrefix = "externalinvite:redeemed:";
/// <summary>Records an out-of-game friend invite aimed at a platform (Steam,
/// Oculus, …) account.
///
/// The client only ever sends the single form field <c>platformId</c>
/// (RecNet.Runtime/OMHDDLFIPLP.txt:876) — <c>platform</c> keeps its default
/// so the older callers that do send it still bind.</summary>
[HttpPost("api/externalfriendinvite/v1/createplatforminvite")]
public async Task<IActionResult> 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);
}
/// <summary>Records an SMS friend invite.
///
/// Form fields are <c>phoneNumber</c>, <c>friendCode</c> and
/// <c>senderName</c> (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 <c>friendCode</c> or it is lost.</summary>
[HttpPost("api/externalfriendinvite/v1/sendtextmessageinvite")]
public async Task<IActionResult> SendTextMessageInvite([FromForm] string? phoneNumber, [FromForm] string? message)
public async Task<IActionResult> 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);
}
/// <summary>Accounts who joined via this player's platform invites.
/// <summary>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
/// <c>FGLDKEJLAKB&lt;List&lt;System.Int32&gt;&gt; POJMMCCLNCB()</c>
/// (RecNet.Runtime/OMHDDLFIPLP.txt:923). Returning objects
/// (<c>{InviteCode, Kind, Value, CreatedAt}</c>) made Json.NET throw as soon
/// as any referrer existed, so the referral screen broke for exactly the
/// players who had referred someone.</summary>
/// (RecNet.Runtime/OMHDDLFIPLP.txt:923-996, body-less POST). Returning
/// objects (<c>{InviteCode, Kind, Value, CreatedAt}</c>) made the strict
/// Int32 reader throw as soon as any row existed, which faulted
/// SessionManager.CheckForAndHandlePlatformFriendOrTextMessageReferrers
/// (SessionManager.txt:31439) during the login bootstrap.</summary>
[HttpGet("api/externalfriendinvite/v1/getplatformreferrers")]
[HttpPost("api/externalfriendinvite/v1/getplatformreferrers")]
public async Task<IActionResult> GetPlatformReferrers()
=> Ok(await ReferrerIdsAsync("externalinvite:platform:"));
=> Ok(await ReferrerIdsAsync("platform"));
/// <summary>Same bare-id-array contract as
/// <see cref="GetPlatformReferrers"/>, for text-message invites.</summary>
/// <see cref="GetPlatformReferrers"/> (OMHDDLFIPLP.txt:998-1071), for
/// text-message invites.</summary>
[HttpGet("api/externalfriendinvite/v1/gettextmessagereferrers")]
[HttpPost("api/externalfriendinvite/v1/gettextmessagereferrers")]
public async Task<IActionResult> GetTextMessageReferrers()
=> Ok(await ReferrerIdsAsync("externalinvite:text:"));
=> Ok(await ReferrerIdsAsync("text"));
/// <summary>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.</summary>
private async Task<List<int>> ReferrerIdsAsync(string keyPrefix)
/// <summary>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 <c>platformId</c> used to be misread as an account id).</summary>
private async Task<List<int>> 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];
/// <summary>Both write routes await <c>FGLDKEJLAKB&lt;DJMHAFPGLLN&gt;</c> and
/// hand it to <c>BFDGEKKNAPB.ExpandRecNetResult</c>, whose lambda pops the
/// <c>Error</c> string as an error toast whenever <c>Success</c> is false
/// (BFDGEKKNAPB_NestedType___c.txt:61 &lt;ExpandRecNetResult&gt;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).</summary>
private static Dictionary<string, object?> Success() => new(StringComparer.Ordinal)
{
["Success"] = true,
["Error"] = string.Empty,
};
}
@@ -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<ActionResult> Popular([FromQuery] int take = 50)
{
@@ -55,6 +53,82 @@ public class InventionsController(
return Ok(rows.Select(ToWire));
}
/// <summary>GET <c>api/inventions/v1/toptoday</c> — 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.</summary>
[HttpGet("api/inventions/v1/toptoday")]
public async Task<ActionResult> 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<InventionEntity>()
: 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));
}
/// <summary>GET <c>api/inventions/v1/featureddormskins</c> — 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. <see cref="InventionEntity"/> has no dorm-skin column, so
/// the marker lives in <see cref="InventionEntity.TagsCsv"/> — see
/// <see cref="IsDormSkinTags"/>. The filter runs in memory because
/// TagsCsv is a packed list no provider can split.</summary>
[HttpGet("api/inventions/v1/featureddormskins")]
public async Task<ActionResult> 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));
}
/// <summary>Dorm-skin marker. There is no IsDormSkin column on
/// <see cref="InventionEntity"/>, so a tag decides: any tag that reads
/// "dormskin" / "dorm skin" (case- and space-insensitive).</summary>
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<ActionResult> Saved([FromQuery] int take = 50)
@@ -80,10 +154,20 @@ public class InventionsController(
[Authorize]
public Task<ActionResult> Mine() => Saved(100);
/// <summary>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.</summary>
[HttpGet("api/inventions/v1/search")]
[HttpGet("api/inventions/v2/search")]
public async Task<ActionResult> Search([FromQuery] string value = "")
public async Task<ActionResult> 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<object>());
@@ -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<long> 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<ActionResult> Batch([FromBody] InventionBatchRequest req)
/// <summary>Body shape for non-2023 callers, which still send a JSON
/// <c>{"InventionIds":[…]}</c> envelope rather than repeated fields.</summary>
private sealed class InventionIdsBody
{
return await BatchIdsAsync(req?.InventionIds ?? []);
public List<long>? InventionIds { get; set; }
public List<long>? Ids { get; set; }
public List<long>? Id { get; set; }
}
private static readonly System.Text.Json.JsonSerializerOptions IdsJson =
new() { PropertyNameCaseInsensitive = true };
/// <summary>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.</summary>
private async Task<List<long>> CollectIdsAsync(params string[] keys)
{
var ids = new List<long>();
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<InventionIdsBody>(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();
}
/// <summary>Read one scalar field from the query string, falling back to
/// the url-encoded form body (POST variants — see the block comment
/// above).</summary>
private async Task<string?> 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<int?> ReadIntFieldAsync(string key)
=> int.TryParse(await ReadFieldAsync(key), out var v) ? v : null;
private async Task<long?> 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<ActionResult> 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<ActionResult> Batch()
=> await BatchIdsAsync(await CollectIdsAsync("id", "ids", "InventionIds"));
return await BatchIdsAsync(ids);
/// <summary><c>api/inventions/v1/dormskinsfromids</c> — the client
/// deserialises a BARE <c>List&lt;Int64&gt;</c> here, not a list of
/// invention objects: the continuation at OEGFNFEAAGO.txt:5941 is typed
/// <c>FGLDKEJLAKB&lt;List`1&lt;Int64&gt;&gt;</c> and is then projected by a
/// <c>Func&lt;List&lt;Int64&gt;, List&lt;Int64&gt;&gt;</c> (: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.</summary>
[HttpGet("api/inventions/v1/dormskinsfromids")]
[HttpPost("api/inventions/v1/dormskinsfromids")]
public async Task<ActionResult> DormSkinsFromIds()
{
var ids = await CollectIdsAsync("ids", "id", "InventionIds");
if (ids.Count == 0) return Ok(Array.Empty<long>());
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<ActionResult> BatchIdsAsync(IReadOnlyCollection<long> requestedIds)
@@ -167,22 +351,24 @@ public class InventionsController(
}
[HttpGet("api/inventions/v1/fromcreators")]
[HttpPost("api/inventions/v1/fromcreators")]
public async Task<ActionResult> 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<object>());
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));
}
@@ -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<IActionResult> 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<KeepsakeRoomInstanceIdsDTO> (= {Int64, List<Guid>}) - dump.cs:1234798-1234810,
// ctor(List<KeepsakeRoomInstanceIdsDTO>, List<KeepsakeRoomInstanceIdsDTO>) 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<object>(),
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<IActionResult> Create()
{
var req = await ReadRequestAsync();
// 2023 client: FGLDKEJLAKB<System.Guid> 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<KeepsakeRequest>(
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<object> GroupInstanceIdsByRoom(IEnumerable<KeepsakeEntity> 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; }
@@ -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; }
}
/// <summary>POST <c>/playlists</c> — create. The 2023-03-21 client posts a
/// form with a single field <c>name</c> (verb 2 at
/// <c>NLDBPDCNNCF.txt:11139-11140</c>, field literal in
/// <c>NLDBPDCNNCF_NestedType_NEECJOENHPI.txt</c> — its only
/// <c>Move rdx, "…"</c> is <c>"name"</c>). The old non-optional
/// <c>[FromBody]</c> parameter made <c>[ApiController]</c> 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.</summary>
[HttpPost("/playlists")]
[Authorize]
public async Task<IActionResult> Create([FromBody] CreatePlaylistRequest req)
public async Task<IActionResult> 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<string>? 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;
}
/// <summary>KMKPEOGJDFK union entry with the per-playlist settings this
/// controller persists spliced back in.
/// <see cref="RoomsController.BuildPlaylistUnionEntry"/> still emits
/// hardcoded defaults for exactly the fields the client mutates here
/// (<c>Accessibility</c>=1, <c>WarningMask</c>=0,
/// <c>SupportsLevelVoting</c>=false, <c>Supports*</c>=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 —
/// <c>NLDBPDCNNCF_NestedType_{BHAHEJIEIMF,PJFBAKCHIHI,GNCDFBNCADM,AIDDFKACPLN}.txt</c>
/// — matched to the PascalCase read keys at
/// <c>MKAMHOIHOJK.txt:516-612</c>.</summary>
private static IDictionary<string, object> 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<IActionResult> ApplyMutation(long playlistId, Action<PlaylistEntity> mutator)
{
try
@@ -329,7 +360,7 @@ public class PlaylistsController(
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> FavoritedByMe()
{
var rows = await playlists.FavoritedByAsync(Me);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
return Ok(rows.Select(UnionEntry).ToList());
}
/// <summary>GET <c>/playlists/visitedby/me</c> — 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.</summary>
/// <summary>GET <c>/playlists/visitedby/me</c> — bare
/// <c>List&lt;CNIPGJIIFJF&gt;</c> (return type at
/// <c>NLDBPDCNNCF.txt:4312</c>, URL at <c>:4355</c>).
///
/// 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).</summary>
[HttpGet("/playlists/visitedby/me")]
[Authorize]
public IActionResult VisitedByMe() => Ok(Array.Empty<object>());
public async Task<IActionResult> 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<object>());
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<object>());
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 ──────────────────────────────────────────────────
/// <summary>GET <c>/playlists/bulk?id=A&amp;id=B</c> — bulk-by-id
/// lookup for the watch's playlist cache warmup. Bare list of
/// KMKPEOGJDFK entries, one per resolved id (missing ids
/// silently dropped).</summary>
/// <summary>
/// <c>playlists/bulk</c> — bulk hydration for the watch's playlist cache.
/// The client has TWO overloads on this one URL, and both pick their verb
/// at runtime:
/// <list type="bullet">
/// <item>by id — <c>NLDBPDCNNCF.txt:3408-3597</c>; repeated field
/// <c>id</c> (<c>NLDBPDCNNCF_NestedType_IOAECIJDKDD.txt:64</c>);
/// <c>Move rsi, 2</c> then <c>cmovl esi,ecx</c> against 100 at
/// <c>:3579-3581</c> — GET below 100 ids, POST at 100+.</item>
/// <item>by name — <c>NLDBPDCNNCF.txt:3624-3813</c>; repeated field
/// <c>name</c> (<c>NLDBPDCNNCF_NestedType_MOBALIODDDF.txt:64</c>); same
/// cmov against 64 at <c>:3794-3797</c> — GET below 64 names, POST at
/// 64+.</item>
/// </list>
/// 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
/// <c>List&lt;CNIPGJIIFJF&gt;</c> either way (return type at
/// <c>NLDBPDCNNCF.txt:3408</c>), missing entries silently dropped.
/// </summary>
[HttpGet("/playlists/bulk")]
[HttpPost("/playlists/bulk")]
public async Task<IActionResult> 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<object>());
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<object>());
var results = new List<PlaylistEntity>();
var seen = new HashSet<long>();
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<string, PlaylistEntity>(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);
@@ -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 <c>OperationResult</c> is the object
/// <c>{ Status, InventoryItem }</c> — see TokenResponse below.
/// </summary>
[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;
}
/// <summary>Token-purchase OperationResult (client enum CABBDKFODEC).</summary>
@@ -223,10 +233,10 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
return StatusBody(Status.Success);
}
/// <summary>POST <c>api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens</c>
/// <summary>PUT <c>api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens</c>
/// — buy one stack unit with tokens. Response is
/// <c>{ OperationResult, BalanceUpdateResult, TokenBalanceResponse:
/// { CurrencyType, Balance, Platform } }</c>.</summary>
/// <c>{ OperationResult: { Status, InventoryItem }, BalanceUpdateResult,
/// TokenBalanceResponse: { CurrencyType, Balance, Platform } }</c>.</summary>
// 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)));
}
/// <summary>POST <c>api/roomconsumables/v1/roomconsumable/{id}/purchase/currency</c>
/// — buy with a room currency. Response is <c>{ OperationResult,
/// BalanceUpdateResult, CurrencyBalanceResponse: { AccountId,
/// CurrencyId, Balance, ModifiedAt } }</c>.</summary>
/// <summary>PUT <c>api/roomconsumables/v1/roomconsumable/{id}/purchase/currency</c>
/// — buy with a room currency. Response is
/// <c>{ OperationResult: { Status, InventoryItem }, BalanceUpdateResult,
/// CurrencyBalanceResponse: { AccountId, CurrencyId, Balance,
/// ModifiedAt } }</c>.</summary>
// 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)));
}
/// <summary>POST <c>api/roomconsumables/v1/roomConsumable/{id}/consume</c>
/// <summary>PUT <c>api/roomconsumables/v1/roomConsumable/{id}/consume</c>
/// — spend one unit. Body is <c>{ CurrentConcurrencyCode,
/// NewConcurrencyCode }</c>; response <c>{ Status, InventoryItem }</c>.
/// 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<RoomConsumableEntity?> FindAsync(Guid publicId) =>
await db.RoomConsumables.FirstOrDefaultAsync(c => c.PublicId == publicId && !c.IsDeleted);
private async Task AddToInventoryAsync(long playerId, RoomConsumableEntity item, Guid? newCode)
/// <summary>Grant one unit and hand back the row, so the purchase
/// responses can embed it as <c>OperationResult.InventoryItem</c>.</summary>
private async Task<RoomConsumableOwnershipEntity> 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<bool> 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
/// <summary>Token-purchase response (client type MJKFEHPIDME, formatter
/// HJOKFJKKEJM).
///
/// <c>OperationResult</c> 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 <c>{ Status, InventoryItem }</c> 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&lt;BGHBAILNNJJ&gt;
/// (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 <c>BalanceUpdateResult</c>
/// (Nullable&lt;CABBDKFODEC&gt;, 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.</summary>
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
/// <summary>Currency-purchase response (client type DEILBLCDNEA,
/// formatter IDBFKCFEAHM). Same base type as the token response, so
/// <c>OperationResult</c> is the same <c>{ Status, InventoryItem }</c>
/// object; index 2 resolves through JECENNBIMEI&lt;BGHBAILNNJJ&gt;
/// (IDBFKCFEAHM.txt:330 key map, :1161 reader). BalanceUpdateResult is
/// Nullable&lt;GACBLALELBP&gt; — an enum with only Success=0 and
/// NotEnoughCredit=1, so every other failure has to be reported through
/// OperationResult.Status and leave this null.</summary>
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,
@@ -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) :
}));
}
/// <summary>Bulk currency award — the CircuitsV2 "award currency" chip.
///
/// The client body is a BARE JSON ARRAY, not an object: the issuing method is
/// <c>FGLDKEJLAKB&lt;List&lt;KBPKAAIDCIJ&gt;&gt; FGBCMAINCLD(List&lt;KOEIABFLEPM&gt;)</c>
/// (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 <c>BNDIAONDFFF.FJLLPHFOOJJ</c> (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 <see cref="ReadFieldsAsync"/>, 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.</summary>
[HttpPost("api/roomcurrencies/v1/awardCurrency/bulk")]
[HttpPut("api/roomcurrencies/v1/awardCurrency/bulk")]
public async Task<IActionResult> 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<object>();
foreach (var playerId in playerIds)
var currencyCache = new Dictionary<Guid, RoomCurrencyEntity?>();
var manageCache = new Dictionary<long, bool>();
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));
}
/// <summary>Both verbs are mapped on purpose: the client picks GET or POST at
/// runtime from the id count (<c>ALHIJCJOLCB.JIECAFGCODK(ids, 100)</c>,
/// MIHKAJFMIPE_NestedType_OBLJADOFIED.txt:210-212) — GET under 100 ids, POST
/// at 100+.</summary>
[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<object>(),
}));
}
[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<long> 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<Dictionary<string, string>> ReadFieldsAsync()
/// <param name="jsonBody">Pre-read request body. The body stream can only be
/// consumed once, so a caller that already had to look at the raw text
/// (<see cref="AwardCurrencyBulk"/> needs to see its ARRAY root) hands it back
/// in here instead of letting this re-read <c>Request.Body</c>.</param>
private async Task<Dictionary<string, string>> ReadFieldsAsync(string? jsonBody = null)
{
var fields = new Dictionary<string, string>(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<string?> 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();
}
/// <summary>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.</summary>
private sealed record AwardRequest(Guid TransactionId, long RecipientId, long Amount, Guid CurrencyId);
/// <summary>Parses the bare JSON array the client posts. Also tolerates an
/// <c>{"awards":[...]}</c> envelope for DorkNet's own tooling.</summary>
private static List<AwardRequest> ParseAwards(string? body)
{
var awards = new List<AwardRequest>();
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,
};
/// <summary>Several wire fields are Int64 while the backing columns are int;
/// clamp instead of failing the parse and silently storing 0.</summary>
private static int ClampToInt(long value) => (int)Math.Clamp(value, 0, int.MaxValue);
private static string? ReadString(Dictionary<string, string> fields, params string[] names)
{
foreach (var name in names)
@@ -417,15 +663,23 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
currency.UpdatedAt,
};
/// <summary>Purchase offer as the client READS it (formatter AALDCAANEMM):
/// <c>CurrencyPurchaseOfferId</c>, <c>CurrencyId</c>, <c>Name</c>,
/// <c>CurrencyAmount</c>, <c>Price</c>, <c>CurrencyType</c>,
/// <c>CreatedAt</c>, <c>ModifiedAt</c>. Legacy aliases retained as in
/// <see cref="ToWire"/>.</summary>
/// <summary>Purchase offer as the client READS it (formatter
/// AALDCAANEMM.txt:499-638): exactly <c>CurrencyPurchaseOfferId</c>,
/// <c>CurrencyId</c>, <c>Order</c>, <c>Name</c>, <c>CurrencyAmount</c>,
/// <c>Price</c>, <c>ModifiedAt</c>. <c>CurrencyType</c>/<c>CreatedAt</c> are
/// not read by the client and are kept only for DorkNet's admin UI, as are
/// the legacy aliases (see <see cref="ToWire"/>).
///
/// <c>Order</c> (Int32, sent by the client on createPurchaseOffer —
/// MIHKAJFMIPE.txt:2379-2383) has no column on
/// <c>RoomCurrencyPurchaseOfferEntity</c> 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.</summary>
private static object ToOfferWire(RoomCurrencyPurchaseOfferEntity offer, RoomCurrencyEntity currency) => new
{
CurrencyPurchaseOfferId = offer.PublicId,
CurrencyId = currency.PublicId,
Order = 0,
offer.Name,
CurrencyAmount = offer.Amount,
offer.Price,
@@ -274,6 +274,14 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
RoomKey = key is null ? null : ToWire(key),
};
/// <summary>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&lt;Guid&gt;) 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.</summary>
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();
}
/// <summary>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: <c>BNDIAONDFFF.AFGEDDANEKP</c> only appends to the
/// param dictionary at field +0x20 (BNDIAONDFFF.txt:492-506), and the send
/// path joins that dictionary with <c>"&amp;"</c> and glues it onto the URL
/// behind <c>"?"</c> (BNDIAONDFFF.txt:3196, 3238, 3248-3251). So
/// <c>POST api/roomkeys/v1/create</c> and
/// <c>PUT api/roomkeys/v1/update*</c> 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).</summary>
private async Task<T> ReadBodyAsync<T>() 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<T>(Request.Body, JsonOptions) ?? new T();
@@ -53,14 +53,48 @@ public class RoomsController(
[FromQuery] string? tags)
=> Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList());
/// <summary>GET <c>rooms/hot</c> — the room-server "hot rooms" list.
/// Response is the PAGED wrapper: the 2023 client's issuing method
/// <c>NLDBPDCNNCF.FANOAMOPHBE</c> returns <c>Task&lt;FFCPFGBNLHN&gt;</c>
/// (NLDBPDCNNCF.txt:2865 + literal at :2983) and FFCPFGBNLHN's reader
/// requires <c>Results</c> + <c>TotalResults</c>
/// (PJPBJKLDMNA.txt ISIL 040/059).</summary>
// 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<IActionResult> 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<RoomEntity> all;
if (tagFilters.Count == 0)
{
all = await rooms.HotAsync(null, take: 200);
}
else
{
var merged = new List<RoomEntity>();
var seen = new HashSet<long>();
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));
}
/// <summary>GET <c>rooms/magic_door</c> — the Magic Door destination.
/// The response is a WRAPPER, not the bare room: the client's
/// <c>MOOCMPOIIGM</c> returns <c>Task&lt;GAPJAGDNFEO&gt;</c>
/// (NLDBPDCNNCF.txt:4503) and that reader requires the keys
/// <c>RefreshesAt</c>, <c>RefreshIntervalMinutes</c> and <c>Room</c>
/// (MIFGPMGEMEK.txt ISIL 046/073/097). Returning the room object
/// directly left <c>Room</c> null and MagicDoorManager with no
/// destination. The <c>partySize</c> query param is accepted and
/// ignored — we have no party-size-aware curation.</summary>
[HttpGet("rooms/magic_door")]
[HttpGet("roomserver/rooms/magic_door")]
public async Task<IActionResult> MagicDoor()
public async Task<IActionResult> 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,
});
}
/// <summary>
@@ -263,8 +318,14 @@ public class RoomsController(
/// Unlike the <c>api/rooms/v*</c> variants (flat <c>List&lt;Room&gt;</c>),
/// this one's client contract is a PAGED wrapper object
/// <c>{ Results, TotalResults }</c> (IDLBPALJJDJ : PagedResult&lt;Room&gt;);
/// returning a bare array crashes the watch's key-based reader.</summary>
/// returning a bare array crashes the watch's key-based reader.
/// Confirmed for 2023 too: <c>NLDBPDCNNCF.MOBJJDBNBMF</c> returns
/// <c>Task&lt;FFCPFGBNLHN&gt;</c> (NLDBPDCNNCF.txt:2733) whose reader
/// requires Results/TotalResults (PJPBJKLDMNA.txt ISIL 040/059).</summary>
// 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<IActionResult> SearchRoomServer(
[FromQuery(Name = "query")] string? query,
[FromQuery(Name = "value")] string? value,
@@ -414,6 +475,46 @@ public class RoomsController(
unityAssetVersion: unityAssetVersion));
}
/// <summary>DELETE <c>rooms/{roomId}</c> — the owner's "delete room"
/// button. Client method <c>NLDBPDCNNCF.FPDBNHOHIAO</c> formats
/// <c>"rooms/{0}"</c> and moves verb ordinal 4 (= DELETE) into r8
/// (NLDBPDCNNCF.txt ISIL 021/030 around :4897); its return type is the
/// body-less promise <c>LDGADANDBIO</c> (CAOCJPMJMDB.txt:339), so the
/// response payload is never parsed.
///
/// <para>The only DELETE for <c>rooms/{id}</c> in the codebase lived on
/// AdminController, which is rooted at <c>api/admin/v1</c> — 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.</para>
///
/// <para>Soft-archive (<c>State = 1</c>) exactly like
/// <c>AdminController.DeleteRoom</c>: 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
/// <c>State == 0</c>).</para></summary>
[HttpDelete("rooms/{roomId:long}")]
[HttpDelete("roomserver/rooms/{roomId:long}")]
[Authorize]
public async Task<IActionResult> 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(
/// <summary>GET <c>api/rooms/v1/agRoomIds</c> — list of every
/// seeded AG / RR-Original room id. Used by the watch to bias the
/// room browser toward official rooms first.</summary>
// Bare array of Int64 — the 2023 issuing method GBONEKLGFFK returns
// Task<List<Int64>> (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<IActionResult> AgRoomIds() =>
Ok(await rooms.AgRoomIdsAsync());
@@ -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
}));
}
/// <summary>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.</summary>
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<IActionResult> Report([FromBody] RoomReportRequest req)
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> 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<string>? 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.
/// <summary>PUT <c>rooms/{id}/description</c> — form key
/// <c>description</c> (NLDBPDCNNCF/HBKMCDLDHHO:57).</summary>
[HttpPost("rooms/{roomId:long}/description")]
[HttpPut("rooms/{roomId:long}/description")]
public async Task<IActionResult> 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<IActionResult> 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;
});
}
/// <summary>PUT <c>rooms/{id}/image</c> — form key <c>imageName</c>
/// (NLDBPDCNNCF/MCDHHIBPJPD:57).</summary>
[HttpPost("rooms/{roomId:long}/image")]
[HttpPut("rooms/{roomId:long}/image")]
public async Task<IActionResult> 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<IActionResult> 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;
});
}
/// <summary>PUT <c>rooms/{id}/tags</c>. The 2023 client sends the tag
/// lists as REPEATED form keys — <c>autoTag</c> then <c>tag</c>
/// (NLDBPDCNNCF/FOPHALMJKGA:70 and :77, both fed from
/// <c>IReadOnlyList&lt;string&gt;</c> parameters, NLDBPDCNNCF.txt:5543) —
/// NOT a single CSV field, so the old <c>Tags</c> binding read nothing
/// and the edit was a silent no-op.</summary>
[HttpPost("rooms/{roomId:long}/tags")]
[HttpPut("rooms/{roomId:long}/tags")]
public async Task<IActionResult> 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<IActionResult> 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<Dictionary<string, List<string>>> ReadPayloadAsync()
{
var map = new Dictionary<string, List<string>>(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<string>();
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,
};
/// <summary>First NON-EMPTY value for any of <paramref name="keys"/>.</summary>
private static string? Value(Dictionary<string, List<string>> 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;
}
/// <summary>First value for any of <paramref name="keys"/> INCLUDING the
/// empty string. Text fields (description, customWarning) are cleared by
/// sending the key with an empty value, so the non-empty filter in
/// <see cref="Value"/> would turn "clear" into a no-op.</summary>
private static string? RawValue(Dictionary<string, List<string>> map, params string[] keys)
{
foreach (var key in keys)
if (map.TryGetValue(key, out var list) && list.Count > 0)
return list[0];
return null;
}
/// <summary>Every non-empty value across <paramref name="keys"/>, 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).</summary>
private static List<string> Values(Dictionary<string, List<string>> map, params string[] keys)
{
var result = new List<string>();
var seen = new HashSet<string>(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<string, List<string>> map, params string[] keys)
=> int.TryParse(Value(map, keys), out var value) ? value : null;
private static long? LongValue(Dictionary<string, List<string>> map, params string[] keys)
=> long.TryParse(Value(map, keys), out var value) ? value : null;
private static bool? BoolValue(Dictionary<string, List<string>> 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 : "[]";
}
/// <summary>PUT <c>rooms/{id}/cloning</c> — form key
/// <c>cloningAllowed</c> (NLDBPDCNNCF/ECMHAPMBNMA:67).</summary>
[HttpPost("rooms/{roomId:long}/cloning")]
[HttpPut("rooms/{roomId:long}/cloning")]
public async Task<IActionResult> 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<IActionResult> 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;
});
}
/// <summary>PUT <c>rooms/{id}/automute</c>. The form key is
/// <c>disable</c>, NOT DisableMicAutoMute
/// (NLDBPDCNNCF/MAICLJKDBOB:67, from
/// <c>ChangeRoomMicAutoMute(bool)</c>) — true means "mic auto-mute is
/// disabled", so it maps straight onto DisableMicAutoMute.</summary>
[HttpPost("rooms/{roomId:long}/automute")]
[HttpPut("rooms/{roomId:long}/automute")]
public async Task<IActionResult> 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<IActionResult> 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;
});
}
/// <summary><c>rooms/{id}/restrictions</c> — junior / age-related
/// restriction toggle. Body bool maps to AllowsJuniors (true = no
/// restriction, false = juniors restricted).</summary>
/// <summary>PUT <c>rooms/{id}/restrictions</c> — the "who can play here"
/// panel. The 2023 client sends FOUR booleans in one request:
/// <c>supportsScreens</c>, <c>supportsWalkVR</c>,
/// <c>supportsTeleportVR</c>, <c>supportsJuniors</c>
/// (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.</summary>
[HttpPost("rooms/{roomId:long}/restrictions")]
[HttpPut("rooms/{roomId:long}/restrictions")]
public async Task<IActionResult> 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<IActionResult> 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;
});
}
/// <summary>PUT <c>rooms/{id}/warning</c> — content warnings. Form keys
/// are <c>warningMask</c> (int) and <c>customWarning</c> (string)
/// (NLDBPDCNNCF/AIDDFKACPLN:80 and :89), not the RoomWarningMask /
/// CustomRoomWarning names the old [FromBody] DTO used.</summary>
[HttpPost("rooms/{roomId:long}/warning")]
[HttpPut("rooms/{roomId:long}/warning")]
public async Task<IActionResult> 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<IActionResult> 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
});
}
/// <summary>PUT <c>rooms/{id}/loadscreen</c> — form keys
/// <c>imageName</c> / <c>title</c> / <c>subtitle</c>
/// (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.</summary>
[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<IActionResult> 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);
});
}
/// <summary>DELETE <c>rooms/{id}/loadscreen</c> — "remove room loading
/// screen" (NLDBPDCNNCF.txt:8677, verb byte 4 = DELETE at :8678), form
/// key <c>imageName</c> (NLDBPDCNNCF/DMOMLNEJHGE:61). Was not registered
/// at all, so removing a load screen 405'd.</summary>
[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<IActionResult> 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);
});
}
/// <summary>A stored load screen matches when its ImageName does — that
/// is the only identity the client sends on remove.</summary>
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")]