mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
feat: Implement Room Consumables API with CRUD operations and purchase logic; add tests for end-to-end coverage
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>/room/{id}/me</c> must carry a non-null
|
||||
/// <c>Consumable</c> desc — RoomConsumablesManager NREs on room join
|
||||
/// otherwise.
|
||||
/// </summary>
|
||||
public sealed class RoomConsumablesShopTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
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<DorkNetDbContext>();
|
||||
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<LevelService>();
|
||||
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<JsonElement> 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<JsonElement> 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();
|
||||
}
|
||||
}
|
||||
@@ -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.</summary>
|
||||
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<string> ParseEquippedItemGuids(string? outfitSelections)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/roomconsumables/v1 — the 2023 client's in-room shop
|
||||
/// (creator-made consumables, <c>RecRoom.Systems.RoomConsumablesManager</c>).
|
||||
///
|
||||
/// 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 }
|
||||
///
|
||||
/// <c>Consumable</c> 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.
|
||||
/// </summary>
|
||||
[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<IActionResult> List([FromRoute] long? roomId = null, [FromQuery] long? id = null)
|
||||
/// <summary>RoomConsumableStatus (client enum DLDNGEDFMOJ).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Token-purchase OperationResult (client enum CABBDKFODEC).</summary>
|
||||
private static class TokenOp
|
||||
{
|
||||
public const int OK = 0;
|
||||
public const int NotEnoughCredit = 2;
|
||||
public const int RequestedPriceDoesNotMatch = 6;
|
||||
}
|
||||
|
||||
/// <summary>Currency-purchase OperationResult (client enum GACBLALELBP).</summary>
|
||||
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;
|
||||
|
||||
/// <summary>GET <c>api/roomconsumables/v1/roomConsumable/room/{roomId}</c>
|
||||
/// — every consumable for sale in a room; the shop UI's catalog.</summary>
|
||||
[HttpGet("api/roomconsumables/v1/roomConsumable/room/{roomId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> 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));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/roomconsumables/v1/roomConsumable/room/{roomId}/me</c>
|
||||
/// — the caller's consumable inventory for a room, fetched on every
|
||||
/// room join.</summary>
|
||||
[HttpGet("api/roomconsumables/v1/roomConsumable/room/{roomId:long}/me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> 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<IActionResult> PurchaseCurrency(long itemId, [FromQuery] int? currencyType = null) =>
|
||||
Purchase(itemId, currencyType);
|
||||
/// <summary>GET <c>api/roomconsumables/v1/roomConsumable/{id}</c> —
|
||||
/// single consumable desc.</summary>
|
||||
[HttpGet("api/roomconsumables/v1/roomConsumable/{publicId:guid}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> 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")]
|
||||
/// <summary>GET <c>api/roomconsumables/v1/roomConsumable/{id}/isOwned</c>
|
||||
/// — "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.</summary>
|
||||
[HttpGet("api/roomconsumables/v1/roomConsumable/{publicId:guid}/isOwned")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> PurchaseTokens(long itemId) => Purchase(itemId, 2);
|
||||
public async Task<IActionResult> 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<IActionResult> Purchase(long itemId, int? currencyType)
|
||||
/// <summary>POST/PUT <c>api/roomconsumables/v1/roomConsumable</c> —
|
||||
/// create (no <c>RoomConsumableId</c> in the body) or update (id
|
||||
/// present) a consumable. Body is the desc DTO itself; response is
|
||||
/// <c>{ Status, Consumable }</c>.</summary>
|
||||
[HttpPost("api/roomconsumables/v1/roomConsumable")]
|
||||
[HttpPut("api/roomconsumables/v1/roomConsumable")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> 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));
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>api/roomconsumables/v1/roomConsumable/{id}</c> —
|
||||
/// soft-delete. The client deserialises the response body as a bare
|
||||
/// RoomConsumableStatus int.</summary>
|
||||
[HttpDelete("api/roomconsumables/v1/roomConsumable/{publicId:guid}")]
|
||||
[HttpPost("api/roomconsumables/v1/roomConsumable/{publicId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>POST <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>
|
||||
[HttpPost("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/tokens")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> 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)
|
||||
/// <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>
|
||||
[HttpPost("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/currency")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> 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));
|
||||
}
|
||||
|
||||
/// <summary>POST <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
|
||||
/// resync.</summary>
|
||||
[HttpPost("api/roomconsumables/v1/roomConsumable/{publicId:guid}/consume")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> 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<RoomConsumableEntity?> 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<bool> 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),
|
||||
};
|
||||
|
||||
/// <summary>Flatten the JSON body one level: nested objects
|
||||
/// (<c>ExpectedPriceAndCurrency</c>, <c>ConcurrencyCodes</c>,
|
||||
/// <c>PriceAndCurrency</c>) contribute their inner keys, so readers
|
||||
/// can be shape-agnostic about the exact nesting.</summary>
|
||||
private async Task<Dictionary<string, JsonElement>> ReadBodyAsync()
|
||||
{
|
||||
var fields = new Dictionary<string, JsonElement>(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<string, JsonElement> fields, string name) =>
|
||||
fields.TryGetValue(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||
|
||||
private static long? ReadLong(Dictionary<string, JsonElement> 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<string, JsonElement> fields, string name) =>
|
||||
fields.TryGetValue(name, out var v) && v.ValueKind == JsonValueKind.String
|
||||
&& Guid.TryParse(v.GetString(), out var guid)
|
||||
? guid
|
||||
: null;
|
||||
|
||||
/// <summary>Read <c>{ Price, CurrencyId }</c> from the body, whether it
|
||||
/// arrives nested under <paramref name="wrapper"/> /
|
||||
/// <c>PriceAndCurrency</c> or flattened at the root.</summary>
|
||||
private static (long Price, Guid? CurrencyId, bool HasPrice) ReadPriceAndCurrency(
|
||||
Dictionary<string, JsonElement> 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<string, JsonElement>(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<string, JsonElement> 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<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var prop in nested.EnumerateObject()) scope[prop.Name] = prop.Value;
|
||||
}
|
||||
return (ReadGuid(scope, "CurrentConcurrencyCode"), ReadGuid(scope, "NewConcurrencyCode"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ public class PurchaseController(StoreService store, ILogger<PurchaseController>
|
||||
[HttpPost("api/purchase/v1/initiatepurchase")]
|
||||
[HttpPost("api/purchase/v2/initiatepurchase")]
|
||||
[HttpPost("api/purchase/v3/initiatepurchase")]
|
||||
[HttpPost("purchase/v1/initiatepurchase")]
|
||||
public async Task<IActionResult> Initiate([FromForm] PurchaseRequest body)
|
||||
{
|
||||
var item = await ResolveItemAsync(body);
|
||||
@@ -65,6 +66,7 @@ public class PurchaseController(StoreService store, ILogger<PurchaseController>
|
||||
[HttpPost("api/purchase/v1/processpurchase")]
|
||||
[HttpPost("api/purchase/v2/processpurchase")]
|
||||
[HttpPost("api/purchase/v3/processpurchase")]
|
||||
[HttpPost("purchase/v1/processpurchase")]
|
||||
public async Task<IActionResult> Process([FromForm] PurchaseRequest body)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
@@ -91,6 +93,7 @@ public class PurchaseController(StoreService store, ILogger<PurchaseController>
|
||||
[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<PurchaseController>
|
||||
[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<PurchaseController>
|
||||
[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 = "" });
|
||||
|
||||
/// <summary>GET/POST <c>purchase/v1/hasspentmoney</c> — 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.</summary>
|
||||
[HttpGet("purchase/v1/hasspentmoney")]
|
||||
[HttpPost("purchase/v1/hasspentmoney")]
|
||||
[HttpGet("api/purchase/v1/hasspentmoney")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult HasSpentMoney() => Content("false", "application/json");
|
||||
|
||||
/// <summary>GET <c>reminder/currentTokenBundles/v2</c> — 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.</summary>
|
||||
[HttpGet("reminder/currentTokenBundles/v2")]
|
||||
[HttpGet("reminder/currentTokenBundles")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult CurrentTokenBundles() => Ok(Array.Empty<object>());
|
||||
|
||||
/// <summary>POST <c>subscription/v1/*</c> — 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
|
||||
|
||||
Reference in New Issue
Block a user