mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
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:
@@ -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<Int32> base avatar item id at +0x10 and the "#RRGGBB"
|
||||
/// colour string at +0x18 (GIOBJIGEMNG.txt:3-43), written from the
|
||||
/// FKCJDHHJAFF(texture, color, baseItemId) args at MPBLNLMCEDL.txt:4652-4654.
|
||||
/// The client's return type is status-only (LDGADANDBIO), so a bare 200
|
||||
/// is the whole contract.</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,
|
||||
|
||||
+76
-26
@@ -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<List<System.Int32>> 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<DJMHAFPGLLN></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 <ExpandRecNetResult>b__2_0).
|
||||
/// Utf8Json leaves absent members at their default, so a response without
|
||||
/// these two keys reads as Success=false and every successful invite raised
|
||||
/// an empty error toast. Key names are literal from the generated formatter
|
||||
/// (LMCCNLLHLCJ.txt:191 "Success", :210 "Error"; it also accepts the
|
||||
/// camelCase aliases at :202/:218, and skips members it does not know, so
|
||||
/// the per-route extras below are inert).</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<Int64></c> here, not a list of
|
||||
/// invention objects: the continuation at OEGFNFEAAGO.txt:5941 is typed
|
||||
/// <c>FGLDKEJLAKB<List`1<Int64>></c> and is then projected by a
|
||||
/// <c>Func<List<Int64>, List<Int64>></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<CNIPGJIIFJF></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&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<CNIPGJIIFJF></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<BGHBAILNNJJ>
|
||||
/// (HJOKFJKKEJM.txt:1104). A bare int there made the generated reader
|
||||
/// hit a number where it verifies '{', faulting the whole request with
|
||||
/// "Failed to purchase room consumable" (DCFKEFHJAGC.txt:8294) — so
|
||||
/// purchases were dead even once the PUT verb was accepted.
|
||||
///
|
||||
/// The scalar operation enum belongs in <c>BalanceUpdateResult</c>
|
||||
/// (Nullable<CABBDKFODEC>, index 0) — that is what the client turns
|
||||
/// into the "Not enough …" / "The price for this … has changed" toast
|
||||
/// (DCFKEFHJAGC.txt:12735-12760). It is null when the balance was never
|
||||
/// touched.</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<BGHBAILNNJJ>
|
||||
/// (IDBFKCFEAHM.txt:330 key map, :1161 reader). BalanceUpdateResult is
|
||||
/// Nullable<GACBLALELBP> — an enum with only Success=0 and
|
||||
/// NotEnoughCredit=1, so every other failure has to be reported through
|
||||
/// OperationResult.Status and leave this null.</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<List<KBPKAAIDCIJ>> FGBCMAINCLD(List<KOEIABFLEPM>)</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<Guid>) and ImageName have no
|
||||
/// column on RoomKeyEntity yet, so they are still omitted rather than
|
||||
/// emitted as permanent nulls — both are absent-tolerant on the client.</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>"&"</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<FFCPFGBNLHN></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<GAPJAGDNFEO></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<Room></c>),
|
||||
/// this one's client contract is a PAGED wrapper object
|
||||
/// <c>{ Results, TotalResults }</c> (IDLBPALJJDJ : PagedResult<Room>);
|
||||
/// 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<FFCPFGBNLHN></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<string></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")]
|
||||
|
||||
@@ -382,23 +382,49 @@ public class MatchController(
|
||||
[FromForm(Name = "roomCode")] string? roomCode,
|
||||
[FromForm(Name = "forceChange")] bool? forceChange)
|
||||
{
|
||||
var requested = (roomCode ?? Request.Query["roomCode"].FirstOrDefault())?.Trim();
|
||||
// Same normalisation the join path applies (GoToController.GoToCode →
|
||||
// RoomCodeService.Normalize), so a code typed with dashes/lowercase on
|
||||
// the watch still matches what we persist.
|
||||
var normalized = RoomCodeService.Normalize(
|
||||
roomCode ?? Request.Query["roomCode"].FirstOrDefault());
|
||||
|
||||
if (!string.IsNullOrEmpty(requested))
|
||||
if (normalized.Length > 0)
|
||||
{
|
||||
var normalized = requested.ToUpperInvariant();
|
||||
var taken = await db.PrivateInstances
|
||||
.AnyAsync(p => p.RoomCode == normalized && p.Id != instanceId);
|
||||
if (taken && forceChange != true)
|
||||
return Content(JsonSerializer.Serialize(RoomCodeService.Generate(instanceId)), "application/json");
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var clash = await db.PrivateInstances
|
||||
.Where(p => p.RoomCode == normalized && p.Id != instanceId)
|
||||
.Select(p => new { p.Id, p.OwnerPlayerId })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
// forceChange only lets a host reclaim a code from one of their OWN
|
||||
// instances; another player's code is never stolen. On refusal the
|
||||
// client just re-reads whatever code we echo back, so we fall
|
||||
// through to the read path instead of erroring.
|
||||
var blocked = clash is not null
|
||||
&& (clash.OwnerPlayerId != pid || forceChange != true);
|
||||
|
||||
var row = await db.PrivateInstances.FirstOrDefaultAsync(p => p.Id == instanceId);
|
||||
if (row is not null)
|
||||
if (!blocked && row is not null)
|
||||
{
|
||||
if (clash is not null)
|
||||
{
|
||||
// Release the caller's older claim first, as its own
|
||||
// statement, so the two rows never hold the code at once.
|
||||
await db.PrivateInstances
|
||||
.Where(p => p.Id == clash.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(p => p.RoomCode, string.Empty));
|
||||
}
|
||||
row.RoomCode = normalized;
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation(
|
||||
"[roomcode] player={Player} instance={Instance} code={Code} force={Force}",
|
||||
pid, instanceId, normalized, forceChange == true);
|
||||
return Content(JsonSerializer.Serialize(normalized), "application/json");
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"[roomcode] player={Player} instance={Instance} refused code={Code} taken={Taken} known={Known}",
|
||||
pid, instanceId, normalized, clash is not null, row is not null);
|
||||
}
|
||||
|
||||
var stored = await db.PrivateInstances
|
||||
|
||||
Reference in New Issue
Block a user