feat: Enhance room management and moderation APIs

- Added support for multiple tag filters in HotRoomServer endpoint, allowing clients to query hot rooms with multiple tags.
- Introduced a new DeleteRoom endpoint for owners to soft-archive their rooms, ensuring room data integrity.
- Updated MagicDoor endpoint to return a structured response including refresh timing.
- Improved reporting functionality to handle form submissions correctly, ensuring room reports are persisted.
- Refactored per-field mutation endpoints to support both legacy and new client formats, accommodating changes in request structure.
- Implemented a multi-field payload reader to handle complex requests more efficiently.
- Enhanced logging for room code management to track player actions and conflicts.
This commit is contained in:
Alexa
2026-07-27 23:54:25 +03:00
parent 49df7393dc
commit 289072583e
11 changed files with 2156 additions and 336 deletions
@@ -31,8 +31,13 @@ namespace DorkNet.Server.Controllers.API.RoomConsumables;
/// Purchase requests carry the client-generated inventory concurrency
/// token: { ConcurrencyCodes: { CurrentConcurrencyCode,
/// NewConcurrencyCode }, ExpectedPriceAndCurrency: { Price, CurrencyId } }.
/// The purchase response has no inventory item — the client applies
/// NewConcurrencyCode locally, so the server must store it verbatim.
/// The server must store NewConcurrencyCode verbatim, because the client
/// adopts the code it sent regardless of what comes back — anything else
/// makes the next consume fail with ConcurrencyCodeMismatch.
///
/// Both purchase responses derive from the same base DTO (OKHNOPLBOFP),
/// whose sole field <c>OperationResult</c> is the object
/// <c>{ Status, InventoryItem }</c> — see TokenResponse below.
/// </summary>
[ApiController]
public class RoomConsumablesController(DorkNetDbContext db, LevelService level) : ControllerBase
@@ -54,7 +59,12 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
public const int DescriptionTooLong = 27;
public const int ConcurrencyCodeMismatch = 32;
public const int PlayerDoesNotOwnConsumable = 33;
public const int PurchaseFailed = 35;
public const int RoomNotFound = 36;
public const int RequestedPriceDoesNotMatch = 38;
public const int RequestedCurrencyDoesNotMatch = 39;
public const int ConsumableCannotBePurchasedWithRoomCurrency = 40;
public const int ConsumableCannotBePurchasedWithTokens = 41;
}
/// <summary>Token-purchase OperationResult (client enum CABBDKFODEC).</summary>
@@ -223,10 +233,10 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
return StatusBody(Status.Success);
}
/// <summary>POST <c>api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens</c>
/// <summary>PUT <c>api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens</c>
/// — buy one stack unit with tokens. Response is
/// <c>{ OperationResult, BalanceUpdateResult, TokenBalanceResponse:
/// { CurrencyType, Balance, Platform } }</c>.</summary>
/// <c>{ OperationResult: { Status, InventoryItem }, BalanceUpdateResult,
/// TokenBalanceResponse: { CurrencyType, Balance, Platform } }</c>.</summary>
// The 2023-03-21 client PUTs this (verb literal 3 = PUT, at the route
// construction in RecNet.Runtime/DCFKEFHJAGC.txt instrs 097-108), so a
// POST-only mapping returned 405 and token-priced consumables were
@@ -238,19 +248,25 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
{
var me = this.RequireCurrentPlayerId();
var item = await FindAsync(publicId);
if (item is null) return NotFound();
// Failures answer 200 + the DTO rather than a bare status code: the
// client only ever parses this route's body through HJOKFJKKEJM, so a
// 404/400 surfaces as the opaque "Failed to purchase room consumable".
if (item is null) return Ok(TokenResponse(null, Status.RoomConsumableNotFound, 0));
var body = await ReadBodyAsync();
var (price, currencyId, hasPrice) = ReadPriceAndCurrency(body, "ExpectedPriceAndCurrency");
var (price, _, hasPrice) = ReadPriceAndCurrency(body, "ExpectedPriceAndCurrency");
var newCode = ReadConcurrency(body).NewCode;
var balance = await level.GetBalanceAsync(me, TokenCurrencyType);
// A token purchase is only valid for a token-priced item at the
// price the client showed the buyer.
if (item.CurrencyId is not null || !hasPrice || price != item.Price)
return Ok(TokenResponse(TokenOp.RequestedPriceDoesNotMatch, balance));
if (item.CurrencyId is not null)
return Ok(TokenResponse(null, Status.ConsumableCannotBePurchasedWithTokens, balance));
if (!hasPrice || price != item.Price)
return Ok(TokenResponse(
TokenOp.RequestedPriceDoesNotMatch, Status.RequestedPriceDoesNotMatch, balance));
if (balance < item.Price)
return Ok(TokenResponse(TokenOp.NotEnoughCredit, balance));
return Ok(TokenResponse(TokenOp.NotEnoughCredit, Status.PurchaseFailed, balance));
var newBalance = item.Price == 0
? balance
@@ -259,15 +275,16 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
await level.GrantCurrencyAsync(item.CreatorPlayerId, TokenCurrencyType, item.Price,
$"sellRoomConsumable:{item.PublicId}");
await AddToInventoryAsync(me, item, newCode);
var own = await AddToInventoryAsync(me, item, newCode);
await db.SaveChangesAsync();
return Ok(TokenResponse(TokenOp.OK, newBalance));
return Ok(TokenResponse(TokenOp.OK, Status.Success, newBalance, ToInventoryWire(own, item)));
}
/// <summary>POST <c>api/roomconsumables/v1/roomconsumable/{id}/purchase/currency</c>
/// — buy with a room currency. Response is <c>{ OperationResult,
/// BalanceUpdateResult, CurrencyBalanceResponse: { AccountId,
/// CurrencyId, Balance, ModifiedAt } }</c>.</summary>
/// <summary>PUT <c>api/roomconsumables/v1/roomconsumable/{id}/purchase/currency</c>
/// — buy with a room currency. Response is
/// <c>{ OperationResult: { Status, InventoryItem }, BalanceUpdateResult,
/// CurrencyBalanceResponse: { AccountId, CurrencyId, Balance,
/// ModifiedAt } }</c>.</summary>
// PUT per the client (verb literal 3 at DCFKEFHJAGC.txt instrs 112-123);
// POST-only returned 405 and currency-priced consumables were unbuyable.
[HttpPut("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/currency")]
@@ -277,37 +294,51 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
{
var me = this.RequireCurrentPlayerId();
var item = await FindAsync(publicId);
if (item is null) return NotFound();
if (item.CurrencyId is not Guid currencyPublicId) return BadRequest("not_currency_priced");
// As with the token route, every rejection is a 200 + DTO carrying a
// real RoomConsumableStatus; the old BadRequest("price_mismatch")
// bodies were not DTOs at all, so the client could only report the
// generic purchase failure.
if (item is null)
return Ok(CurrencyResponse(null, Status.RoomConsumableNotFound, me, Guid.Empty, 0));
if (item.CurrencyId is not Guid currencyPublicId)
return Ok(CurrencyResponse(
null, Status.ConsumableCannotBePurchasedWithRoomCurrency, me, Guid.Empty, 0));
var currency = await db.RoomCurrencies
.FirstOrDefaultAsync(c => c.PublicId == currencyPublicId && !c.IsDeleted);
if (currency is null) return BadRequest("currency_not_found");
if (currency is null)
return Ok(CurrencyResponse(null, Status.CurrencyNotFound, me, currencyPublicId, 0));
var body = await ReadBodyAsync();
var (price, requestedCurrency, hasPrice) = ReadPriceAndCurrency(body, "ExpectedPriceAndCurrency");
var newCode = ReadConcurrency(body).NewCode;
if (!hasPrice || price != item.Price
|| (requestedCurrency is Guid reqCur && reqCur != currencyPublicId))
return BadRequest("price_mismatch");
var balanceRow = await db.RoomCurrencyBalances
.FirstOrDefaultAsync(b => b.PlayerId == me && b.RoomCurrencyId == currency.Id);
var balance = balanceRow?.Balance ?? 0;
if (!hasPrice || price != item.Price)
return Ok(CurrencyResponse(
null, Status.RequestedPriceDoesNotMatch, me, currencyPublicId, balance));
if (requestedCurrency is Guid reqCur && reqCur != currencyPublicId)
return Ok(CurrencyResponse(
null, Status.RequestedCurrencyDoesNotMatch, me, currencyPublicId, balance));
if (balance < item.Price)
return Ok(CurrencyResponse(CurrencyOp.NotEnoughCredit, me, currencyPublicId, balance));
return Ok(CurrencyResponse(
CurrencyOp.NotEnoughCredit, Status.PurchaseFailed, me, currencyPublicId, balance));
if (item.Price > 0)
{
balanceRow!.Balance -= item.Price;
balanceRow.UpdatedAt = DateTime.UtcNow;
}
await AddToInventoryAsync(me, item, newCode);
var own = await AddToInventoryAsync(me, item, newCode);
await db.SaveChangesAsync();
return Ok(CurrencyResponse(CurrencyOp.Success, me, currencyPublicId, balanceRow?.Balance ?? 0));
return Ok(CurrencyResponse(CurrencyOp.Success, Status.Success, me, currencyPublicId,
balanceRow?.Balance ?? 0, ToInventoryWire(own, item)));
}
/// <summary>POST <c>api/roomconsumables/v1/roomConsumable/{id}/consume</c>
/// <summary>PUT <c>api/roomconsumables/v1/roomConsumable/{id}/consume</c>
/// — spend one unit. Body is <c>{ CurrentConcurrencyCode,
/// NewConcurrencyCode }</c>; response <c>{ Status, InventoryItem }</c>.
/// On a code mismatch the current row is returned so the client can
@@ -347,7 +378,10 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
private async Task<RoomConsumableEntity?> FindAsync(Guid publicId) =>
await db.RoomConsumables.FirstOrDefaultAsync(c => c.PublicId == publicId && !c.IsDeleted);
private async Task AddToInventoryAsync(long playerId, RoomConsumableEntity item, Guid? newCode)
/// <summary>Grant one unit and hand back the row, so the purchase
/// responses can embed it as <c>OperationResult.InventoryItem</c>.</summary>
private async Task<RoomConsumableOwnershipEntity> AddToInventoryAsync(
long playerId, RoomConsumableEntity item, Guid? newCode)
{
var own = await db.RoomConsumableOwnership
.FirstOrDefaultAsync(o => o.PlayerId == playerId && o.RoomConsumableId == item.Id);
@@ -357,11 +391,12 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
db.RoomConsumableOwnership.Add(own);
}
own.Count += 1;
// The purchase response carries no inventory row, so the client
// adopts the NewConcurrencyCode it sent — store it verbatim or
// the next consume hits ConcurrencyCodeMismatch.
// The client adopts the NewConcurrencyCode it sent regardless of what
// the response echoes back — store it verbatim or the next consume
// hits ConcurrencyCodeMismatch.
if (newCode is Guid code && code != Guid.Empty) own.ConcurrencyCode = code;
own.ModifiedAt = DateTime.UtcNow;
return own;
}
private async Task<bool> CanManageRoomAsync(RoomEntity room, long playerId)
@@ -391,10 +426,32 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
Consumable = item is null ? null : ToDescWire(item),
};
private static object TokenResponse(int operationResult, long balance) => new
/// <summary>Token-purchase response (client type MJKFEHPIDME, formatter
/// HJOKFJKKEJM).
///
/// <c>OperationResult</c> is an OBJECT, not a scalar. MJKFEHPIDME only
/// declares BalanceUpdateResult (+24) and TokenBalanceResponse (+32)
/// itself (MJKFEHPIDME.txt:3,23); OperationResult (+16) comes from its
/// base OKHNOPLBOFP, whose single field is typed BGHBAILNNJJ
/// (OKHNOPLBOFP.txt:3) — the same <c>{ Status, InventoryItem }</c> pair
/// the consume route returns (BGHBAILNNJJ.txt:3,83). HJOKFJKKEJM's key
/// map puts "OperationResult" at index 2 (HJOKFJKKEJM.txt:330) and its
/// reader resolves index 2 through JECENNBIMEI&lt;BGHBAILNNJJ&gt;
/// (HJOKFJKKEJM.txt:1104). A bare int there made the generated reader
/// hit a number where it verifies '{', faulting the whole request with
/// "Failed to purchase room consumable" (DCFKEFHJAGC.txt:8294) — so
/// purchases were dead even once the PUT verb was accepted.
///
/// The scalar operation enum belongs in <c>BalanceUpdateResult</c>
/// (Nullable&lt;CABBDKFODEC&gt;, index 0) — that is what the client turns
/// into the "Not enough …" / "The price for this … has changed" toast
/// (DCFKEFHJAGC.txt:12735-12760). It is null when the balance was never
/// touched.</summary>
private static object TokenResponse(
int? balanceUpdateResult, int status, long balance, object? inventoryItem = null) => new
{
OperationResult = operationResult,
BalanceUpdateResult = operationResult,
OperationResult = new { Status = status, InventoryItem = inventoryItem },
BalanceUpdateResult = balanceUpdateResult,
TokenBalanceResponse = new
{
CurrencyType = TokenCurrencyType,
@@ -403,10 +460,20 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
},
};
private static object CurrencyResponse(int operationResult, long playerId, Guid currencyId, long balance) => new
/// <summary>Currency-purchase response (client type DEILBLCDNEA,
/// formatter IDBFKCFEAHM). Same base type as the token response, so
/// <c>OperationResult</c> is the same <c>{ Status, InventoryItem }</c>
/// object; index 2 resolves through JECENNBIMEI&lt;BGHBAILNNJJ&gt;
/// (IDBFKCFEAHM.txt:330 key map, :1161 reader). BalanceUpdateResult is
/// Nullable&lt;GACBLALELBP&gt; — an enum with only Success=0 and
/// NotEnoughCredit=1, so every other failure has to be reported through
/// OperationResult.Status and leave this null.</summary>
private static object CurrencyResponse(
int? balanceUpdateResult, int status, long playerId, Guid currencyId, long balance,
object? inventoryItem = null) => new
{
OperationResult = operationResult,
BalanceUpdateResult = operationResult,
OperationResult = new { Status = status, InventoryItem = inventoryItem },
BalanceUpdateResult = balanceUpdateResult,
CurrencyBalanceResponse = new
{
AccountId = (int)playerId,