From 8e87a9de82d05f66ce8a0c7904bf21a0ccf4902b 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()); + /// POST subscription/v1/* — Rec Room+. The private /// server has no real billing integration, so buying RR+ is a yes/ack /// operation and cancel is also acknowledged. Returning an active