From 148d70b048a5e527fc94afd9ba8f87d1152ee9ff Mon Sep 17 00:00:00 2001
From: Alexa <53471801+Alexa-RR@users.noreply.github.com>
Date: Tue, 7 Jul 2026 14:42:05 +0300
Subject: [PATCH] feat: Implement Room Consumables API with CRUD operations and
purchase logic; add tests for end-to-end coverage
---
DorkNet.Contracts/DorkNetRouteOwnership.cs | 5 +
.../RoomConsumablesShopTests.cs | 207 +++++++
.../API/Avatar/V4/AvatarItemsController.cs | 16 +-
.../RoomConsumablesController.cs | 540 +++++++++++++++---
.../API/Store/PurchaseController.cs | 26 +
5 files changed, 699 insertions(+), 95 deletions(-)
create mode 100644 DorkNet.Server.Tests/RoomConsumablesShopTests.cs
diff --git a/DorkNet.Contracts/DorkNetRouteOwnership.cs b/DorkNet.Contracts/DorkNetRouteOwnership.cs
index 0a9b94b..6215025 100644
--- a/DorkNet.Contracts/DorkNetRouteOwnership.cs
+++ b/DorkNet.Contracts/DorkNetRouteOwnership.cs
@@ -121,9 +121,14 @@ public static class DorkNetRouteOwnership
"/api/gamerewards",
"/api/inventions",
"/api/purchase",
+ "/api/roomconsumables",
+ "/api/roomcurrencies",
"/api/storefronts",
"/commerce",
"/econ",
+ "/purchase",
+ "/purchasecampaign",
+ "/reminder",
]),
new(
ServiceNames.Platform,
diff --git a/DorkNet.Server.Tests/RoomConsumablesShopTests.cs b/DorkNet.Server.Tests/RoomConsumablesShopTests.cs
new file mode 100644
index 0000000..73870b5
--- /dev/null
+++ b/DorkNet.Server.Tests/RoomConsumablesShopTests.cs
@@ -0,0 +1,207 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using DorkNet.Server.Data;
+using DorkNet.Server.Data.Entities;
+using DorkNet.Server.Services;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// End-to-end coverage of the 2023 client's in-room shop
+/// (api/roomconsumables/v1) using the exact request/response wire shapes
+/// the 2023.03.21 client serialises (verified against the RecNet.Runtime
+/// formatters in the ISIL dump). The critical regression guard: every
+/// inventory row returned by /room/{id}/me must carry a non-null
+/// Consumable desc — RoomConsumablesManager NREs on room join
+/// otherwise.
+///
+public sealed class RoomConsumablesShopTests : IClassFixture
+{
+ private readonly DorkNetServerFactory _factory;
+
+ public RoomConsumablesShopTests(DorkNetServerFactory factory) => _factory = factory;
+
+ [Fact]
+ public async Task Full_shop_loop_create_purchase_inventory_consume_delete()
+ {
+ using var setupClient = ApiClient();
+ var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
+ var buyer = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
+
+ var roomId = 9_200_001L;
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Rooms.Add(new RoomEntity
+ {
+ Id = roomId,
+ Name = $"ShopRoom_{Guid.NewGuid():N}"[..24],
+ Description = "shop test room",
+ CreatorPlayerId = owner.PlayerId,
+ ImageName = RoomService.DefaultRoomImageName,
+ State = 0,
+ Accessibility = 1,
+ IsAGRoom = true,
+ TagsCsv = "community",
+ });
+ await db.SaveChangesAsync();
+
+ var level = scope.ServiceProvider.GetRequiredService();
+ await level.GrantCurrencyAsync(buyer.PlayerId, 2, 100, "test-seed");
+ }
+
+ using var ownerClient = ApiClient(owner);
+ using var buyerClient = ApiClient(buyer);
+
+ // ── Create (client body = the desc DTO itself) ───────────────────
+ var created = await PostJsonAsync(ownerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
+ {
+ "RoomId": {{roomId}},
+ "Name": "Cola",
+ "Description": "A refreshing test beverage",
+ "ImageName": "",
+ "PriceAndCurrency": { "Price": 10, "CurrencyId": null }
+ }
+ """);
+ Assert.Equal(0, created.GetProperty("Status").GetInt32());
+ var consumableId = created.GetProperty("Consumable").GetProperty("RoomConsumableId").GetGuid();
+ Assert.NotEqual(Guid.Empty, consumableId);
+ Assert.Equal(roomId, created.GetProperty("Consumable").GetProperty("RoomId").GetInt64());
+
+ // ── Non-owner cannot create in someone else's room ───────────────
+ var forbidden = await PostJsonAsync(buyerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
+ { "RoomId": {{roomId}}, "Name": "Hax", "Description": "", "ImageName": "",
+ "PriceAndCurrency": { "Price": 1, "CurrencyId": null } }
+ """);
+ Assert.Equal(7, forbidden.GetProperty("Status").GetInt32()); // PlayerDoesntHavePermission
+
+ // ── Room catalog ─────────────────────────────────────────────────
+ var catalog = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}");
+ Assert.Equal(1, catalog.GetArrayLength());
+ Assert.Equal(10, catalog[0].GetProperty("PriceAndCurrency").GetProperty("Price").GetInt64());
+
+ // ── Purchase at the wrong price is rejected ──────────────────────
+ var codeA = Guid.NewGuid();
+ var badPrice = await PostJsonAsync(buyerClient,
+ $"/api/roomconsumables/v1/roomconsumable/{consumableId}/purchase/tokens", $$"""
+ {
+ "ConcurrencyCodes": { "CurrentConcurrencyCode": null, "NewConcurrencyCode": "{{codeA}}" },
+ "ExpectedPriceAndCurrency": { "Price": 5, "CurrencyId": null }
+ }
+ """);
+ Assert.Equal(6, badPrice.GetProperty("OperationResult").GetInt32()); // RequestedPriceDoesNotMatch
+ var balanceBefore = badPrice.GetProperty("TokenBalanceResponse").GetProperty("Balance").GetInt64();
+
+ // ── Purchase (client stores NewConcurrencyCode locally) ──────────
+ var purchase = await PostJsonAsync(buyerClient,
+ $"/api/roomconsumables/v1/roomconsumable/{consumableId}/purchase/tokens", $$"""
+ {
+ "ConcurrencyCodes": { "CurrentConcurrencyCode": null, "NewConcurrencyCode": "{{codeA}}" },
+ "ExpectedPriceAndCurrency": { "Price": 10, "CurrencyId": null }
+ }
+ """);
+ Assert.Equal(0, purchase.GetProperty("OperationResult").GetInt32());
+ var tokenBalance = purchase.GetProperty("TokenBalanceResponse");
+ Assert.Equal(2, tokenBalance.GetProperty("CurrencyType").GetInt32());
+ Assert.Equal(balanceBefore - 10, tokenBalance.GetProperty("Balance").GetInt64());
+
+ // ── Inventory on room join: Consumable must NEVER be null ────────
+ var inventory = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}/me");
+ Assert.Equal(1, inventory.GetArrayLength());
+ var row = inventory[0];
+ Assert.Equal(consumableId, row.GetProperty("RoomConsumableId").GetGuid());
+ Assert.Equal((int)buyer.PlayerId, row.GetProperty("AccountId").GetInt32());
+ Assert.Equal(1, row.GetProperty("Count").GetInt32());
+ Assert.Equal(codeA, row.GetProperty("ConcurrencyCode").GetGuid());
+ Assert.Equal(JsonValueKind.Object, row.GetProperty("Consumable").ValueKind);
+ Assert.Equal(roomId, row.GetProperty("Consumable").GetProperty("RoomId").GetInt64());
+
+ // ── isOwned: a non-creator holds stock ───────────────────────────
+ using var isOwnedResponse = await ownerClient.GetAsync(
+ $"/api/roomconsumables/v1/roomConsumable/{consumableId}/isOwned");
+ Assert.Equal("true", await isOwnedResponse.Content.ReadAsStringAsync());
+
+ // ── Consume with a stale concurrency code resyncs the client ─────
+ var codeB = Guid.NewGuid();
+ var staleConsume = await PostJsonAsync(buyerClient,
+ $"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
+ { "CurrentConcurrencyCode": "{{Guid.NewGuid()}}", "NewConcurrencyCode": "{{codeB}}" }
+ """);
+ Assert.Equal(32, staleConsume.GetProperty("Status").GetInt32()); // ConcurrencyCodeMismatch
+ Assert.Equal(1, staleConsume.GetProperty("InventoryItem").GetProperty("Count").GetInt32());
+
+ // ── Consume with the right code decrements and rotates the code ──
+ var consume = await PostJsonAsync(buyerClient,
+ $"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
+ { "CurrentConcurrencyCode": "{{codeA}}", "NewConcurrencyCode": "{{codeB}}" }
+ """);
+ Assert.Equal(0, consume.GetProperty("Status").GetInt32());
+ Assert.Equal(0, consume.GetProperty("InventoryItem").GetProperty("Count").GetInt32());
+ Assert.Equal(codeB, consume.GetProperty("InventoryItem").GetProperty("ConcurrencyCode").GetGuid());
+
+ // ── Consuming an empty stack is refused ──────────────────────────
+ var emptyConsume = await PostJsonAsync(buyerClient,
+ $"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
+ { "CurrentConcurrencyCode": "{{codeB}}", "NewConcurrencyCode": "{{Guid.NewGuid()}}" }
+ """);
+ Assert.Equal(33, emptyConsume.GetProperty("Status").GetInt32()); // PlayerDoesNotOwnConsumable
+
+ // ── Update via POST with RoomConsumableId in the body ────────────
+ var updated = await PostJsonAsync(ownerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
+ {
+ "RoomConsumableId": "{{consumableId}}",
+ "RoomId": {{roomId}},
+ "Name": "Diet Cola",
+ "Description": "Now with fewer tokens",
+ "ImageName": "",
+ "PriceAndCurrency": { "Price": 5, "CurrencyId": null }
+ }
+ """);
+ Assert.Equal(0, updated.GetProperty("Status").GetInt32());
+ Assert.Equal("Diet Cola", updated.GetProperty("Consumable").GetProperty("Name").GetString());
+ Assert.Equal(5, updated.GetProperty("Consumable").GetProperty("PriceAndCurrency").GetProperty("Price").GetInt64());
+
+ // ── Delete: buyer refused, owner succeeds, catalog empties ───────
+ using var buyerDelete = await buyerClient.DeleteAsync(
+ $"/api/roomconsumables/v1/roomConsumable/{consumableId}");
+ Assert.Equal("7", await buyerDelete.Content.ReadAsStringAsync()); // PlayerDoesntHavePermission
+
+ using var ownerDelete = await ownerClient.DeleteAsync(
+ $"/api/roomconsumables/v1/roomConsumable/{consumableId}");
+ Assert.Equal("0", await ownerDelete.Content.ReadAsStringAsync());
+
+ var emptied = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}");
+ Assert.Equal(0, emptied.GetArrayLength());
+ }
+
+ private HttpClient ApiClient(GameClientSession? session = null)
+ {
+ var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
+ client.Timeout = TimeSpan.FromSeconds(30);
+ client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
+ client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
+ if (session is not null)
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
+ return client;
+ }
+
+ private static async Task PostJsonAsync(HttpClient client, string path, string json)
+ {
+ using var response = await client.PostAsync(path, new StringContent(json, Encoding.UTF8, "application/json"));
+ response.EnsureSuccessStatusCode();
+ using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
+ return document.RootElement.Clone();
+ }
+
+ private static async Task GetJsonAsync(HttpClient client, string path)
+ {
+ using var response = await client.GetAsync(path);
+ response.EnsureSuccessStatusCode();
+ using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
+ return document.RootElement.Clone();
+ }
+}
diff --git a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs
index cef18ba..4ddabff 100644
--- a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs
+++ b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs
@@ -363,11 +363,17 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
/// inside the wardrobe parser. Stored colored descs are already
/// "{guid},{swatch},{mask}," (3 commas); this pads any shorter legacy
/// value.
- private static string ToWatchDesc(string storedDesc)
- {
- var commas = storedDesc.Count(c => c == ',');
- return commas >= 3 ? storedDesc : storedDesc + new string(',', 3 - commas);
- }
+ // A stored colored descriptor is the EXACT RecNet equip string
+ // ({itemGuid},{combinationGuid…}) — the client feeds everything after the
+ // item GUID into combinationLookup.TryGetValue as an opaque, case-sensitive
+ // key, so it must be returned byte-for-byte. Do NOT pad to 3 commas: the
+ // 2023 combination model has short-form descriptors ({item},{combo}) whose
+ // trailing commas are significant — padding "{item},{combo}" to
+ // "{item},{combo},," corrupts the key and the watch fails to resolve the
+ // combination (renders a broken/default mesh). Only genuinely bare entries
+ // (FullDesc == BaseGuid) get the "{guid},,," base form, and those never
+ // reach here (the caller emits the catalog DTO directly for them).
+ private static string ToWatchDesc(string storedDesc) => storedDesc;
private static HashSet ParseEquippedItemGuids(string? outfitSelections)
{
diff --git a/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs b/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs
index bcefb8f..675d8c8 100644
--- a/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs
+++ b/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs
@@ -1,8 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
-using System.Security.Cryptography;
-using System.Text;
+using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
@@ -10,130 +9,491 @@ using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.RoomConsumables;
+///
+/// api.rec.net/api/roomconsumables/v1 — the 2023 client's in-room shop
+/// (creator-made consumables, RecRoom.Systems.RoomConsumablesManager).
+///
+/// Wire shapes verified against the 2023.03.21 ISIL dump
+/// (RecNet.Runtime formatters CICEHLNDLDE / HJPEABLEEKL / JEBCHNDADKK /
+/// LPFAFIGAIOE / HJOKFJKKEJM / IDBFKCFEAHM / PECGEJAAMHB / DPNLANKOHON):
+///
+/// desc = { RoomConsumableId, RoomId, Name, Description, ImageName,
+/// PriceAndCurrency: { Price, CurrencyId } }
+/// inventory = { RoomConsumableId, AccountId, Count, ConcurrencyCode,
+/// ModifiedAt, Consumable: desc }
+///
+/// Consumable must never be null on an inventory row — the
+/// client's RoomConsumablesManager dereferences it unconditionally while
+/// processing the room-join inventory fetch and NREs otherwise (that NRE
+/// is an unobserved task exception that kills the shop and uploads a
+/// crash report).
+///
+/// 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.
+///
[ApiController]
public class RoomConsumablesController(DorkNetDbContext db, LevelService level) : ControllerBase
{
- [HttpGet("api/roomconsumables")]
- [HttpGet("api/roomconsumables/v1/roomConsumable")]
- [HttpGet("api/roomconsumables/v1/roomConsumable/room/{roomId:long}")]
- [AllowAnonymous]
- public async Task List([FromRoute] long? roomId = null, [FromQuery] long? id = null)
+ /// RoomConsumableStatus (client enum DLDNGEDFMOJ).
+ private static class Status
{
- var query = db.StoreItems
- .Where(i => i.IsActive && i.Category.ToLower() == "consumable");
- if (id is long itemId && itemId > 0)
- query = query.Where(i => i.Id == itemId);
- var rows = await query
- .OrderBy(i => i.DisplayName)
- .Take(200)
- .ToListAsync();
- return Ok(rows.Select(ToWire));
+ public const int Success = 0;
+ public const int RoomConsumableNotFound = 4;
+ public const int PlayerDoesntHavePermission = 7;
+ public const int MaxConsumablesInRoom = 13;
+ public const int RoomIdMissing = 15;
+ public const int PriceOrCurrencyMissing = 17;
+ public const int CurrencyNotFound = 18;
+ public const int PriceTooLowTokens = 21;
+ public const int PriceTooHighTokens = 22;
+ public const int NameTooShort = 23;
+ public const int NameTooLong = 24;
+ public const int DescriptionTooLong = 27;
+ public const int ConcurrencyCodeMismatch = 32;
+ public const int PlayerDoesNotOwnConsumable = 33;
+ public const int RoomNotFound = 36;
}
+ /// Token-purchase OperationResult (client enum CABBDKFODEC).
+ private static class TokenOp
+ {
+ public const int OK = 0;
+ public const int NotEnoughCredit = 2;
+ public const int RequestedPriceDoesNotMatch = 6;
+ }
+
+ /// Currency-purchase OperationResult (client enum GACBLALELBP).
+ private static class CurrencyOp
+ {
+ public const int Success = 0;
+ public const int NotEnoughCredit = 1;
+ }
+
+ private const int TokenCurrencyType = 2; // internal RecCenterTokens row
+ private const int MaxConsumablesPerRoom = 50;
+
+ /// GET api/roomconsumables/v1/roomConsumable/room/{roomId}
+ /// — every consumable for sale in a room; the shop UI's catalog.
+ [HttpGet("api/roomconsumables/v1/roomConsumable/room/{roomId:long}")]
+ [AllowAnonymous]
+ public async Task ListForRoom(long roomId)
+ {
+ var rows = await db.RoomConsumables
+ .Where(c => c.RoomId == roomId && !c.IsDeleted)
+ .OrderBy(c => c.CreatedAt)
+ .Take(200)
+ .ToListAsync();
+ return Ok(rows.Select(ToDescWire));
+ }
+
+ /// GET api/roomconsumables/v1/roomConsumable/room/{roomId}/me
+ /// — the caller's consumable inventory for a room, fetched on every
+ /// room join.
[HttpGet("api/roomconsumables/v1/roomConsumable/room/{roomId:long}/me")]
[Authorize]
public async Task MineForRoom(long roomId)
{
var me = this.RequireCurrentPlayerId();
- var rows = await (from inventory in db.PlayerInventory
- join item in db.StoreItems on inventory.ItemSlug equals item.Slug
- where inventory.PlayerId == me
- && inventory.Quantity > 0
- && item.IsActive
- && item.Category.ToLower() == "consumable"
- orderby item.DisplayName
- select new { Inventory = inventory, Item = item })
+ var rows = await (from own in db.RoomConsumableOwnership
+ join item in db.RoomConsumables on own.RoomConsumableId equals item.Id
+ where own.PlayerId == me && item.RoomId == roomId && !item.IsDeleted
+ orderby item.CreatedAt
+ select new { own, item })
.Take(200)
.ToListAsync();
- return Ok(rows.Select(row => ToInventoryWire(row.Item, row.Inventory)));
+ return Ok(rows.Select(r => ToInventoryWire(r.own, r.item)));
}
- [HttpPost("api/roomconsumables/v1/roomconsumable/{itemId:long}/purchase/currency")]
- [Authorize]
- public Task PurchaseCurrency(long itemId, [FromQuery] int? currencyType = null) =>
- Purchase(itemId, currencyType);
+ /// GET api/roomconsumables/v1/roomConsumable/{id} —
+ /// single consumable desc.
+ [HttpGet("api/roomconsumables/v1/roomConsumable/{publicId:guid}")]
+ [AllowAnonymous]
+ public async Task GetOne(Guid publicId)
+ {
+ var item = await FindAsync(publicId);
+ return item is null ? NotFound() : Ok(ToDescWire(item));
+ }
- [HttpPost("api/roomconsumables/v1/roomconsumable/{itemId:long}/purchase/tokens")]
+ /// GET api/roomconsumables/v1/roomConsumable/{id}/isOwned
+ /// — "do players other than the creator hold this in inventory";
+ /// the client asks before edits/deletes to warn about invalidating
+ /// sold stock. Bare boolean body.
+ [HttpGet("api/roomconsumables/v1/roomConsumable/{publicId:guid}/isOwned")]
[Authorize]
- public Task PurchaseTokens(long itemId) => Purchase(itemId, 2);
+ public async Task IsOwned(Guid publicId)
+ {
+ var item = await FindAsync(publicId);
+ if (item is null) return Content("false", "application/json");
+ var owned = await db.RoomConsumableOwnership.AnyAsync(o =>
+ o.RoomConsumableId == item.Id && o.Count > 0 && o.PlayerId != item.CreatorPlayerId);
+ return Content(owned ? "true" : "false", "application/json");
+ }
- private async Task Purchase(long itemId, int? currencyType)
+ /// POST/PUT api/roomconsumables/v1/roomConsumable —
+ /// create (no RoomConsumableId in the body) or update (id
+ /// present) a consumable. Body is the desc DTO itself; response is
+ /// { Status, Consumable }.
+ [HttpPost("api/roomconsumables/v1/roomConsumable")]
+ [HttpPut("api/roomconsumables/v1/roomConsumable")]
+ [Authorize]
+ public async Task CreateOrUpdate()
{
var me = this.RequireCurrentPlayerId();
- var item = await db.StoreItems
- .FirstOrDefaultAsync(i => i.Id == itemId && i.IsActive && i.Category.ToLower() == "consumable");
+ var body = await ReadBodyAsync();
+ var name = (ReadString(body, "Name") ?? string.Empty).Trim();
+ var description = (ReadString(body, "Description") ?? string.Empty).Trim();
+ var imageName = (ReadString(body, "ImageName") ?? string.Empty).Trim();
+ var (price, currencyId, hasPrice) = ReadPriceAndCurrency(body);
+
+ var publicId = ReadGuid(body, "RoomConsumableId");
+ var existing = publicId is Guid id && id != Guid.Empty ? await FindAsync(id) : null;
+
+ if (existing is null)
+ {
+ var roomId = ReadLong(body, "RoomId");
+ if (roomId is not long rid || rid <= 0) return Ok(EditResponse(Status.RoomIdMissing));
+ var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == rid);
+ if (room is null) return Ok(EditResponse(Status.RoomNotFound));
+ if (!await CanManageRoomAsync(room, me)) return Ok(EditResponse(Status.PlayerDoesntHavePermission));
+
+ if (ValidateFields(name, description, hasPrice, price) is int fieldError)
+ return Ok(EditResponse(fieldError));
+ if (currencyId is Guid cur
+ && !await db.RoomCurrencies.AnyAsync(c => c.PublicId == cur && c.RoomId == rid && !c.IsDeleted))
+ return Ok(EditResponse(Status.CurrencyNotFound));
+ if (await db.RoomConsumables.CountAsync(c => c.RoomId == rid && !c.IsDeleted) >= MaxConsumablesPerRoom)
+ return Ok(EditResponse(Status.MaxConsumablesInRoom));
+
+ var created = new RoomConsumableEntity
+ {
+ RoomId = rid,
+ CreatorPlayerId = me,
+ Name = name,
+ Description = description,
+ ImageName = imageName,
+ Price = price,
+ CurrencyId = currencyId,
+ };
+ db.RoomConsumables.Add(created);
+ await db.SaveChangesAsync();
+ return Ok(EditResponse(Status.Success, created));
+ }
+
+ var owningRoom = await db.Rooms.FirstOrDefaultAsync(r => r.Id == existing.RoomId);
+ if (owningRoom is null || !await CanManageRoomAsync(owningRoom, me))
+ return Ok(EditResponse(Status.PlayerDoesntHavePermission, existing));
+
+ if (ValidateFields(name, description, hasPrice, price) is int updateError)
+ return Ok(EditResponse(updateError, existing));
+ if (currencyId is Guid updateCur
+ && !await db.RoomCurrencies.AnyAsync(c => c.PublicId == updateCur && c.RoomId == existing.RoomId && !c.IsDeleted))
+ return Ok(EditResponse(Status.CurrencyNotFound, existing));
+
+ existing.Name = name;
+ existing.Description = description;
+ existing.ImageName = imageName;
+ existing.Price = price;
+ existing.CurrencyId = currencyId;
+ existing.UpdatedAt = DateTime.UtcNow;
+ await db.SaveChangesAsync();
+ return Ok(EditResponse(Status.Success, existing));
+ }
+
+ /// DELETE api/roomconsumables/v1/roomConsumable/{id} —
+ /// soft-delete. The client deserialises the response body as a bare
+ /// RoomConsumableStatus int.
+ [HttpDelete("api/roomconsumables/v1/roomConsumable/{publicId:guid}")]
+ [HttpPost("api/roomconsumables/v1/roomConsumable/{publicId:guid}")]
+ [Authorize]
+ public async Task Delete(Guid publicId)
+ {
+ var me = this.RequireCurrentPlayerId();
+ var item = await FindAsync(publicId);
+ if (item is null) return StatusBody(Status.RoomConsumableNotFound);
+ var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == item.RoomId);
+ if (room is null || !await CanManageRoomAsync(room, me))
+ return StatusBody(Status.PlayerDoesntHavePermission);
+
+ item.IsDeleted = true;
+ item.UpdatedAt = DateTime.UtcNow;
+ await db.SaveChangesAsync();
+ return StatusBody(Status.Success);
+ }
+
+ /// POST api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens
+ /// — buy one stack unit with tokens. Response is
+ /// { OperationResult, BalanceUpdateResult, TokenBalanceResponse:
+ /// { CurrencyType, Balance, Platform } }.
+ [HttpPost("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/tokens")]
+ [Authorize]
+ public async Task PurchaseTokens(Guid publicId)
+ {
+ var me = this.RequireCurrentPlayerId();
+ var item = await FindAsync(publicId);
if (item is null) return NotFound();
- var spendCurrency = currencyType ?? item.CurrencyType;
- var balance = await level.GetBalanceAsync(me, spendCurrency);
+ var body = await ReadBodyAsync();
+ var (price, currencyId, 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 (balance < item.Price)
- return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance, CurrencyType = spendCurrency });
+ return Ok(TokenResponse(TokenOp.NotEnoughCredit, balance));
- var newBalance = await level.GrantCurrencyAsync(me, spendCurrency, -item.Price, $"roomConsumable:{item.Slug}");
- var inventory = await db.PlayerInventory
- .FirstOrDefaultAsync(i => i.PlayerId == me && i.ItemSlug == item.Slug);
- if (inventory is null)
- {
- inventory = new PlayerInventoryEntity
- {
- PlayerId = me,
- ItemSlug = item.Slug,
- Quantity = 0,
- };
- db.PlayerInventory.Add(inventory);
- }
- inventory.Quantity += 1;
+ var newBalance = item.Price == 0
+ ? balance
+ : await level.GrantCurrencyAsync(me, TokenCurrencyType, -item.Price, $"roomConsumable:{item.PublicId}");
+ if (item.Price > 0 && item.CreatorPlayerId != me)
+ await level.GrantCurrencyAsync(item.CreatorPlayerId, TokenCurrencyType, item.Price,
+ $"sellRoomConsumable:{item.PublicId}");
+
+ await AddToInventoryAsync(me, item, newCode);
await db.SaveChangesAsync();
- return Ok(new
- {
- Success = true,
- Balance = newBalance,
- CurrencyType = spendCurrency,
- Item = ToWire(item),
- Quantity = inventory.Quantity,
- });
+ return Ok(TokenResponse(TokenOp.OK, newBalance));
}
- private static object ToWire(StoreItemEntity item)
+ /// POST api/roomconsumables/v1/roomconsumable/{id}/purchase/currency
+ /// — buy with a room currency. Response is { OperationResult,
+ /// BalanceUpdateResult, CurrencyBalanceResponse: { AccountId,
+ /// CurrencyId, Balance, ModifiedAt } }.
+ [HttpPost("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/currency")]
+ [Authorize]
+ public async Task PurchaseCurrency(Guid publicId)
{
- var roomConsumableId = StableRoomConsumableId(item.Id).ToString("D");
- return new
+ 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");
+
+ var currency = await db.RoomCurrencies
+ .FirstOrDefaultAsync(c => c.PublicId == currencyPublicId && !c.IsDeleted);
+ if (currency is null) return BadRequest("currency_not_found");
+
+ 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 (balance < item.Price)
+ return Ok(CurrencyResponse(CurrencyOp.NotEnoughCredit, me, currencyPublicId, balance));
+
+ if (item.Price > 0)
{
- RoomConsumableId = roomConsumableId,
- Name = item.DisplayName,
- item.ImageName,
- item.Description,
- item.Price,
- CurrencyId = (Guid?)null,
- CreatedAt = DateTimeOffset.UnixEpoch,
- ItemId = item.Id,
- item.Slug,
- item.CurrencyType,
- item.Category,
+ balanceRow!.Balance -= item.Price;
+ balanceRow.UpdatedAt = DateTime.UtcNow;
+ }
+ await AddToInventoryAsync(me, item, newCode);
+ await db.SaveChangesAsync();
+ return Ok(CurrencyResponse(CurrencyOp.Success, me, currencyPublicId, balanceRow?.Balance ?? 0));
+ }
+
+ /// POST api/roomconsumables/v1/roomConsumable/{id}/consume
+ /// — spend one unit. Body is { CurrentConcurrencyCode,
+ /// NewConcurrencyCode }; response { Status, InventoryItem }.
+ /// On a code mismatch the current row is returned so the client can
+ /// resync.
+ [HttpPost("api/roomconsumables/v1/roomConsumable/{publicId:guid}/consume")]
+ [Authorize]
+ public async Task Consume(Guid publicId)
+ {
+ var me = this.RequireCurrentPlayerId();
+ var item = await FindAsync(publicId);
+ if (item is null) return Ok(new { Status = Status.RoomConsumableNotFound, InventoryItem = (object?)null });
+
+ var own = await db.RoomConsumableOwnership
+ .FirstOrDefaultAsync(o => o.PlayerId == me && o.RoomConsumableId == item.Id);
+ if (own is null || own.Count <= 0)
+ return Ok(new
+ {
+ Status = Status.PlayerDoesNotOwnConsumable,
+ InventoryItem = own is null ? null : ToInventoryWire(own, item),
+ });
+
+ var (currentCode, newCode) = ReadConcurrency(await ReadBodyAsync());
+ if (currentCode is Guid current && current != own.ConcurrencyCode)
+ return Ok(new { Status = Status.ConcurrencyCodeMismatch, InventoryItem = ToInventoryWire(own, item) });
+
+ own.Count -= 1;
+ own.ConcurrencyCode = newCode ?? Guid.NewGuid();
+ own.ModifiedAt = DateTime.UtcNow;
+ await db.SaveChangesAsync();
+ return Ok(new { Status = Status.Success, InventoryItem = ToInventoryWire(own, item) });
+ }
+
+ private async Task FindAsync(Guid publicId) =>
+ await db.RoomConsumables.FirstOrDefaultAsync(c => c.PublicId == publicId && !c.IsDeleted);
+
+ private async Task AddToInventoryAsync(long playerId, RoomConsumableEntity item, Guid? newCode)
+ {
+ var own = await db.RoomConsumableOwnership
+ .FirstOrDefaultAsync(o => o.PlayerId == playerId && o.RoomConsumableId == item.Id);
+ if (own is null)
+ {
+ own = new RoomConsumableOwnershipEntity { PlayerId = playerId, RoomConsumableId = item.Id };
+ 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.
+ if (newCode is Guid code && code != Guid.Empty) own.ConcurrencyCode = code;
+ own.ModifiedAt = DateTime.UtcNow;
+ }
+
+ private async Task CanManageRoomAsync(RoomEntity room, long playerId)
+ {
+ if (room.CreatorPlayerId == playerId) return true;
+ if (await db.RoomRoles.AnyAsync(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted))
+ return true;
+ return await db.Players.Where(p => p.Id == playerId).Select(p => p.IsAdmin).FirstOrDefaultAsync();
+ }
+
+ private static int? ValidateFields(string name, string description, bool hasPrice, long price)
+ {
+ if (name.Length == 0) return Status.NameTooShort;
+ if (name.Length > 128) return Status.NameTooLong;
+ if (description.Length > 1024) return Status.DescriptionTooLong;
+ if (!hasPrice) return Status.PriceOrCurrencyMissing;
+ if (price < 0) return Status.PriceTooLowTokens;
+ if (price > 1_000_000) return Status.PriceTooHighTokens;
+ return null;
+ }
+
+ private IActionResult StatusBody(int status) => Content(status.ToString(), "application/json");
+
+ private static object EditResponse(int status, RoomConsumableEntity? item = null) => new
+ {
+ Status = status,
+ Consumable = item is null ? null : ToDescWire(item),
+ };
+
+ private static object TokenResponse(int operationResult, long balance) => new
+ {
+ OperationResult = operationResult,
+ BalanceUpdateResult = operationResult,
+ TokenBalanceResponse = new
+ {
+ CurrencyType = TokenCurrencyType,
+ Balance = balance,
+ Platform = 0,
+ },
+ };
+
+ private static object CurrencyResponse(int operationResult, long playerId, Guid currencyId, long balance) => new
+ {
+ OperationResult = operationResult,
+ BalanceUpdateResult = operationResult,
+ CurrencyBalanceResponse = new
+ {
+ AccountId = (int)playerId,
+ CurrencyId = currencyId,
+ Balance = balance,
+ ModifiedAt = DateTime.UtcNow,
+ },
+ };
+
+ private static object ToDescWire(RoomConsumableEntity c) => new
+ {
+ RoomConsumableId = c.PublicId,
+ c.RoomId,
+ c.Name,
+ c.Description,
+ c.ImageName,
+ PriceAndCurrency = new { c.Price, c.CurrencyId },
+ };
+
+ private static object ToInventoryWire(RoomConsumableOwnershipEntity own, RoomConsumableEntity item) => new
+ {
+ RoomConsumableId = item.PublicId,
+ AccountId = (int)own.PlayerId,
+ Count = Math.Max(0, own.Count),
+ own.ConcurrencyCode,
+ ModifiedAt = DateTime.SpecifyKind(own.ModifiedAt, DateTimeKind.Utc),
+ Consumable = ToDescWire(item),
+ };
+
+ /// Flatten the JSON body one level: nested objects
+ /// (ExpectedPriceAndCurrency, ConcurrencyCodes,
+ /// PriceAndCurrency) contribute their inner keys, so readers
+ /// can be shape-agnostic about the exact nesting.
+ private async Task> ReadBodyAsync()
+ {
+ var fields = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (Request.ContentLength is 0) return fields;
+ try
+ {
+ using var doc = await JsonDocument.ParseAsync(Request.Body);
+ if (doc.RootElement.ValueKind != JsonValueKind.Object) return fields;
+ foreach (var prop in doc.RootElement.EnumerateObject())
+ {
+ fields[prop.Name] = prop.Value.Clone();
+ if (prop.Value.ValueKind == JsonValueKind.Object)
+ foreach (var inner in prop.Value.EnumerateObject())
+ fields.TryAdd(inner.Name, inner.Value.Clone());
+ }
+ }
+ catch (JsonException)
+ {
+ }
+ return fields;
+ }
+
+ private static string? ReadString(Dictionary fields, string name) =>
+ fields.TryGetValue(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
+
+ private static long? ReadLong(Dictionary fields, string name)
+ {
+ if (!fields.TryGetValue(name, out var v)) return null;
+ return v.ValueKind switch
+ {
+ JsonValueKind.Number when v.TryGetInt64(out var n) => n,
+ JsonValueKind.String when long.TryParse(v.GetString(), out var n) => n,
+ _ => null,
};
}
- private static object ToInventoryWire(StoreItemEntity item, PlayerInventoryEntity inventory)
+ private static Guid? ReadGuid(Dictionary fields, string name) =>
+ fields.TryGetValue(name, out var v) && v.ValueKind == JsonValueKind.String
+ && Guid.TryParse(v.GetString(), out var guid)
+ ? guid
+ : null;
+
+ /// Read { Price, CurrencyId } from the body, whether it
+ /// arrives nested under /
+ /// PriceAndCurrency or flattened at the root.
+ private static (long Price, Guid? CurrencyId, bool HasPrice) ReadPriceAndCurrency(
+ Dictionary fields, string wrapper = "PriceAndCurrency")
{
- var createdAt = inventory.AcquiredAt == default
- ? DateTimeOffset.UnixEpoch
- : new DateTimeOffset(DateTime.SpecifyKind(inventory.AcquiredAt, DateTimeKind.Utc));
- return new
+ var scope = fields;
+ if (fields.TryGetValue(wrapper, out var nested) && nested.ValueKind == JsonValueKind.Object)
{
- Id = StableRoomConsumableId(item.Id).ToString("D"),
- ConsumableItemDesc = ToWire(item),
- Count = Math.Max(0, inventory.Quantity),
- InitialCount = Math.Max(0, inventory.Quantity),
- CreatedAt = createdAt,
- ActiveDurationMinutes = 0,
- IsActive = inventory.IsActive,
- IsTransferable = false,
- };
+ scope = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var prop in nested.EnumerateObject()) scope[prop.Name] = prop.Value;
+ }
+ var price = ReadLong(scope, "Price");
+ return (price ?? 0, ReadGuid(scope, "CurrencyId"), price is not null);
}
- private static Guid StableRoomConsumableId(long itemId)
+ private static (Guid? CurrentCode, Guid? NewCode) ReadConcurrency(Dictionary fields)
{
- var hash = MD5.HashData(Encoding.UTF8.GetBytes($"roomConsumable:{itemId}"));
- return new Guid(hash);
+ var scope = fields;
+ if (fields.TryGetValue("ConcurrencyCodes", out var nested) && nested.ValueKind == JsonValueKind.Object)
+ {
+ scope = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var prop in nested.EnumerateObject()) scope[prop.Name] = prop.Value;
+ }
+ return (ReadGuid(scope, "CurrentConcurrencyCode"), ReadGuid(scope, "NewConcurrencyCode"));
}
}
diff --git a/DorkNet.Server/Controllers/API/Store/PurchaseController.cs b/DorkNet.Server/Controllers/API/Store/PurchaseController.cs
index 85adadb..6c1f53d 100644
--- a/DorkNet.Server/Controllers/API/Store/PurchaseController.cs
+++ b/DorkNet.Server/Controllers/API/Store/PurchaseController.cs
@@ -40,6 +40,7 @@ public class PurchaseController(StoreService store, ILogger
[HttpPost("api/purchase/v1/initiatepurchase")]
[HttpPost("api/purchase/v2/initiatepurchase")]
[HttpPost("api/purchase/v3/initiatepurchase")]
+ [HttpPost("purchase/v1/initiatepurchase")]
public async Task Initiate([FromForm] PurchaseRequest body)
{
var item = await ResolveItemAsync(body);
@@ -65,6 +66,7 @@ public class PurchaseController(StoreService store, ILogger
[HttpPost("api/purchase/v1/processpurchase")]
[HttpPost("api/purchase/v2/processpurchase")]
[HttpPost("api/purchase/v3/processpurchase")]
+ [HttpPost("purchase/v1/processpurchase")]
public async Task Process([FromForm] PurchaseRequest body)
{
var pid = this.RequireCurrentPlayerId();
@@ -91,6 +93,7 @@ public class PurchaseController(StoreService store, ILogger
[HttpPost("api/purchase/v1/completepurchase")]
[HttpPost("api/purchase/v2/completepurchase")]
[HttpPost("api/purchase/v3/completepurchase")]
+ [HttpPost("purchase/v1/completepurchase")]
public IActionResult Complete() =>
Ok(new { success = true, error = "" });
@@ -100,6 +103,8 @@ public class PurchaseController(StoreService store, ILogger
[HttpPost("api/purchase/v1/cancelpurchase")]
[HttpPost("api/purchase/v2/cancelpurchase")]
[HttpPost("api/purchase/v3/cancelpurchase")]
+ [HttpPost("purchase/v1/cancelpurchase")]
+ [AllowAnonymous]
public IActionResult Cancel() =>
Ok(new { success = true, error = "" });
@@ -110,9 +115,30 @@ public class PurchaseController(StoreService store, ILogger
[HttpPost("api/purchase/v1/cleanuppending")]
[HttpPost("api/purchase/v2/cleanuppending")]
[HttpPost("api/purchase/v3/cleanuppending")]
+ [HttpPost("purchase/v1/cleanuppending")]
+ [AllowAnonymous]
public IActionResult CleanupPending() =>
Ok(new { success = true, error = "" });
+ /// GET/POST purchase/v1/hasspentmoney — the 2023
+ /// client's startup probe gating first-purchase promo UI. Bare
+ /// boolean body; a private server has no real-money store, so the
+ /// honest answer is always false.
+ [HttpGet("purchase/v1/hasspentmoney")]
+ [HttpPost("purchase/v1/hasspentmoney")]
+ [HttpGet("api/purchase/v1/hasspentmoney")]
+ [AllowAnonymous]
+ public IActionResult HasSpentMoney() => Content("false", "application/json");
+
+ /// GET reminder/currentTokenBundles/v2 — rotating
+ /// real-money token-bundle offers shown on the 2023 client's token
+ /// wallet page. No IAP on a private server, so the offer list is
+ /// genuinely empty.
+ [HttpGet("reminder/currentTokenBundles/v2")]
+ [HttpGet("reminder/currentTokenBundles")]
+ [AllowAnonymous]
+ public IActionResult CurrentTokenBundles() => Ok(Array.Empty