mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-27 07:58:45 -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(
|
public class CustomAvatarItemsController(
|
||||||
DorkNetDbContext db,
|
DorkNetDbContext db,
|
||||||
LevelService level,
|
LevelService level,
|
||||||
DomainConfig domain) : ControllerBase
|
DomainConfig domain,
|
||||||
|
IObjectStorage storage,
|
||||||
|
ILogger<CustomAvatarItemsController> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
@@ -47,6 +49,71 @@ public class CustomAvatarItemsController(
|
|||||||
return Ok(result.Select(ToWire));
|
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}")]
|
[HttpGet("{id:guid}")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> Get(Guid id)
|
public async Task<IActionResult> Get(Guid id)
|
||||||
@@ -57,12 +124,101 @@ public class CustomAvatarItemsController(
|
|||||||
return Ok(ToWire(item));
|
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")]
|
[HttpGet("bulk")]
|
||||||
[HttpPost("bulk")]
|
[HttpPost("bulk")]
|
||||||
[AllowAnonymous]
|
[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>());
|
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||||
var rows = await db.CustomAvatarItems
|
var rows = await db.CustomAvatarItems
|
||||||
.Where(i => ids.Contains(i.PublicId) && (i.IsPublic || i.CreatorPlayerId == this.CurrentPlayerId()))
|
.Where(i => ids.Contains(i.PublicId) && (i.IsPublic || i.CreatorPlayerId == this.CurrentPlayerId()))
|
||||||
@@ -222,9 +378,126 @@ public class CustomAvatarItemsController(
|
|||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public IActionResult IsRenderingEnabled() => Content("true", "application/json");
|
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")]
|
[HttpGet("minPriceForPublicItem")]
|
||||||
[AllowAnonymous]
|
[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")]
|
[HttpPost("design")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
@@ -287,6 +560,16 @@ public class CustomAvatarItemsController(
|
|||||||
if (await db.CustomAvatarItemOwnership.AnyAsync(o => o.PlayerId == pid && o.CustomAvatarItemId == item.Id))
|
if (await db.CustomAvatarItemOwnership.AnyAsync(o => o.PlayerId == pid && o.CustomAvatarItemId == item.Id))
|
||||||
return Ok(new { Success = true, AlreadyOwned = true, Item = ToWire(item) });
|
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);
|
var balance = await level.GetBalanceAsync(pid, 2);
|
||||||
if (balance < item.Price)
|
if (balance < item.Price)
|
||||||
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance });
|
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance });
|
||||||
@@ -315,64 +598,311 @@ public class CustomAvatarItemsController(
|
|||||||
await db.SaveChangesAsync();
|
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>();
|
var result = new List<Guid>();
|
||||||
if (body?.Ids is { Count: > 0 })
|
void AddCsv(string? value)
|
||||||
result.AddRange(body.Ids);
|
{
|
||||||
if (body?.ItemIds is { Count: > 0 })
|
foreach (var part in (value ?? string.Empty)
|
||||||
result.AddRange(body.ItemIds);
|
.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 pair in Request.Query)
|
||||||
foreach (var value in pair.Value)
|
foreach (var value in pair.Value)
|
||||||
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
AddCsv(value);
|
||||||
if (Guid.TryParse(part, out var id)) result.Add(id);
|
|
||||||
|
|
||||||
if (Request.HasFormContentType)
|
if (Request.HasFormContentType)
|
||||||
{
|
{
|
||||||
var form = await Request.ReadFormAsync();
|
var form = await Request.ReadFormAsync();
|
||||||
foreach (var key in new[] { "ids", "Ids", "itemIds", "ItemIds" })
|
foreach (var field in form)
|
||||||
foreach (var value in form[key])
|
{
|
||||||
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
if (!IdListKeys.Contains(field.Key)) continue;
|
||||||
if (Guid.TryParse(part, out var id)) result.Add(id);
|
foreach (var value in field.Value) AddCsv(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.Distinct().Take(200).ToList();
|
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()
|
private async Task<DesignRequest> ReadDesignRequestAsync()
|
||||||
{
|
{
|
||||||
if (Request.HasFormContentType)
|
if (Request.HasFormContentType)
|
||||||
{
|
{
|
||||||
var form = await Request.ReadFormAsync();
|
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
|
return new DesignRequest
|
||||||
{
|
{
|
||||||
Id = Guid.TryParse(form["id"].FirstOrDefault() ?? form["Id"].FirstOrDefault(), out var id) ? id : null,
|
Id = Guid.TryParse(Field("id", "Id"), out var id) ? id : null,
|
||||||
Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(),
|
Name = Field("name", "Name"),
|
||||||
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
|
Description = Field("description", "Description"),
|
||||||
Price = int.TryParse(form["price"].FirstOrDefault() ?? form["Price"].FirstOrDefault(), out var price) ? price : null,
|
Price = int.TryParse(Field("price", "Price"), out var price) ? price : null,
|
||||||
ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var itemType) ? itemType : null,
|
ItemType = int.TryParse(Field("itemType", "ItemType", "permission", "Permission"), out var itemType) ? itemType : null,
|
||||||
BaseAvatarItemId = int.TryParse(form["baseAvatarItemId"].FirstOrDefault() ?? form["BaseAvatarItemId"].FirstOrDefault(), out var baseId) ? baseId : null,
|
BaseAvatarItemId = int.TryParse(Field("baseAvatarItemId", "BaseAvatarItemId"), out var baseId) ? baseId : null,
|
||||||
Color = form["color"].FirstOrDefault() ?? form["Color"].FirstOrDefault(),
|
Color = Field("color", "Color"),
|
||||||
ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(),
|
ImageName = Field("imageName", "ImageName"),
|
||||||
AssetName = form["assetName"].FirstOrDefault() ?? form["AssetName"].FirstOrDefault(),
|
AssetName = Field("assetName", "AssetName"),
|
||||||
IsPublic = bool.TryParse(form["isPublic"].FirstOrDefault() ?? form["IsPublic"].FirstOrDefault(), out var pub) ? pub : null,
|
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
|
try
|
||||||
{
|
{
|
||||||
return await JsonSerializer.DeserializeAsync<DesignRequest>(
|
using var doc = JsonDocument.Parse(json);
|
||||||
Request.Body,
|
if (doc.RootElement.ValueKind != JsonValueKind.Object) return req;
|
||||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||||
?? new DesignRequest();
|
{
|
||||||
|
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)
|
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
|
private object ToWire(CustomAvatarItemEntity item) => new
|
||||||
{
|
{
|
||||||
Id = item.PublicId,
|
Id = item.PublicId,
|
||||||
|
|||||||
+76
-26
@@ -13,73 +13,123 @@ public class ExternalFriendInviteController(DorkNetDbContext db) : ControllerBas
|
|||||||
{
|
{
|
||||||
private long Me => this.RequireCurrentPlayerId();
|
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")]
|
[HttpPost("api/externalfriendinvite/v1/createplatforminvite")]
|
||||||
public async Task<IActionResult> CreatePlatformInvite([FromForm] int platform = 0, [FromForm] string? platformId = null)
|
public async Task<IActionResult> CreatePlatformInvite([FromForm] int platform = 0, [FromForm] string? platformId = null)
|
||||||
{
|
{
|
||||||
var code = Guid.NewGuid().ToString("N")[..10];
|
var code = NewInviteCode();
|
||||||
var key = $"externalinvite:platform:{code}";
|
|
||||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||||
{
|
{
|
||||||
PlayerId = Me,
|
PlayerId = Me,
|
||||||
Key = key,
|
Key = $"externalinvite:platform:{code}",
|
||||||
Value = $"{platform}|{platformId ?? string.Empty}|{DateTime.UtcNow:O}",
|
Value = $"{platform}|{platformId ?? string.Empty}|{DateTime.UtcNow:O}",
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
var url = $"{Request.Scheme}://{Request.Host}/invite/{code}";
|
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")]
|
[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
|
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||||
{
|
{
|
||||||
PlayerId = Me,
|
PlayerId = Me,
|
||||||
Key = $"externalinvite:text:{code}",
|
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();
|
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
|
/// The response is a bare array of account ids — the issuing method is
|
||||||
/// <c>FGLDKEJLAKB<List<System.Int32>> POJMMCCLNCB()</c>
|
/// <c>FGLDKEJLAKB<List<System.Int32>> POJMMCCLNCB()</c>
|
||||||
/// (RecNet.Runtime/OMHDDLFIPLP.txt:923). Returning objects
|
/// (RecNet.Runtime/OMHDDLFIPLP.txt:923-996, body-less POST). Returning
|
||||||
/// (<c>{InviteCode, Kind, Value, CreatedAt}</c>) made Json.NET throw as soon
|
/// objects (<c>{InviteCode, Kind, Value, CreatedAt}</c>) made the strict
|
||||||
/// as any referrer existed, so the referral screen broke for exactly the
|
/// Int32 reader throw as soon as any row existed, which faulted
|
||||||
/// players who had referred someone.</summary>
|
/// SessionManager.CheckForAndHandlePlatformFriendOrTextMessageReferrers
|
||||||
|
/// (SessionManager.txt:31439) during the login bootstrap.</summary>
|
||||||
[HttpGet("api/externalfriendinvite/v1/getplatformreferrers")]
|
[HttpGet("api/externalfriendinvite/v1/getplatformreferrers")]
|
||||||
[HttpPost("api/externalfriendinvite/v1/getplatformreferrers")]
|
[HttpPost("api/externalfriendinvite/v1/getplatformreferrers")]
|
||||||
public async Task<IActionResult> GetPlatformReferrers()
|
public async Task<IActionResult> GetPlatformReferrers()
|
||||||
=> Ok(await ReferrerIdsAsync("externalinvite:platform:"));
|
=> Ok(await ReferrerIdsAsync("platform"));
|
||||||
|
|
||||||
/// <summary>Same bare-id-array contract as
|
/// <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")]
|
[HttpGet("api/externalfriendinvite/v1/gettextmessagereferrers")]
|
||||||
[HttpPost("api/externalfriendinvite/v1/gettextmessagereferrers")]
|
[HttpPost("api/externalfriendinvite/v1/gettextmessagereferrers")]
|
||||||
public async Task<IActionResult> 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
|
/// <summary>Account ids recorded as having referred the caller with an
|
||||||
/// kind. The redeeming account id is stored as the second pipe-delimited
|
/// invite of the given kind. The inviter id is the trailing key segment of
|
||||||
/// field once an invite is claimed; unclaimed invites contribute nothing.</summary>
|
/// the redemption row, so no id can leak in from an invite's own payload
|
||||||
private async Task<List<int>> ReferrerIdsAsync(string keyPrefix)
|
/// (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
|
var prefix = RedeemedPrefix + kind + ":";
|
||||||
.Where(s => s.PlayerId == Me && s.Key.StartsWith(keyPrefix))
|
var keys = await db.PlayerSettings
|
||||||
|
.Where(s => s.PlayerId == Me && s.Key.StartsWith(prefix))
|
||||||
.OrderByDescending(s => s.Id)
|
.OrderByDescending(s => s.Id)
|
||||||
.Select(s => s.Value)
|
.Select(s => s.Key)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return rows
|
return keys
|
||||||
.Select(v => v.Split('|'))
|
.Select(k => int.TryParse(k[prefix.Length..], out var id) ? id : 0)
|
||||||
.Select(parts => int.TryParse(parts.ElementAtOrDefault(1), out var id) ? id : 0)
|
|
||||||
.Where(id => id > 0)
|
.Where(id => id > 0)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.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 ───────────────────────────────────────────────────────────
|
// ── Browse ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[HttpGet("api/inventions/v1/featured")]
|
[HttpGet("api/inventions/v1/featured")]
|
||||||
[HttpGet("api/inventions/v1/featureddormskins")]
|
|
||||||
[HttpGet("api/inventions/v1/toptoday")]
|
|
||||||
[HttpGet("api/inventions/v3/popular")]
|
[HttpGet("api/inventions/v3/popular")]
|
||||||
public async Task<ActionResult> Popular([FromQuery] int take = 50)
|
public async Task<ActionResult> Popular([FromQuery] int take = 50)
|
||||||
{
|
{
|
||||||
@@ -55,6 +53,82 @@ public class InventionsController(
|
|||||||
return Ok(rows.Select(ToWire));
|
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")]
|
[HttpGet("api/inventions/v3/saved")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult> Saved([FromQuery] int take = 50)
|
public async Task<ActionResult> Saved([FromQuery] int take = 50)
|
||||||
@@ -80,10 +154,20 @@ public class InventionsController(
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public Task<ActionResult> Mine() => Saved(100);
|
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/v1/search")]
|
||||||
[HttpGet("api/inventions/v2/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))
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
return Ok(Array.Empty<object>());
|
return Ok(Array.Empty<object>());
|
||||||
|
|
||||||
@@ -112,7 +196,8 @@ public class InventionsController(
|
|||||||
.Where(i => !i.IsDeleted && i.IsPublished
|
.Where(i => !i.IsDeleted && i.IsPublished
|
||||||
&& creators.Contains(i.CreatorPlayerId))
|
&& creators.Contains(i.CreatorPlayerId))
|
||||||
.OrderByDescending(i => i.CheerCount)
|
.OrderByDescending(i => i.CheerCount)
|
||||||
.Take(50)
|
.Skip(skip)
|
||||||
|
.Take(take)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
return Ok(byCreator.Select(ToWire));
|
return Ok(byCreator.Select(ToWire));
|
||||||
}
|
}
|
||||||
@@ -123,36 +208,135 @@ public class InventionsController(
|
|||||||
&& (i.Name.ToLower().Contains(v)
|
&& (i.Name.ToLower().Contains(v)
|
||||||
|| i.TagsCsv.ToLower().Contains(v)))
|
|| i.TagsCsv.ToLower().Contains(v)))
|
||||||
.OrderByDescending(i => i.CheerCount)
|
.OrderByDescending(i => i.CheerCount)
|
||||||
.Take(50)
|
.Skip(skip)
|
||||||
|
.Take(take)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
return Ok(rows.Select(ToWire));
|
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")]
|
/// <summary>Body shape for non-2023 callers, which still send a JSON
|
||||||
[HttpPost("api/inventions/v2/batch")]
|
/// <c>{"InventionIds":[…]}</c> envelope rather than repeated fields.</summary>
|
||||||
[HttpPost("api/inventions/v1/dormskinsfromids")]
|
private sealed class InventionIdsBody
|
||||||
public async Task<ActionResult> Batch([FromBody] InventionBatchRequest req)
|
|
||||||
{
|
{
|
||||||
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/v1/batch")]
|
||||||
[HttpGet("api/inventions/v2/batch")]
|
[HttpGet("api/inventions/v2/batch")]
|
||||||
[HttpGet("api/inventions/v1/dormskinsfromids")]
|
[HttpPost("api/inventions/v1/batch")]
|
||||||
public async Task<ActionResult> BatchFromQuery()
|
[HttpPost("api/inventions/v2/batch")]
|
||||||
{
|
public async Task<ActionResult> Batch()
|
||||||
var ids = Request.Query
|
=> await BatchIdsAsync(await CollectIdsAsync("id", "ids", "InventionIds"));
|
||||||
.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();
|
|
||||||
|
|
||||||
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)
|
private async Task<ActionResult> BatchIdsAsync(IReadOnlyCollection<long> requestedIds)
|
||||||
@@ -167,22 +351,24 @@ public class InventionsController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("api/inventions/v1/fromcreators")]
|
[HttpGet("api/inventions/v1/fromcreators")]
|
||||||
|
[HttpPost("api/inventions/v1/fromcreators")]
|
||||||
public async Task<ActionResult> FromCreators()
|
public async Task<ActionResult> FromCreators()
|
||||||
{
|
{
|
||||||
var creatorIds = Request.Query
|
// Only the literal "id" key carries creator ids. The previous
|
||||||
.SelectMany(q => q.Value)
|
// SelectMany-over-every-query-field also swallowed the client's
|
||||||
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
// "skip"/"take" pairs, so take=100 quietly added player 100's
|
||||||
.Select(v => long.TryParse(v, out var id) ? id : 0L)
|
// inventions to the shelf.
|
||||||
.Where(id => id > 0)
|
var creatorIds = await CollectIdsAsync("id");
|
||||||
.Distinct()
|
|
||||||
.Take(200)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (creatorIds.Count == 0) return Ok(Array.Empty<object>());
|
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
|
var rows = await db.Inventions
|
||||||
.Where(i => !i.IsDeleted && i.IsPublished && creatorIds.Contains(i.CreatorPlayerId))
|
.Where(i => !i.IsDeleted && i.IsPublished && creatorIds.Contains(i.CreatorPlayerId))
|
||||||
.OrderByDescending(i => i.UpdatedAt)
|
.OrderByDescending(i => i.UpdatedAt)
|
||||||
.Take(200)
|
.Skip(skip)
|
||||||
|
.Take(take)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
return Ok(rows.Select(ToWire));
|
return Ok(rows.Select(ToWire));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Globalization;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -59,10 +60,11 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
|
|||||||
[HttpGet("events/{eventId:long}")]
|
[HttpGet("events/{eventId:long}")]
|
||||||
public async Task<IActionResult> EventInstances(long eventId)
|
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
|
var rows = await db.Keepsakes
|
||||||
.Where(k => k.PlayerId == Me
|
.Where(k => k.Category == "event"
|
||||||
&& k.Category == "event"
|
|
||||||
&& (k.EventKey == eventKey
|
&& (k.EventKey == eventKey
|
||||||
|| k.EventKey == $"event:{eventKey}"
|
|| k.EventKey == $"event:{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)
|
.OrderByDescending(k => k.EarnedAt)
|
||||||
.ToListAsync();
|
.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
|
return Ok(new
|
||||||
{
|
{
|
||||||
KeepsakeProgressionEventId = eventId,
|
Instances = GroupInstanceIdsByRoom(rows),
|
||||||
Instances = rows.Select(ToWire),
|
CollectionRecords = GroupInstanceIdsByRoom(rows.Where(r => r.PlayerId == Me)),
|
||||||
CollectionRecords = Array.Empty<object>(),
|
|
||||||
KeepsakeProgressionEventIds = new[] { eventId },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +126,31 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
|
|||||||
public async Task<IActionResult> Create()
|
public async Task<IActionResult> Create()
|
||||||
{
|
{
|
||||||
var req = await ReadRequestAsync();
|
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);
|
var key = Trim(req.EventKey, 128);
|
||||||
if (key.Length == 0) key = $"manual:{Guid.NewGuid():N}";
|
if (key.Length == 0) key = $"manual:{Guid.NewGuid():N}";
|
||||||
var category = Trim(req.Category, 64);
|
var category = Trim(req.Category, 64);
|
||||||
@@ -167,6 +200,9 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
|
|||||||
var form = await Request.ReadFormAsync();
|
var form = await Request.ReadFormAsync();
|
||||||
return new KeepsakeRequest
|
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(),
|
Category = form["category"].FirstOrDefault() ?? form["Category"].FirstOrDefault(),
|
||||||
EventKey = form["eventKey"].FirstOrDefault() ?? form["EventKey"].FirstOrDefault(),
|
EventKey = form["eventKey"].FirstOrDefault() ?? form["EventKey"].FirstOrDefault(),
|
||||||
Title = form["title"].FirstOrDefault() ?? form["Title"].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
|
try
|
||||||
{
|
{
|
||||||
return await JsonSerializer.DeserializeAsync<KeepsakeRequest>(
|
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||||
Request.Body,
|
var root = doc.RootElement;
|
||||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
return new KeepsakeRequest
|
||||||
?? 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)
|
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
|
private object ToWire(KeepsakeEntity row) => new
|
||||||
{
|
{
|
||||||
row.Id,
|
row.Id,
|
||||||
@@ -207,16 +292,67 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
|
|||||||
var placedByAccountId = row.PlayerId is > 0 and <= int.MaxValue
|
var placedByAccountId = row.PlayerId is > 0 and <= int.MaxValue
|
||||||
? (int)row.PlayerId
|
? (int)row.PlayerId
|
||||||
: 0;
|
: 0;
|
||||||
|
var parsed = TryParseInstanceKey(row.EventKey, out var keyRoomId, out var subRoomId, out var categoryConfigId, out var instanceId);
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
KeepsakeInstanceId = StableKeepsakeInstanceId(row.Id).ToString("D"),
|
KeepsakeInstanceId = (parsed ? instanceId : StableKeepsakeInstanceId(row.Id)).ToString("D"),
|
||||||
KeepsakeCategoryConfigId = CategoryId(row.Category),
|
// 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,
|
PlacedByAccountId = placedByAccountId,
|
||||||
RoomId = roomId,
|
RoomId = parsed ? keyRoomId : roomId,
|
||||||
SubRoomId = (long?)null,
|
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) =>
|
private static int CategoryId(string? category) =>
|
||||||
string.Equals(category, "event", StringComparison.OrdinalIgnoreCase) ? 1 :
|
string.Equals(category, "event", StringComparison.OrdinalIgnoreCase) ? 1 :
|
||||||
string.Equals(category, "room", StringComparison.OrdinalIgnoreCase) ? 2 :
|
string.Equals(category, "room", StringComparison.OrdinalIgnoreCase) ? 2 :
|
||||||
@@ -236,6 +372,11 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
|
|||||||
|
|
||||||
public sealed class KeepsakeRequest
|
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? Category { get; set; }
|
||||||
public string? EventKey { get; set; }
|
public string? EventKey { get; set; }
|
||||||
public string? Title { get; set; }
|
public string? Title { get; set; }
|
||||||
|
|||||||
@@ -37,20 +37,30 @@ public class PlaylistsController(
|
|||||||
|
|
||||||
// ── Create / delete ──────────────────────────────────────────────
|
// ── Create / delete ──────────────────────────────────────────────
|
||||||
|
|
||||||
public sealed class CreatePlaylistRequest
|
/// <summary>POST <c>/playlists</c> — create. The 2023-03-21 client posts a
|
||||||
{
|
/// form with a single field <c>name</c> (verb 2 at
|
||||||
public string? Name { get; set; }
|
/// <c>NLDBPDCNNCF.txt:11139-11140</c>, field literal in
|
||||||
public string? Description { get; set; }
|
/// <c>NLDBPDCNNCF_NestedType_NEECJOENHPI.txt</c> — its only
|
||||||
public string? ImageName { get; set; }
|
/// <c>Move rdx, "…"</c> is <c>"name"</c>). The old non-optional
|
||||||
public string? Tags { get; set; }
|
/// <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")]
|
[HttpPost("/playlists")]
|
||||||
[Authorize]
|
[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 name = await ReadFieldAsync("name", "Name");
|
||||||
var created = await playlists.CreateAsync(Me, req.Name, req.Description, req.ImageName, req.Tags);
|
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);
|
return await BuildDetailsResponseAsync(created.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,11 +78,6 @@ public class PlaylistsController(
|
|||||||
|
|
||||||
// ── Per-field mutations (each returns BMFAGMFKODA) ───────────────
|
// ── 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
|
// The 2023-03-21 client sends these as form-urlencoded PUTs. A
|
||||||
// non-optional [FromBody] parameter made [ApiController] demand JSON and
|
// non-optional [FromBody] parameter made [ApiController] demand JSON and
|
||||||
// reject every one with 415 before the action ran, so renaming, re-imaging,
|
// reject every one with 415 before the action ran, so renaming, re-imaging,
|
||||||
@@ -248,6 +253,32 @@ public class PlaylistsController(
|
|||||||
return found;
|
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)
|
private async Task<IActionResult> ApplyMutation(long playlistId, Action<PlaylistEntity> mutator)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -329,7 +360,7 @@ public class PlaylistsController(
|
|||||||
public async Task<IActionResult> CreatedByMe()
|
public async Task<IActionResult> CreatedByMe()
|
||||||
{
|
{
|
||||||
var rows = await playlists.CreatedByAsync(Me);
|
var rows = await playlists.CreatedByAsync(Me);
|
||||||
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
|
return Ok(rows.Select(UnionEntry).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("/playlists/cheeredby/me")]
|
[HttpGet("/playlists/cheeredby/me")]
|
||||||
@@ -337,7 +368,7 @@ public class PlaylistsController(
|
|||||||
public async Task<IActionResult> CheeredByMe()
|
public async Task<IActionResult> CheeredByMe()
|
||||||
{
|
{
|
||||||
var rows = await playlists.CheeredByAsync(Me);
|
var rows = await playlists.CheeredByAsync(Me);
|
||||||
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
|
return Ok(rows.Select(UnionEntry).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("/playlists/favoritedby/me")]
|
[HttpGet("/playlists/favoritedby/me")]
|
||||||
@@ -345,36 +376,122 @@ public class PlaylistsController(
|
|||||||
public async Task<IActionResult> FavoritedByMe()
|
public async Task<IActionResult> FavoritedByMe()
|
||||||
{
|
{
|
||||||
var rows = await playlists.FavoritedByAsync(Me);
|
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
|
/// <summary>GET <c>/playlists/visitedby/me</c> — bare
|
||||||
/// per-player playlist visits yet (visits are tracked at the Room
|
/// <c>List<CNIPGJIIFJF></c> (return type at
|
||||||
/// level via RoomVisitEntity). Return empty so the watch's
|
/// <c>NLDBPDCNNCF.txt:4312</c>, URL at <c>:4355</c>).
|
||||||
/// playlists tab renders cleanly; populated once we add a
|
///
|
||||||
/// PlaylistVisitEntity or derive from member-room visits.</summary>
|
/// 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")]
|
[HttpGet("/playlists/visitedby/me")]
|
||||||
[Authorize]
|
[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 ──────────────────────────────────────────────────
|
// ── Bulk lookup ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>GET <c>/playlists/bulk?id=A&id=B</c> — bulk-by-id
|
/// <summary>
|
||||||
/// lookup for the watch's playlist cache warmup. Bare list of
|
/// <c>playlists/bulk</c> — bulk hydration for the watch's playlist cache.
|
||||||
/// KMKPEOGJDFK entries, one per resolved id (missing ids
|
/// The client has TWO overloads on this one URL, and both pick their verb
|
||||||
/// silently dropped).</summary>
|
/// 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")]
|
[HttpGet("/playlists/bulk")]
|
||||||
|
[HttpPost("/playlists/bulk")]
|
||||||
public async Task<IActionResult> Bulk()
|
public async Task<IActionResult> Bulk()
|
||||||
{
|
{
|
||||||
var ids = Request.Query["id"].Concat(Request.Query["ids"])
|
var ids = (await ReadFieldsAsync("id", "ids", "Id", "Ids"))
|
||||||
.SelectMany(v => (v ?? string.Empty).Split(',',
|
.SelectMany(v => v.Split(',',
|
||||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
.Select(s => long.TryParse(s, out var n) ? n : 0)
|
.Select(s => long.TryParse(s, out var n) ? n : 0)
|
||||||
.Where(n => n != 0)
|
.Where(n => n != 0)
|
||||||
.ToList();
|
.ToList();
|
||||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
|
||||||
var rows = await playlists.BulkAsync(ids);
|
// Names are NOT comma-split — the client repeats the field once per
|
||||||
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
|
// 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) ─────────────────
|
// ── Detail-response builder (BMFAGMFKODA shape) ─────────────────
|
||||||
@@ -420,7 +537,7 @@ public class PlaylistsController(
|
|||||||
.Select(t => (object)new { Type = 0, Tag = t })
|
.Select(t => (object)new { Type = 0, Tag = t })
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var result = RoomsController.BuildPlaylistUnionEntry(p);
|
var result = UnionEntry(p);
|
||||||
result["Rooms"] = roomsWire;
|
result["Rooms"] = roomsWire;
|
||||||
result["Tags"] = tagsWire;
|
result["Tags"] = tagsWire;
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
|
|||||||
@@ -31,8 +31,13 @@ namespace DorkNet.Server.Controllers.API.RoomConsumables;
|
|||||||
/// Purchase requests carry the client-generated inventory concurrency
|
/// Purchase requests carry the client-generated inventory concurrency
|
||||||
/// token: { ConcurrencyCodes: { CurrentConcurrencyCode,
|
/// token: { ConcurrencyCodes: { CurrentConcurrencyCode,
|
||||||
/// NewConcurrencyCode }, ExpectedPriceAndCurrency: { Price, CurrencyId } }.
|
/// NewConcurrencyCode }, ExpectedPriceAndCurrency: { Price, CurrencyId } }.
|
||||||
/// The purchase response has no inventory item — the client applies
|
/// The server must store NewConcurrencyCode verbatim, because the client
|
||||||
/// NewConcurrencyCode locally, so the server must store it verbatim.
|
/// 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>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
public class RoomConsumablesController(DorkNetDbContext db, LevelService level) : ControllerBase
|
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 DescriptionTooLong = 27;
|
||||||
public const int ConcurrencyCodeMismatch = 32;
|
public const int ConcurrencyCodeMismatch = 32;
|
||||||
public const int PlayerDoesNotOwnConsumable = 33;
|
public const int PlayerDoesNotOwnConsumable = 33;
|
||||||
|
public const int PurchaseFailed = 35;
|
||||||
public const int RoomNotFound = 36;
|
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>
|
/// <summary>Token-purchase OperationResult (client enum CABBDKFODEC).</summary>
|
||||||
@@ -223,10 +233,10 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
|
|||||||
return StatusBody(Status.Success);
|
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
|
/// — buy one stack unit with tokens. Response is
|
||||||
/// <c>{ OperationResult, BalanceUpdateResult, TokenBalanceResponse:
|
/// <c>{ OperationResult: { Status, InventoryItem }, BalanceUpdateResult,
|
||||||
/// { CurrencyType, Balance, Platform } }</c>.</summary>
|
/// TokenBalanceResponse: { CurrencyType, Balance, Platform } }</c>.</summary>
|
||||||
// The 2023-03-21 client PUTs this (verb literal 3 = PUT, at the route
|
// 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
|
// construction in RecNet.Runtime/DCFKEFHJAGC.txt instrs 097-108), so a
|
||||||
// POST-only mapping returned 405 and token-priced consumables were
|
// 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 me = this.RequireCurrentPlayerId();
|
||||||
var item = await FindAsync(publicId);
|
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 body = await ReadBodyAsync();
|
||||||
var (price, currencyId, hasPrice) = ReadPriceAndCurrency(body, "ExpectedPriceAndCurrency");
|
var (price, _, hasPrice) = ReadPriceAndCurrency(body, "ExpectedPriceAndCurrency");
|
||||||
var newCode = ReadConcurrency(body).NewCode;
|
var newCode = ReadConcurrency(body).NewCode;
|
||||||
var balance = await level.GetBalanceAsync(me, TokenCurrencyType);
|
var balance = await level.GetBalanceAsync(me, TokenCurrencyType);
|
||||||
|
|
||||||
// A token purchase is only valid for a token-priced item at the
|
// A token purchase is only valid for a token-priced item at the
|
||||||
// price the client showed the buyer.
|
// price the client showed the buyer.
|
||||||
if (item.CurrencyId is not null || !hasPrice || price != item.Price)
|
if (item.CurrencyId is not null)
|
||||||
return Ok(TokenResponse(TokenOp.RequestedPriceDoesNotMatch, balance));
|
return Ok(TokenResponse(null, Status.ConsumableCannotBePurchasedWithTokens, balance));
|
||||||
|
if (!hasPrice || price != item.Price)
|
||||||
|
return Ok(TokenResponse(
|
||||||
|
TokenOp.RequestedPriceDoesNotMatch, Status.RequestedPriceDoesNotMatch, balance));
|
||||||
if (balance < item.Price)
|
if (balance < item.Price)
|
||||||
return Ok(TokenResponse(TokenOp.NotEnoughCredit, balance));
|
return Ok(TokenResponse(TokenOp.NotEnoughCredit, Status.PurchaseFailed, balance));
|
||||||
|
|
||||||
var newBalance = item.Price == 0
|
var newBalance = item.Price == 0
|
||||||
? balance
|
? balance
|
||||||
@@ -259,15 +275,16 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
|
|||||||
await level.GrantCurrencyAsync(item.CreatorPlayerId, TokenCurrencyType, item.Price,
|
await level.GrantCurrencyAsync(item.CreatorPlayerId, TokenCurrencyType, item.Price,
|
||||||
$"sellRoomConsumable:{item.PublicId}");
|
$"sellRoomConsumable:{item.PublicId}");
|
||||||
|
|
||||||
await AddToInventoryAsync(me, item, newCode);
|
var own = await AddToInventoryAsync(me, item, newCode);
|
||||||
await db.SaveChangesAsync();
|
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>
|
/// <summary>PUT <c>api/roomconsumables/v1/roomconsumable/{id}/purchase/currency</c>
|
||||||
/// — buy with a room currency. Response is <c>{ OperationResult,
|
/// — buy with a room currency. Response is
|
||||||
/// BalanceUpdateResult, CurrencyBalanceResponse: { AccountId,
|
/// <c>{ OperationResult: { Status, InventoryItem }, BalanceUpdateResult,
|
||||||
/// CurrencyId, Balance, ModifiedAt } }</c>.</summary>
|
/// CurrencyBalanceResponse: { AccountId, CurrencyId, Balance,
|
||||||
|
/// ModifiedAt } }</c>.</summary>
|
||||||
// PUT per the client (verb literal 3 at DCFKEFHJAGC.txt instrs 112-123);
|
// PUT per the client (verb literal 3 at DCFKEFHJAGC.txt instrs 112-123);
|
||||||
// POST-only returned 405 and currency-priced consumables were unbuyable.
|
// POST-only returned 405 and currency-priced consumables were unbuyable.
|
||||||
[HttpPut("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/currency")]
|
[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 me = this.RequireCurrentPlayerId();
|
||||||
var item = await FindAsync(publicId);
|
var item = await FindAsync(publicId);
|
||||||
if (item is null) return NotFound();
|
// As with the token route, every rejection is a 200 + DTO carrying a
|
||||||
if (item.CurrencyId is not Guid currencyPublicId) return BadRequest("not_currency_priced");
|
// 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
|
var currency = await db.RoomCurrencies
|
||||||
.FirstOrDefaultAsync(c => c.PublicId == currencyPublicId && !c.IsDeleted);
|
.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 body = await ReadBodyAsync();
|
||||||
var (price, requestedCurrency, hasPrice) = ReadPriceAndCurrency(body, "ExpectedPriceAndCurrency");
|
var (price, requestedCurrency, hasPrice) = ReadPriceAndCurrency(body, "ExpectedPriceAndCurrency");
|
||||||
var newCode = ReadConcurrency(body).NewCode;
|
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
|
var balanceRow = await db.RoomCurrencyBalances
|
||||||
.FirstOrDefaultAsync(b => b.PlayerId == me && b.RoomCurrencyId == currency.Id);
|
.FirstOrDefaultAsync(b => b.PlayerId == me && b.RoomCurrencyId == currency.Id);
|
||||||
var balance = balanceRow?.Balance ?? 0;
|
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)
|
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)
|
if (item.Price > 0)
|
||||||
{
|
{
|
||||||
balanceRow!.Balance -= item.Price;
|
balanceRow!.Balance -= item.Price;
|
||||||
balanceRow.UpdatedAt = DateTime.UtcNow;
|
balanceRow.UpdatedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
await AddToInventoryAsync(me, item, newCode);
|
var own = await AddToInventoryAsync(me, item, newCode);
|
||||||
await db.SaveChangesAsync();
|
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,
|
/// — spend one unit. Body is <c>{ CurrentConcurrencyCode,
|
||||||
/// NewConcurrencyCode }</c>; response <c>{ Status, InventoryItem }</c>.
|
/// NewConcurrencyCode }</c>; response <c>{ Status, InventoryItem }</c>.
|
||||||
/// On a code mismatch the current row is returned so the client can
|
/// 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) =>
|
private async Task<RoomConsumableEntity?> FindAsync(Guid publicId) =>
|
||||||
await db.RoomConsumables.FirstOrDefaultAsync(c => c.PublicId == publicId && !c.IsDeleted);
|
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
|
var own = await db.RoomConsumableOwnership
|
||||||
.FirstOrDefaultAsync(o => o.PlayerId == playerId && o.RoomConsumableId == item.Id);
|
.FirstOrDefaultAsync(o => o.PlayerId == playerId && o.RoomConsumableId == item.Id);
|
||||||
@@ -357,11 +391,12 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
|
|||||||
db.RoomConsumableOwnership.Add(own);
|
db.RoomConsumableOwnership.Add(own);
|
||||||
}
|
}
|
||||||
own.Count += 1;
|
own.Count += 1;
|
||||||
// The purchase response carries no inventory row, so the client
|
// The client adopts the NewConcurrencyCode it sent regardless of what
|
||||||
// adopts the NewConcurrencyCode it sent — store it verbatim or
|
// the response echoes back — store it verbatim or the next consume
|
||||||
// the next consume hits ConcurrencyCodeMismatch.
|
// hits ConcurrencyCodeMismatch.
|
||||||
if (newCode is Guid code && code != Guid.Empty) own.ConcurrencyCode = code;
|
if (newCode is Guid code && code != Guid.Empty) own.ConcurrencyCode = code;
|
||||||
own.ModifiedAt = DateTime.UtcNow;
|
own.ModifiedAt = DateTime.UtcNow;
|
||||||
|
return own;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> CanManageRoomAsync(RoomEntity room, long playerId)
|
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),
|
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,
|
OperationResult = new { Status = status, InventoryItem = inventoryItem },
|
||||||
BalanceUpdateResult = operationResult,
|
BalanceUpdateResult = balanceUpdateResult,
|
||||||
TokenBalanceResponse = new
|
TokenBalanceResponse = new
|
||||||
{
|
{
|
||||||
CurrencyType = TokenCurrencyType,
|
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,
|
OperationResult = new { Status = status, InventoryItem = inventoryItem },
|
||||||
BalanceUpdateResult = operationResult,
|
BalanceUpdateResult = balanceUpdateResult,
|
||||||
CurrencyBalanceResponse = new
|
CurrencyBalanceResponse = new
|
||||||
{
|
{
|
||||||
AccountId = (int)playerId,
|
AccountId = (int)playerId,
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
|||||||
Name = name,
|
Name = name,
|
||||||
Description = Trim(ReadString(fields, "description", "Description"), 512),
|
Description = Trim(ReadString(fields, "description", "Description"), 512),
|
||||||
ImageName = Trim(ReadString(fields, "imageName", "ImageName"), 256),
|
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);
|
db.RoomCurrencies.Add(currency);
|
||||||
await db.SaveChangesAsync();
|
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, "name", "Name") is { } name) currency.Name = Trim(name, 64);
|
||||||
if (ReadString(fields, "description", "Description") is { } description) currency.Description = Trim(description, 512);
|
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 (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;
|
currency.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok(ToWire(currency));
|
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")]
|
[HttpPost("api/roomcurrencies/v1/awardCurrency/bulk")]
|
||||||
|
[HttpPut("api/roomcurrencies/v1/awardCurrency/bulk")]
|
||||||
public async Task<IActionResult> AwardCurrencyBulk()
|
public async Task<IActionResult> AwardCurrencyBulk()
|
||||||
{
|
{
|
||||||
var fields = await ReadFieldsAsync();
|
// The body stream is single-read: pull the raw text once and hand it to
|
||||||
var currency = await FindCurrencyAsync(fields);
|
// the field reader for the legacy fallback below.
|
||||||
if (currency is null) return NotFound();
|
var body = await ReadJsonBodyAsync();
|
||||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
|
var awards = ParseAwards(body);
|
||||||
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
|
|
||||||
|
|
||||||
var playerIds = ReadLongList(fields, "playerIds", "PlayerIds", "accountIds", "AccountIds");
|
if (awards.Count == 0)
|
||||||
var amount = ReadLong(fields, "amount", "Amount") ?? 0;
|
{
|
||||||
if (playerIds.Count == 0 || amount == 0) return BadRequest("missing_awards");
|
// 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>();
|
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);
|
if (!currencyCache.TryGetValue(award.CurrencyId, out var currency))
|
||||||
results.Add(new { PlayerId = (int)playerId, RoomCurrencyId = currency.PublicId, Balance = balance });
|
{
|
||||||
|
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();
|
await db.SaveChangesAsync();
|
||||||
@@ -158,8 +235,13 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
|||||||
{
|
{
|
||||||
RoomCurrencyId = currency.Id,
|
RoomCurrencyId = currency.Id,
|
||||||
Name = Trim(ReadString(fields, "name", "Name") ?? currency.Name, 64),
|
Name = Trim(ReadString(fields, "name", "Name") ?? currency.Name, 64),
|
||||||
Amount = Math.Max(1, ReadInt(fields, "amount", "Amount") ?? 1),
|
// Amount/Price are Int64 on the wire (MIHKAJFMIPE.txt:2351-2374 boxes
|
||||||
Price = Math.Max(0, ReadInt(fields, "price", "Price") ?? 0),
|
// 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),
|
CurrencyType = Math.Max(0, ReadInt(fields, "currencyType", "CurrencyType") ?? 2),
|
||||||
};
|
};
|
||||||
db.RoomCurrencyPurchaseOffers.Add(offer);
|
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 (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
|
||||||
|
|
||||||
if (ReadString(fields, "name", "Name") is { } name) offer.Name = Trim(name, 64);
|
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);
|
// Int64 on the wire, as in createPurchaseOffer.
|
||||||
if (ReadInt(fields, "price", "Price") is int price) offer.Price = Math.Max(0, price);
|
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);
|
if (ReadInt(fields, "currencyType", "CurrencyType") is int currencyType) offer.CurrencyType = Math.Max(0, currencyType);
|
||||||
offer.UpdatedAt = DateTime.UtcNow;
|
offer.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -204,6 +287,10 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
|||||||
return Ok(ToOfferWire(offer, currency));
|
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")]
|
[HttpPost("api/roomcurrencies/v1/getPurchaseOffersBatch")]
|
||||||
[HttpGet("api/roomcurrencies/v1/getPurchaseOffersBatch")]
|
[HttpGet("api/roomcurrencies/v1/getPurchaseOffersBatch")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
@@ -211,7 +298,11 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
|||||||
{
|
{
|
||||||
var fields = await ReadFieldsAsync();
|
var fields = await ReadFieldsAsync();
|
||||||
var offerIds = ReadGuidList(fields, "purchaseOfferIds", "PurchaseOfferIds", "offerIds", "OfferIds");
|
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
|
var query = db.RoomCurrencyPurchaseOffers
|
||||||
.Where(o => !o.IsDeleted)
|
.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));
|
if (currencyIds.Count > 0) query = query.Where(x => currencyIds.Contains(x.c.PublicId));
|
||||||
|
|
||||||
var rows = await query.Take(200).ToListAsync();
|
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")]
|
[HttpPost("api/roomCurrencies/v2/purchase")]
|
||||||
@@ -233,9 +340,28 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
|||||||
if (offer is null) return NotFound();
|
if (offer is null) return NotFound();
|
||||||
var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId);
|
var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId);
|
||||||
var pid = Me;
|
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);
|
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)
|
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
|
var newBalance = offer.Price == 0
|
||||||
? balance
|
? balance
|
||||||
: await level.GrantCurrencyAsync(pid, offer.CurrencyType, -offer.Price, $"roomCurrency:{offer.PublicId}");
|
: 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();
|
await db.SaveChangesAsync();
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
Success = true,
|
// Nested shapes: DPNLANKOHON.txt:331-398 {AccountId, CurrencyId,
|
||||||
Balance = newBalance,
|
// Balance, ModifiedAt} and PECGEJAAMHB.txt:255-298 {Balance,
|
||||||
offer.CurrencyType,
|
// CurrencyType, Platform}.
|
||||||
RoomCurrencyId = currency.PublicId,
|
CurrencyBalanceResponse = new
|
||||||
RoomCurrencyBalance = customBalance,
|
{
|
||||||
|
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)
|
private async Task<long> AddBalanceAsync(long playerId, long currencyId, long amount)
|
||||||
{
|
{
|
||||||
var row = await db.RoomCurrencyBalances
|
// Check the change tracker first: a bulk award can name the same
|
||||||
.FirstOrDefaultAsync(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId);
|
// (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)
|
if (row is null)
|
||||||
{
|
{
|
||||||
row = new RoomCurrencyBalanceEntity { PlayerId = playerId, RoomCurrencyId = currencyId };
|
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();
|
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);
|
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var pair in Request.Query)
|
foreach (var pair in Request.Query)
|
||||||
@@ -313,34 +460,133 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
|||||||
var form = await Request.ReadFormAsync();
|
var form = await Request.ReadFormAsync();
|
||||||
foreach (var pair in form)
|
foreach (var pair in form)
|
||||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
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);
|
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
|
||||||
{
|
{
|
||||||
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,
|
||||||
JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())),
|
_ => prop.Value.GetRawText(),
|
||||||
JsonValueKind.String => prop.Value.GetString() ?? string.Empty,
|
};
|
||||||
_ => prop.Value.GetRawText(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
}
|
||||||
{
|
catch (JsonException)
|
||||||
}
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
return fields;
|
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)
|
private static string? ReadString(Dictionary<string, string> fields, params string[] names)
|
||||||
{
|
{
|
||||||
foreach (var name in names)
|
foreach (var name in names)
|
||||||
@@ -417,15 +663,23 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
|||||||
currency.UpdatedAt,
|
currency.UpdatedAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Purchase offer as the client READS it (formatter AALDCAANEMM):
|
/// <summary>Purchase offer as the client READS it (formatter
|
||||||
/// <c>CurrencyPurchaseOfferId</c>, <c>CurrencyId</c>, <c>Name</c>,
|
/// AALDCAANEMM.txt:499-638): exactly <c>CurrencyPurchaseOfferId</c>,
|
||||||
/// <c>CurrencyAmount</c>, <c>Price</c>, <c>CurrencyType</c>,
|
/// <c>CurrencyId</c>, <c>Order</c>, <c>Name</c>, <c>CurrencyAmount</c>,
|
||||||
/// <c>CreatedAt</c>, <c>ModifiedAt</c>. Legacy aliases retained as in
|
/// <c>Price</c>, <c>ModifiedAt</c>. <c>CurrencyType</c>/<c>CreatedAt</c> are
|
||||||
/// <see cref="ToWire"/>.</summary>
|
/// 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
|
private static object ToOfferWire(RoomCurrencyPurchaseOfferEntity offer, RoomCurrencyEntity currency) => new
|
||||||
{
|
{
|
||||||
CurrencyPurchaseOfferId = offer.PublicId,
|
CurrencyPurchaseOfferId = offer.PublicId,
|
||||||
CurrencyId = currency.PublicId,
|
CurrencyId = currency.PublicId,
|
||||||
|
Order = 0,
|
||||||
offer.Name,
|
offer.Name,
|
||||||
CurrencyAmount = offer.Amount,
|
CurrencyAmount = offer.Amount,
|
||||||
offer.Price,
|
offer.Price,
|
||||||
|
|||||||
@@ -274,6 +274,14 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
|
|||||||
RoomKey = key is null ? null : ToWire(key),
|
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
|
public static object ToWire(RoomKeyEntity key) => new
|
||||||
{
|
{
|
||||||
RoomKeyId = key.Id,
|
RoomKeyId = key.Id,
|
||||||
@@ -284,6 +292,7 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
|
|||||||
key.Name,
|
key.Name,
|
||||||
key.Description,
|
key.Description,
|
||||||
key.Price,
|
key.Price,
|
||||||
|
key.CreatedAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static RoomKeyStatus ValidateText(string? name, string? description, int price)
|
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();
|
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()
|
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();
|
foreach (var name in names)
|
||||||
if (typeof(T) == typeof(NewRoomKeyRequest))
|
{
|
||||||
|
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
|
object req = new NewRoomKeyRequest
|
||||||
{
|
{
|
||||||
RoomId = long.TryParse(form["roomId"].FirstOrDefault()
|
RoomId = long.TryParse(roomId, out var parsedRoomId) ? parsedRoomId : 0,
|
||||||
?? form["RoomId"].FirstOrDefault(), out var roomId)
|
Name = name,
|
||||||
? roomId
|
Description = description,
|
||||||
: 0,
|
Price = int.TryParse(price, out var parsedPrice) ? parsedPrice : 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,
|
|
||||||
};
|
};
|
||||||
return (T)req;
|
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
|
object req = new UpdateRoomKeyRequest
|
||||||
{
|
{
|
||||||
RoomKeyId = long.TryParse(form["roomKeyId"].FirstOrDefault()
|
RoomKeyId = long.TryParse(roomKeyId, out var parsedKeyId) ? parsedKeyId : 0,
|
||||||
?? form["RoomKeyId"].FirstOrDefault(), out var roomKeyId)
|
Name = name,
|
||||||
? roomKeyId
|
Description = description,
|
||||||
: 0,
|
Price = int.TryParse(price, out var parsedPrice) ? parsedPrice : 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,
|
|
||||||
};
|
};
|
||||||
return (T)req;
|
return (T)req;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new T();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (form is not null) return new T();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await JsonSerializer.DeserializeAsync<T>(Request.Body, JsonOptions) ?? new T();
|
return await JsonSerializer.DeserializeAsync<T>(Request.Body, JsonOptions) ?? new T();
|
||||||
|
|||||||
@@ -53,14 +53,48 @@ public class RoomsController(
|
|||||||
[FromQuery] string? tags)
|
[FromQuery] string? tags)
|
||||||
=> Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList());
|
=> 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("rooms/hot")]
|
||||||
|
[HttpGet("roomserver/rooms/hot")]
|
||||||
public async Task<IActionResult> HotRoomServer(
|
public async Task<IActionResult> HotRoomServer(
|
||||||
[FromQuery] string? tag,
|
|
||||||
[FromQuery] string? tags,
|
|
||||||
[FromQuery] int skip = 0,
|
[FromQuery] int skip = 0,
|
||||||
[FromQuery] int take = 100)
|
[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
|
var page = all
|
||||||
.Skip(Math.Max(skip, 0))
|
.Skip(Math.Max(skip, 0))
|
||||||
.Take(Math.Clamp(take, 1, 100))
|
.Take(Math.Clamp(take, 1, 100))
|
||||||
@@ -151,16 +185,37 @@ public class RoomsController(
|
|||||||
return Ok(await BuildRoomServerListAsync(rows));
|
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("rooms/magic_door")]
|
||||||
[HttpGet("roomserver/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()
|
var room = await PublicRoomQuery()
|
||||||
.OrderByDescending(r => r.HotScore)
|
.OrderByDescending(r => r.HotScore)
|
||||||
.ThenBy(r => r.Id)
|
.ThenBy(r => r.Id)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (room is null) return NotFound();
|
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>
|
/// <summary>
|
||||||
@@ -263,8 +318,14 @@ public class RoomsController(
|
|||||||
/// Unlike the <c>api/rooms/v*</c> variants (flat <c>List<Room></c>),
|
/// Unlike the <c>api/rooms/v*</c> variants (flat <c>List<Room></c>),
|
||||||
/// this one's client contract is a PAGED wrapper object
|
/// this one's client contract is a PAGED wrapper object
|
||||||
/// <c>{ Results, TotalResults }</c> (IDLBPALJJDJ : PagedResult<Room>);
|
/// <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("rooms/search")]
|
||||||
|
[HttpGet("roomserver/rooms/search")]
|
||||||
public async Task<IActionResult> SearchRoomServer(
|
public async Task<IActionResult> SearchRoomServer(
|
||||||
[FromQuery(Name = "query")] string? query,
|
[FromQuery(Name = "query")] string? query,
|
||||||
[FromQuery(Name = "value")] string? value,
|
[FromQuery(Name = "value")] string? value,
|
||||||
@@ -414,6 +475,46 @@ public class RoomsController(
|
|||||||
unityAssetVersion: unityAssetVersion));
|
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) ─────────────────────────────
|
// ── RoomDetails (the boot-critical one) ─────────────────────────────
|
||||||
//
|
//
|
||||||
// /v4/details/{roomId} is hit *during* the matchmaking goto flow.
|
// /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
|
/// <summary>GET <c>api/rooms/v1/agRoomIds</c> — list of every
|
||||||
/// seeded AG / RR-Original room id. Used by the watch to bias the
|
/// seeded AG / RR-Original room id. Used by the watch to bias the
|
||||||
/// room browser toward official rooms first.</summary>
|
/// 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("api/rooms/v1/agRoomIds")]
|
||||||
[HttpGet("rooms/rro_ids")]
|
[HttpGet("rooms/rro_ids")]
|
||||||
|
[HttpGet("roomserver/rooms/rro_ids")]
|
||||||
public async Task<IActionResult> AgRoomIds() =>
|
public async Task<IActionResult> AgRoomIds() =>
|
||||||
Ok(await rooms.AgRoomIdsAsync());
|
Ok(await rooms.AgRoomIdsAsync());
|
||||||
|
|
||||||
|
|||||||
@@ -244,6 +244,19 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
return Ok(rows.Select(b => new
|
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.Id,
|
||||||
b.RoomId,
|
b.RoomId,
|
||||||
b.BannedPlayerId,
|
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 ───────────────────────────────────────────────────────────
|
// ── Report ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public sealed class RoomReportRequest
|
// The 2023 client posts the room report as x-www-form-urlencoded with
|
||||||
{
|
// PascalCase keys RoomId (Int64) / RoomKeyId (Int64?) / Details (string) /
|
||||||
public long RoomId { get; set; }
|
// ReportCategory (Int32) — IsilDump/RecNet.Runtime/IBEOONPEELF.txt:23471
|
||||||
public int Category { get; set; }
|
// ("api/rooms/v2/report"), verb byte 2 = POST at :23473, field names at
|
||||||
public string? Message { get; set; }
|
// :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")]
|
[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();
|
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
|
db.Reports.Add(new ReportEntity
|
||||||
{
|
{
|
||||||
ReporterPlayerId = Me,
|
ReporterPlayerId = Me,
|
||||||
TargetPlayerId = room.CreatorPlayerId,
|
TargetPlayerId = room.CreatorPlayerId,
|
||||||
TargetRoomId = req.RoomId,
|
TargetRoomId = roomId,
|
||||||
RoomId = req.RoomId,
|
RoomId = roomId,
|
||||||
Category = req.Category,
|
Category = IntValue(payload, "ReportCategory", "Category") ?? 0,
|
||||||
Message = msg[..Math.Min(1000, msg.Length)],
|
Message = msg[..Math.Min(1000, msg.Length)],
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
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
|
// Each per-field route registers BOTH POST and PUT — the 2020.12
|
||||||
// watch's request-builder wraps the HTTP method inside opaque
|
// watch's request-builder wraps the HTTP method inside opaque
|
||||||
// BPHGKAEDBPE helpers; the existing /name handler at
|
// BPHGKAEDBPE helpers; the existing /name handler at
|
||||||
// RoomsController.cs:1455-1456 set the precedent of "register both
|
// RoomsController.cs:1455-1456 set the precedent of "register both
|
||||||
// because the ISIL is opaque". Same pattern here so the bind never
|
// 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")]
|
[HttpPost("rooms/{roomId:long}/description")]
|
||||||
[HttpPut("rooms/{roomId:long}/description")]
|
[HttpPut("rooms/{roomId:long}/description")]
|
||||||
public async Task<IActionResult> BareDescription(long roomId,
|
[HttpPost("roomserver/rooms/{roomId:long}/description")]
|
||||||
[FromBody] BareDescriptionRequest? body,
|
[HttpPut("roomserver/rooms/{roomId:long}/description")]
|
||||||
[FromForm(Name = "Description")] string? form) =>
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
await ApplyAndReturn(roomId, r => r.Description = body?.Description ?? form ?? r.Description);
|
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")]
|
[HttpPost("rooms/{roomId:long}/image")]
|
||||||
[HttpPut("rooms/{roomId:long}/image")]
|
[HttpPut("rooms/{roomId:long}/image")]
|
||||||
public async Task<IActionResult> BareImage(long roomId,
|
[HttpPost("roomserver/rooms/{roomId:long}/image")]
|
||||||
[FromBody] BareImageRequest? body,
|
[HttpPut("roomserver/rooms/{roomId:long}/image")]
|
||||||
[FromForm(Name = "ImageName")] string? form) =>
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
await ApplyAndReturn(roomId, r => r.ImageName = body?.ImageName ?? form ?? r.ImageName);
|
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")]
|
[HttpPost("rooms/{roomId:long}/tags")]
|
||||||
[HttpPut("rooms/{roomId:long}/tags")]
|
[HttpPut("rooms/{roomId:long}/tags")]
|
||||||
public async Task<IActionResult> BareTags(long roomId,
|
[HttpPost("roomserver/rooms/{roomId:long}/tags")]
|
||||||
[FromBody] BareTagsRequest? body,
|
[HttpPut("roomserver/rooms/{roomId:long}/tags")]
|
||||||
[FromForm(Name = "Tags")] string? form)
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
public async Task<IActionResult> BareTags(long roomId)
|
||||||
{
|
{
|
||||||
var csv = body?.Tags
|
var payload = await ReadPayloadAsync();
|
||||||
?? (body?.TagsList is { Count: > 0 } list ? string.Join(',', list) : null)
|
var tags = Values(payload, "autoTag", "tag");
|
||||||
?? form;
|
// 2020.12 watch / admin tooling: one comma-separated "Tags" field.
|
||||||
return await ApplyAndReturn(roomId, r => r.TagsCsv = csv ?? r.TagsCsv);
|
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")]
|
[HttpPost("rooms/{roomId:long}/accessibility")]
|
||||||
@@ -681,6 +758,129 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
|||||||
return null;
|
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)
|
private static bool TryParseBool(string? raw, out bool value)
|
||||||
{
|
{
|
||||||
if (bool.TryParse(raw, out value)) return true;
|
if (bool.TryParse(raw, out value)) return true;
|
||||||
@@ -726,47 +926,91 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
|||||||
return json.Length <= maxChars ? json : "[]";
|
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")]
|
[HttpPost("rooms/{roomId:long}/cloning")]
|
||||||
[HttpPut("rooms/{roomId:long}/cloning")]
|
[HttpPut("rooms/{roomId:long}/cloning")]
|
||||||
public async Task<IActionResult> BareCloning(long roomId, [FromBody] BareBoolRequest? body,
|
[HttpPost("roomserver/rooms/{roomId:long}/cloning")]
|
||||||
[FromForm(Name = "CloningAllowed")] bool? form) =>
|
[HttpPut("roomserver/rooms/{roomId:long}/cloning")]
|
||||||
await ApplyAndReturn(roomId, r =>
|
[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 (value is bool v) r.CloningAllowed = v;
|
||||||
if (v is bool vv) r.CloningAllowed = vv;
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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")]
|
[HttpPost("rooms/{roomId:long}/automute")]
|
||||||
[HttpPut("rooms/{roomId:long}/automute")]
|
[HttpPut("rooms/{roomId:long}/automute")]
|
||||||
public async Task<IActionResult> BareAutomute(long roomId, [FromBody] BareBoolRequest? body,
|
[HttpPost("roomserver/rooms/{roomId:long}/automute")]
|
||||||
[FromForm(Name = "DisableMicAutoMute")] bool? form) =>
|
[HttpPut("roomserver/rooms/{roomId:long}/automute")]
|
||||||
await ApplyAndReturn(roomId, r =>
|
[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 (value is bool v) r.DisableMicAutoMute = v;
|
||||||
if (v is bool vv) r.DisableMicAutoMute = vv;
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary><c>rooms/{id}/restrictions</c> — junior / age-related
|
/// <summary>PUT <c>rooms/{id}/restrictions</c> — the "who can play here"
|
||||||
/// restriction toggle. Body bool maps to AllowsJuniors (true = no
|
/// panel. The 2023 client sends FOUR booleans in one request:
|
||||||
/// restriction, false = juniors restricted).</summary>
|
/// <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")]
|
[HttpPost("rooms/{roomId:long}/restrictions")]
|
||||||
[HttpPut("rooms/{roomId:long}/restrictions")]
|
[HttpPut("rooms/{roomId:long}/restrictions")]
|
||||||
public async Task<IActionResult> BareRestrictions(long roomId, [FromBody] BareBoolRequest? body,
|
[HttpPost("roomserver/rooms/{roomId:long}/restrictions")]
|
||||||
[FromForm(Name = "AllowsJuniors")] bool? form) =>
|
[HttpPut("roomserver/rooms/{roomId:long}/restrictions")]
|
||||||
await ApplyAndReturn(roomId, r =>
|
[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 (screens is bool s) r.SupportsScreens = s;
|
||||||
if (v is bool vv) r.AllowsJuniors = vv;
|
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")]
|
[HttpPost("rooms/{roomId:long}/warning")]
|
||||||
[HttpPut("rooms/{roomId:long}/warning")]
|
[HttpPut("rooms/{roomId:long}/warning")]
|
||||||
public async Task<IActionResult> BareWarning(long roomId, [FromBody] BareWarningRequest? body) =>
|
[HttpPost("roomserver/rooms/{roomId:long}/warning")]
|
||||||
await ApplyAndReturn(roomId, r =>
|
[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 (mask is int m) r.RoomWarningMask = m;
|
||||||
if (body?.CustomRoomWarning is not null) r.CustomRoomWarning = body.CustomRoomWarning;
|
if (custom is not null) r.CustomRoomWarning = custom.Length > 512 ? custom[..512] : custom;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("rooms/{roomId:long}/creator")]
|
[HttpPost("rooms/{roomId:long}/creator")]
|
||||||
[HttpPut("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")]
|
[HttpPost("rooms/{roomId:long}/loadscreen")]
|
||||||
[HttpPut("rooms/{roomId:long}/loadscreen")]
|
[HttpPut("rooms/{roomId:long}/loadscreen")]
|
||||||
[HttpPost("roomserver/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")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
public async Task<IActionResult> BareLoadScreen(long roomId)
|
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 =>
|
return await ApplyAndReturn(roomId, r =>
|
||||||
{
|
{
|
||||||
if (payload is JsonElement p)
|
// Re-setting an existing image replaces that entry rather than
|
||||||
r.LoadScreensJson = p.ValueKind == JsonValueKind.Array
|
// stacking a duplicate.
|
||||||
? p.GetRawText()
|
var screens = ReadJsonElementList(r.LoadScreensJson)
|
||||||
: SerializeLimited(new[] { p });
|
.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")]
|
[HttpPost("rooms/{roomId:long}/promo_images")]
|
||||||
[HttpPut("rooms/{roomId:long}/promo_images")]
|
[HttpPut("rooms/{roomId:long}/promo_images")]
|
||||||
[HttpPost("roomserver/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 = "roomCode")] string? roomCode,
|
||||||
[FromForm(Name = "forceChange")] bool? forceChange)
|
[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 pid = this.RequireCurrentPlayerId();
|
||||||
var taken = await db.PrivateInstances
|
var clash = await db.PrivateInstances
|
||||||
.AnyAsync(p => p.RoomCode == normalized && p.Id != instanceId);
|
.Where(p => p.RoomCode == normalized && p.Id != instanceId)
|
||||||
if (taken && forceChange != true)
|
.Select(p => new { p.Id, p.OwnerPlayerId })
|
||||||
return Content(JsonSerializer.Serialize(RoomCodeService.Generate(instanceId)), "application/json");
|
.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);
|
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;
|
row.RoomCode = normalized;
|
||||||
await db.SaveChangesAsync();
|
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");
|
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
|
var stored = await db.PrivateInstances
|
||||||
|
|||||||
Reference in New Issue
Block a user