using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Controllers.API.RoomKeys;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Store;
///
/// api.rec.net/api/storefronts/v2/{buyItem,buyTier,buyElite} — the
/// secondary purchase URL family used by the watch's gift-drop
/// tiles and season-pass tier buttons. Wire request matches
/// RecNet.RequestPurchaseItemDTO:
/// StorefrontType, PurchasableItemId, CurrencyType,
/// CouponConsumablePlayerMappingId, Gift.
///
/// Response is PurchaseBalanceUpdateResponseDTO:
/// BalanceUpdates: List<BalanceUpdateDTO>. The client
/// applies these balance deltas optimistically + reads the optional
/// gift package from inventory after.
///
/// The primary purchase URL (api/purchase/v1/*) is handled
/// elsewhere by PurchaseController; this just covers the v2 surface.
///
[ApiController]
[Authorize]
public class StorefrontsBuyController(
DorkNetDbContext db,
LevelService level,
NotificationService notifications) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
public sealed class RequestPurchaseItemDto
{
public int StorefrontType { get; set; }
public int PurchasableItemId { get; set; }
public int CurrencyType { get; set; } = 2;
/// The price the tile displayed, sent as a stale-price guard.
/// The field was missing here, so buyTier/buyElite had nothing to
/// validate against and fell back to a flat cost. Verified on the
/// 2023-03-21 build: Nullable<Int64> at +32 in
/// RecNet.Runtime/DCFKEFHJAGC_NestedType_RequestPurchaseItemDTO.txt:123-137,
/// wire key "RequestedPrice" in the generated JSON contract
/// RecNet.Runtime/FPLGJGDJIJG.txt:534.
public long? RequestedPrice { get; set; }
public long? CouponConsumablePlayerMappingId { get; set; }
public GiftDto? Gift { get; set; }
}
public sealed class GiftDto
{
public long ToPlayerId { get; set; }
public string? Message { get; set; }
public bool Anonymous { get; set; }
public int GiftContext { get; set; }
}
[HttpPost("api/storefronts/v2/buyItem")]
public async Task BuyItem([FromBody] RequestPurchaseItemDto req)
{
if (req is null) return BadRequest("missing body");
var pid = Me;
var item = await db.StoreItems.FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive);
if (item is null) return NotFound(BalanceUpdateResponse(0, 2 /* CurrencyType */, null, null));
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
if (balance < item.Price)
{
// Insufficient funds — return current balance, no item granted.
return Ok(BalanceUpdateResponse(balance, req.CurrencyType, null, null));
}
// Gift block (when present) means the BUYER spends the tokens but
// the ITEM goes to Gift.ToPlayerId. Self-gifting is disallowed —
// the watch's gift UI prevents it, but guard server-side too.
var giftTarget = req.Gift is { ToPlayerId: > 0 } g && g.ToPlayerId != pid
? g
: null;
var recipient = giftTarget?.ToPlayerId ?? pid;
var isGift = giftTarget is not null;
var newBalance = await level.GrantCurrencyAsync(pid, req.CurrencyType, -item.Price,
isGift ? $"giftItem:{item.Slug}->{recipient}" : $"buyItem:{item.Slug}");
// Grant the item to the RECIPIENT (= buyer for self-purchase, gift
// target for gift purchase). Avatar payloads append to
// AvatarEntity.InventoryJson; consumables and everything else
// insert a PlayerInventory row.
if (StoreService.TryGetAvatarItemPayload(item.Slug, out _, out var avatarItemDesc))
{
await GrantWardrobeAsync(recipient, StoreService.InventoryAvatarItemDesc(avatarItemDesc));
}
else
{
await GrantInventoryAsync(recipient, item.Slug);
}
// Insert a real GiftPackageEntity for the gift-box flow. The watch
// shows a gift-box animation after a purchase, then POSTs the
// package Id to /api/avatar/v2/gifts/consume/ to claim it. If the
// Id isn't a real row, that consume returns 404 and the watch
// logs 'Server failed to consume gift … HTTP Error 404' — the
// user's symptom on every successful buy. By writing the row
// synchronously and returning its DB-assigned Id in the
// BalanceUpdate response, the subsequent consume succeeds.
StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out avatarItemDesc);
var inventoryAvatarDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc);
StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableItemDesc);
StoreService.TryGetEquipmentPayload(item.Slug, out var equipmentPrefabName, out var equipmentModificationGuid);
if (StoreService.TryGetHairDyePayload(item.Slug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
{
avatarItemType = 1;
inventoryAvatarDesc = hairDyeColorGuid;
consumableItemDesc = hairDyeConsumableDesc;
}
// The client matches this gift's item against api/avatar/v4/items by
// EXACT AvatarItemDesc string in OutfitManager.UnlockAvatarItemAndMarkNew
// (fired after the buyer consumes the post-purchase gift box). v4/items
// emits outfit items as the 4-part "{guid},,," desc (the bare guid
// crashes the client's AvatarItem parser), so the gift must carry the
// SAME 4-part desc — sending the stripped bare guid made Find() miss and
// NRE, which surfaced as items vanishing on every buy. Hair dyes match
// by their bare colour guid (inventoryAvatarDesc), unchanged.
var giftWireDesc = string.IsNullOrEmpty(avatarItemDesc) ? inventoryAvatarDesc : avatarItemDesc;
var gift = new GiftPackageEntity
{
RecipientPlayerId = recipient,
FromPlayerId = isGift && !giftTarget!.Anonymous ? (int?)pid : null,
AvatarItemType = string.IsNullOrEmpty(giftWireDesc) ? null : avatarItemType,
AvatarItemDescOrHairDyeDesc = giftWireDesc,
ConsumableItemDesc = consumableItemDesc,
EquipmentPrefabName = equipmentPrefabName,
EquipmentModificationGuid = equipmentModificationGuid,
CurrencyType = req.CurrencyType,
Currency = 0,
Xp = 0,
Level = 1,
GiftContext = isGift ? giftTarget!.GiftContext : 0,
GiftRarity = 0,
Message = isGift ? (giftTarget!.Message ?? string.Empty) : string.Empty,
Platform = -1,
PackageVariant = "Standard",
PackageMaterial = string.Empty,
Consumed = false,
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
await db.SaveChangesAsync();
// Push the buyer's balance update so their wallet UI refreshes.
await notifications.NotifyAsync(pid,
PushNotificationId.StorefrontBalancePurchase,
new { req.CurrencyType, Balance = newBalance, ItemId = item.Id, item.Slug });
if (isGift)
{
// Tell the recipient a gift is waiting. The watch dispatches
// GiftPackageReceived (id=30) to refresh /api/avatar/v2/gifts
// and pop the "you got a gift" toast; the gift box itself
// appears next time the recipient opens their inventory.
await notifications.NotifyAsync(recipient,
PushNotificationId.GiftPackageReceived,
new
{
Id = gift.Id,
Platform = -1,
Xp = 0,
Level = 1,
GiftRarity = 0,
GiftContext = gift.GiftContext,
FromPlayerId = gift.FromPlayerId,
AvatarItemType = gift.AvatarItemType,
AvatarItemDesc = avatarItemDesc,
ConsumableItemDesc = gift.ConsumableItemDesc ?? string.Empty,
EquipmentPrefabName = gift.EquipmentPrefabName,
EquipmentModificationGuid = gift.EquipmentModificationGuid,
Message = gift.Message ?? string.Empty,
});
}
// For gift purchases the buyer doesn't get a box — their flow
// ends with the balance update. Returning an empty Data array
// (giftPackageId = null) makes the watch's BuyItem callback
// skip the gift-receive animation on the sender's screen.
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType,
isGift ? null : item.Slug,
isGift ? null : gift.Id));
}
/// POST api/storefronts/v2/buyTier — season-pass "skip a
/// tier" button. The tier is an ordinary StoreItems row on the
/// season:{n} storefront (see StorefrontsController.Season), so its
/// real price is charged rather than a flat 1 token, and the purchase is
/// recorded as a seasonTier:* progress row so the season page can
/// count how many tiers the player bought.
[HttpPost("api/storefronts/v2/buyTier")]
public async Task BuyTier([FromBody] RequestPurchaseItemDto req)
{
if (req is null) return BadRequest("missing body");
var pid = Me;
var item = await db.StoreItems
.FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive);
// Tiers that aren't seeded as StoreItems still cost the catalogue
// default so the button isn't free on a bare install.
var cost = Math.Max(0, item?.Price ?? StoreService.StoreItemPrice);
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
// Stale-price guard: the client sends the price its tile showed.
if (req.RequestedPrice is long requested && requested != cost)
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
if (balance < cost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
var newBalance = cost == 0
? balance
: await level.GrantCurrencyAsync(pid, req.CurrencyType, -cost,
$"buyTier:{req.PurchasableItemId}");
await RecordSeasonProgressAsync(pid,
$"seasonTier:{req.StorefrontType}:{req.PurchasableItemId}");
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType));
}
/// POST api/storefronts/v2/buyElite — season "elite
/// upgrade" (the paid premium track). There is no real-money flow on a
/// private server, so it is priced in tokens off the same StoreItems row
/// the season storefront advertised. The flag is persisted as a
/// seasonElite:* progress row.
[HttpPost("api/storefronts/v2/buyElite")]
public async Task BuyElite([FromBody] RequestPurchaseItemDto req)
{
if (req is null) return BadRequest("missing body");
var pid = Me;
var eliteKey = $"seasonElite:{req.StorefrontType}";
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
// Already upgraded — ack without charging again so a replayed tap
// can't drain the wallet.
if (await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == eliteKey))
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
var item = await db.StoreItems
.FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive);
var cost = Math.Max(0, item?.Price ?? StoreService.StoreItemPrice);
if (req.RequestedPrice is long requested && requested != cost)
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
if (balance < cost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
var newBalance = cost == 0
? balance
: await level.GrantCurrencyAsync(pid, req.CurrencyType, -cost,
$"buyElite:{req.PurchasableItemId}");
await RecordSeasonProgressAsync(pid, eliteKey);
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType));
}
/// GET api/storefronts/v1/buyRoomKey — purchase a
/// persisted room key. The 2020.12 client sends roomKeyId and the
/// price it displayed so we reject stale-price purchases.
[HttpGet("api/storefronts/v1/buyRoomKey")]
public async Task BuyRoomKey(
[FromQuery] long roomKeyId,
[FromQuery] int requestedPrice)
{
var pid = Me;
var key = await db.RoomKeys.FirstOrDefaultAsync(k => k.Id == roomKeyId && !k.IsDeleted);
if (key is null)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.DoesNotExist, null, await level.GetBalanceAsync(pid, 2)));
if (key.CreatorPlayerId == pid)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.PlayerAlreadyOwns, key, await level.GetBalanceAsync(pid, 2)));
if (requestedPrice != key.Price)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.InvalidParameters, key, await level.GetBalanceAsync(pid, 2)));
var existing = await db.RoomKeyPurchases
.FirstOrDefaultAsync(p => p.RoomKeyId == key.Id && p.PlayerId == pid);
if (existing is not null)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.PlayerAlreadyOwns, key, await level.GetBalanceAsync(pid, 2)));
var balance = await level.GetBalanceAsync(pid, 2);
if (balance < key.Price)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.PurchaseFailed, key, balance));
var newBalance = await level.GrantCurrencyAsync(pid, 2, -key.Price, $"buyRoomKey:{key.Id}");
db.RoomKeyPurchases.Add(new RoomKeyPurchaseEntity
{
RoomKeyId = key.Id,
PlayerId = pid,
PaidPrice = key.Price,
});
await db.SaveChangesAsync();
if (key.CreatorPlayerId != pid && key.Price > 0)
await level.GrantCurrencyAsync(key.CreatorPlayerId, 2, key.Price, $"sellRoomKey:{key.Id}");
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.Success, key, newBalance));
}
public sealed class PurchaseRoomKeyWithCurrencyRequest
{
public long RoomKeyId { get; set; }
public long RequestedPrice { get; set; }
public Guid RequestedPurchaseCurrencyId { get; set; }
}
/// POST api/storefronts/v1/PurchaseRoomKeyWithCurrency —
/// buying a room key with a ROOM currency instead of tokens.
///
/// This was only registered as a [HttpGet] alias of BuyRoomKey, so
/// every attempt 405'd. The 2023-03-21 client issues it with verb 2 (POST —
/// RecNet.Runtime/DCFKEFHJAGC.txt:7426) and three form fields,
/// RoomKeyId (Int64, :7439), RequestedPrice (Int64, :7451) and
/// RequestedPurchaseCurrencyId (Guid, :7464); the DTO signature is
/// INBGDNMFJBP(Int64, Int64, Guid) at :7206.
///
/// The response is RoomKeyPurchaseWithCurrencyResponseDTO, whose
/// generated contract has exactly two members — Balance and
/// RoomKeyResponse (RecNet.Runtime/EDMKBBPGJAB.txt:203/222) —
/// where Balance is the ROOM-currency balance row
/// {AccountId, CurrencyId, Balance, ModifiedAt}
/// (RecNet.Runtime/DPNLANKOHON.txt:331-414), NOT the token
/// BalanceUpdateResponse that the GET alias returned.
[HttpPost("api/storefronts/v1/PurchaseRoomKeyWithCurrency")]
public async Task PurchaseRoomKeyWithCurrency(
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))]
PurchaseRoomKeyWithCurrencyRequest req)
{
var pid = Me;
var currency = await db.RoomCurrencies.FirstOrDefaultAsync(
c => c.PublicId == req.RequestedPurchaseCurrencyId && !c.IsDeleted);
var key = await db.RoomKeys.FirstOrDefaultAsync(
k => k.Id == req.RoomKeyId && !k.IsDeleted);
RoomCurrencyBalanceEntity? row = null;
if (currency is not null)
{
var currencyRowId = currency.Id;
row = await db.RoomCurrencyBalances.FirstOrDefaultAsync(
b => b.PlayerId == pid && b.RoomCurrencyId == currencyRowId);
}
if (currency is null)
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.InvalidParameters, key, pid, req.RequestedPurchaseCurrencyId, null));
if (key is null)
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.DoesNotExist, null, pid, currency.PublicId, row));
if (key.CreatorPlayerId == pid)
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.PlayerAlreadyOwns, key, pid, currency.PublicId, row));
var roomKeyRowId = key.Id;
if (await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyRowId && p.PlayerId == pid))
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.PlayerAlreadyOwns, key, pid, currency.PublicId, row));
if (req.RequestedPrice != key.Price)
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.InvalidParameters, key, pid, currency.PublicId, row));
var balance = row?.Balance ?? 0;
if (balance < key.Price)
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.PurchaseFailed, key, pid, currency.PublicId, row));
if (row is null)
{
row = new RoomCurrencyBalanceEntity
{
PlayerId = pid,
RoomCurrencyId = currency.Id,
Balance = 0,
};
db.RoomCurrencyBalances.Add(row);
}
row.Balance = Math.Max(0, row.Balance - key.Price);
row.UpdatedAt = DateTime.UtcNow;
db.RoomKeyPurchases.Add(new RoomKeyPurchaseEntity
{
RoomKeyId = key.Id,
PlayerId = pid,
PaidPrice = key.Price,
});
await db.SaveChangesAsync();
return Ok(RoomKeyCurrencyPurchaseResponse(
RoomKeyStatus.Success, key, pid, currency.PublicId, row));
}
/// GET api/storefronts/v2/buyInvention — MakerPen store
/// purchase of a paid invention. Verb is 0/GET
/// (RecNet.Runtime/DCFKEFHJAGC.txt:6772) with query fields
/// inventionId (Int64, :6785) and requestedPrice (Int32,
/// :6797) — the latter was being ignored.
///
/// The response type is InventionPurchaseResponseDTO, whose contract
/// is {InventionResponse, BalanceUpdateResponse}
/// (RecNet.Runtime/MAHHAAEGEEP.txt:215/242). The handler used to
/// return the bare token-balance wrapper, so InventionResponse was
/// absent and the client got Status/Invention/InventionVersion = null —
/// it could never confirm the purchase or spawn what it just bought.
/// InventionResponse itself is {Status, Invention, InventionVersion}
/// (RecNet.Runtime/GMBGBPNMGBA.txt:255/274/290) — the same triple
/// InventionsController already returns from addversion.
[HttpGet("api/storefronts/v2/buyInvention")]
[HttpPost("api/storefronts/v2/buyInvention")]
public async Task BuyInvention(
[FromQuery] long inventionId,
[FromQuery] long? requestedPrice = null,
[FromQuery] int currencyType = 2)
{
var pid = Me;
var balance = await level.GetBalanceAsync(pid, currencyType);
var inv = await db.Inventions.FirstOrDefaultAsync(
i => i.Id == inventionId && !i.IsDeleted && i.IsPublished);
if (inv is null)
return Ok(InventionPurchaseResponse(
InventionStatusDoesNotExist, null, null, balance, currencyType));
var version = await db.InventionVersions
.Where(v => v.InventionId == inv.Id)
.OrderByDescending(v => v.VersionNumber)
.FirstOrDefaultAsync();
var price = Math.Max(0, inv.Price);
// Stale-price guard — the MakerPen tile sends the price it rendered.
if (requestedPrice is long asked && asked != price)
return Ok(InventionPurchaseResponse(
InventionStatusInvalidParameters, inv, version, balance, currencyType));
var key = $"invention_purchase:{inv.Id}";
var alreadyOwns = inv.CreatorPlayerId == pid
|| await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == key);
if (alreadyOwns)
{
// Deliberately Success rather than PlayerAlreadyOwns (=19): the
// MakerPen re-issues this call on every spawn of a paid creation,
// and a non-zero status blocks the spawn.
return Ok(InventionPurchaseResponse(
InventionStatusSuccess, inv, version, balance, currencyType));
}
if (balance < price)
return Ok(InventionPurchaseResponse(
InventionStatusPurchaseFailed, inv, version, balance, currencyType));
var newBalance = price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price, $"buyInvention:{inv.Id}");
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = key,
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
if (price > 0 && inv.CreatorPlayerId != pid)
await level.GrantCurrencyAsync(inv.CreatorPlayerId, currencyType, price,
$"sellInvention:{inv.Id}");
return Ok(InventionPurchaseResponse(
InventionStatusSuccess, inv, version, newBalance, currencyType));
}
/// Body of buyForFreeGiftButton. Unlike its siblings this
/// route does NOT use form fields: the client serialises a
/// RequestPurchaseForFreeGiftButtonDTO and posts it as the whole
/// body (RecNet.Runtime/DCFKEFHJAGC.txt:5910-5924, verb 2 at :5912).
/// Field offsets +16/+24/+40/+44/+48 in
/// DCFKEFHJAGC_NestedType_RequestPurchaseForFreeGiftButtonDTO.txt,
/// wire keys PurchasableItemId / CouponConsumablePlayerMappingId / Count /
/// CreatorPlayerId / RequestedPrice in RecNet.Runtime/IAPMDDFENDD.txt:395-502.
///
public sealed class FreeGiftButtonRequest
{
public int PurchasableItemId { get; set; }
public long? CouponConsumablePlayerMappingId { get; set; }
public int Count { get; set; } = 1;
public int CreatorPlayerId { get; set; }
public long? RequestedPrice { get; set; }
}
/// POST api/storefronts/v1/buyForFreeGiftButton — the
/// in-room "free gift button" gadget handing an item to whoever pressed it.
///
/// The handler bound [FromQuery] currencyType/price while the client
/// sends everything in the JSON body, so the requested item id never
/// arrived and a hardcoded 25-token "FreeGiftButton" package was inserted
/// instead — the gadget paid out the wrong reward on every press. Now the
/// body is read and the requested StoreItems row is granted directly.
///
/// The response is the plain BalanceResponseDTO
/// (DCFKEFHJAGC.txt:6008/6055) whose contract is exactly
/// {Balance, CurrencyType, Platform} — RecNet.Runtime/PECGEJAAMHB.txt:255/274/298.
/// There is no gift-package member, so nothing can be handed back through a
/// gift box here; the item has to land in the inventory server-side.
[HttpPost("api/storefronts/v1/buyForFreeGiftButton")]
[HttpGet("api/storefronts/v1/buyForFreeGiftButton")]
public async Task BuyForFreeGiftButton(
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))]
FreeGiftButtonRequest req,
[FromQuery] int currencyType = 2)
{
var pid = Me;
var balance = await level.GetBalanceAsync(pid, currencyType);
var item = await db.StoreItems
.FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive);
if (item is null) return Ok(BalanceResponse(balance, currencyType));
// RequestedPrice is what the gadget quoted, and it is the total for the
// press — the gadget is normally free, so the item's catalogue price is
// deliberately NOT charged here. CouponConsumablePlayerMappingId is
// carried by the DTO but there is no coupon-mapping table to redeem it
// against yet, so it is accepted and ignored.
var price = Math.Max(0, req.RequestedPrice ?? 0);
if (balance < price) return Ok(BalanceResponse(balance, currencyType));
var newBalance = price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price,
$"freeGiftButton:{item.Slug}");
await GrantPurchasedItemAsync(pid, item, Math.Clamp(req.Count, 1, 50));
return Ok(BalanceResponse(newBalance, currencyType));
}
/// Form body of buyProgressionEventXpBoost, verified at
/// RecNet.Runtime/DCFKEFHJAGC.txt:12431 (progressionEventId, Int64),
/// :12444 (purchasableXpBoostId, Guid), :12456 (requestedPrice, Int32) and
/// :12468 (expectedXp, Int32).
public sealed class ProgressionEventXpBoostRequest
{
public long ProgressionEventId { get; set; }
public Guid PurchasableXpBoostId { get; set; }
public int RequestedPrice { get; set; }
public int ExpectedXp { get; set; }
}
/// POST api/storefronts/v1/buyProgressionEventXpBoost.
///
/// Two defects: the handler bound [FromQuery] currencyType/price so
/// none of the four form fields arrived (nothing charged, no XP granted),
/// and it reused the shared BalanceUpdateResponse whose
/// BalanceUpdates[].Data is a LIST. This route's response is
/// BalanceUpdateResponseDTO`1<PurchasedProgressionEventXpBoostDTO>
/// (signature at DCFKEFHJAGC.txt:12118) and that family's inner Data
/// is a SINGLE object — get_Data returns DataTypeDTO, not
/// List<DataTypeDTO>, in
/// DCFKEFHJAGC_NestedType_BalanceUpdateResponseDTO`1_NestedType_DEJIAFEKFCK.txt:83
/// (contrast the Purchase* variant at
/// ..._PurchaseBalanceUpdateResponseDTO`1_NestedType_PKKNEGNNABE.txt:83,
/// which IS a List). An array there makes the reader throw.
/// The payload object is {Xp:Int32} — RecNet.Runtime/NNEJHCKLOHL.txt:139.
[HttpPost("api/storefronts/v1/buyProgressionEventXpBoost")]
[HttpGet("api/storefronts/v1/buyProgressionEventXpBoost")]
public async Task BuyProgressionEventXpBoost(
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))]
ProgressionEventXpBoostRequest req,
[FromQuery] int currencyType = 2)
{
var pid = Me;
var price = Math.Max(0, req.RequestedPrice);
var balance = await level.GetBalanceAsync(pid, currencyType);
if (balance < price) return Ok(XpBoostResponse(balance, currencyType, 0));
var newBalance = price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price,
$"progressionEventXpBoost:{req.ProgressionEventId}");
// The client tells us how much XP the boost it just paid for is worth;
// clamped so a spoofed field can't mint arbitrary levels.
var xp = Math.Clamp(req.ExpectedXp, 0, 100_000);
if (xp > 0)
await level.AwardXpAsync(pid, xp, $"progressionEventXpBoost:{req.ProgressionEventId}");
// Upsert: (PlayerId, Key) is unique, and a boost can legitimately be
// bought more than once — a blind Add threw
// "UNIQUE constraint failed" and turned the second purchase into a 500
// AFTER the currency had already been taken.
var progressKey = $"progressionEvent:{req.ProgressionEventId}:xpBoost:{req.PurchasableXpBoostId:N}";
var progress = await db.ObjectiveProgress
.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == progressKey);
if (progress is null)
{
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = progressKey,
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
}
else
{
progress.IsCompleted = true;
progress.ClearedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
return Ok(XpBoostResponse(newBalance, currencyType, xp));
}
/// Form body of buyPurchaseReminder, verified at
/// RecNet.Runtime/DCFKEFHJAGC.txt:10203 (purchaseReminderId, Int32)
/// and :10215 (requestedPrice, Int64).
public sealed class PurchaseReminderRequest
{
public int PurchaseReminderId { get; set; }
public long RequestedPrice { get; set; }
}
/// POST api/storefronts/v1/buyPurchaseReminder — buying the
/// item a "you were looking at this" reminder popup advertises.
///
/// Was a stub returning {Success:true}; the client deserialises
/// JNPGKDJJPPM = {Balance:Int64, CurrencyType, BalanceType, Data:
/// List<GiftPackage>} (signature at DCFKEFHJAGC.txt:9928, keys at
/// RecNet.Runtime/OLIHBGPDPHF.txt:319/338/362/386), so every field
/// defaulted and the wallet read 0. Note this DTO really does spell its
/// third member BalanceType, unlike the BalanceUpdateResponse family
/// which renames the same property to Platform.
///
/// There is no reminder catalogue on a private server — the offer feed
/// (reminder/currentTokenBundles/v2) is genuinely empty — so the
/// reminder id is resolved as the StoreItems row it points at. Unknown ids
/// charge nothing and return an empty Data list.
[HttpPost("api/storefronts/v1/buyPurchaseReminder")]
[HttpGet("api/storefronts/v1/buyPurchaseReminder")]
public async Task BuyPurchaseReminder(
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))]
PurchaseReminderRequest req,
[FromQuery] int currencyType = 2)
{
var pid = Me;
var balance = await level.GetBalanceAsync(pid, currencyType);
var item = await db.StoreItems
.FirstOrDefaultAsync(s => s.Id == req.PurchaseReminderId && s.IsActive);
if (item is null)
return Ok(PurchaseReminderResponse(balance, currencyType, null, null));
// Stale-price guard; 0 means the popup didn't quote a price.
if (req.RequestedPrice > 0 && req.RequestedPrice != item.Price)
return Ok(PurchaseReminderResponse(balance, currencyType, null, null));
if (balance < item.Price)
return Ok(PurchaseReminderResponse(balance, currencyType, null, null));
var newBalance = item.Price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -item.Price,
$"buyPurchaseReminder:{item.Slug}");
var giftId = await GrantWithGiftBoxAsync(pid, item, currencyType);
// Upsert: (PlayerId, Key) is unique and the same reminder can be
// bought again — a blind Add threw "UNIQUE constraint failed" and
// turned the second purchase into a 500 AFTER the currency had
// already been taken and the gift saved.
var progressKey = $"purchaseReminder:{req.PurchaseReminderId}";
var progress = await db.ObjectiveProgress
.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == progressKey);
if (progress is null)
{
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = progressKey,
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
}
else
{
progress.IsCompleted = true;
progress.ClearedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
return Ok(PurchaseReminderResponse(newBalance, currencyType, item.Slug, giftId));
}
// ── Helpers ──────────────────────────────────────────────────────────
private async Task GrantWardrobeAsync(long pid, string guid)
{
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = pid };
db.Avatars.Add(avatar);
}
try
{
var list = System.Text.Json.JsonSerializer
.Deserialize>(avatar.InventoryJson) ?? new();
if (!list.Contains(guid))
{
list.Add(guid);
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
avatar.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
}
catch
{
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(new[] { guid });
avatar.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
}
private async Task GrantInventoryAsync(long pid, string slug)
{
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug);
if (row is null)
{
db.PlayerInventory.Add(new PlayerInventoryEntity
{
PlayerId = pid,
ItemSlug = slug,
Quantity = 1,
});
}
else
{
row.Quantity += 1;
}
await db.SaveChangesAsync();
}
private static object BalanceUpdateResponse(long balance, int currencyType,
string? grantedSlug = null, long? giftPackageId = null)
{
// PurchaseBalanceUpdateResponseDTO wire shape, verified
// against Cpp2IL_ISIL/.../RecNet/Avatars_NestedType_GiftPackage.txt:
//
// PurchaseBalanceUpdateResponseDTO EXTENDS BalanceResponseDTO,
// so the watch first runs BalanceResponseDTO.Deserialize which
// does Util.GetKey("Balance") / GetKey("CurrencyType")
// / GetKey("BalanceType") — REQUIRED, KeyNotFoundException
// on miss. Mirror the trio on the OUTER object.
//
// Then it reads BalanceUpdates: List>.
// Each inner BalanceUpdateDTO has UpdateResponse + Data, where
// Data is List. GiftPackage.Deserialize REQUIRES:
// "Id" (long) | Util.GetKey
// "Platform" (int) | Util.GetKey
// "Xp" (int) | Util.GetKey
// "Level" (int) | Util.GetKey
// "GiftRarity" (int) | Util.GetKey
// "GiftContext" (int) | Util.GetKey
// Plus optional FromPlayerId / AvatarItemType / AvatarItemDesc /
// ConsumableItemDesc / EquipmentPrefabName / EquipmentModificationGuid /
// CurrencyType / Currency / Message.
//
// The previous shape shipped {CurrencyType,Balance,BalanceType,Platform}
// in the Data array — those don't match GiftPackage's required keys,
// so Util.GetKey("Id") threw KeyNotFoundException, the
// ApiCallback received a non-null error string, and StorePurchaseItem
// Screen logged the error as "Null". The user's
// output_log.txt:920 shows that callback path firing on every
// purchase attempt. New shape ships a real (synthetic) GiftPackage
// with all required keys + the granted item's GUID under
// AvatarItemDesc so the watch's gift-receive flow can preview the
// unlocked item.
//
// CRITICAL: the watch follows up every purchase with a POST to
// /api/avatar/v2/gifts/consume/ containing this Id. If the Id
// isn't a real GiftPackageEntity row in the DB, consume returns
// 404 and the watch's logs 'Server failed to
// consume gift … HTTP Error 404', which the player-destroy
// cascade picks up on (the user's symptom on every buy:
// game crashes after opening the gift box). Caller MUST pass
// = the saved
// GiftPackageEntity.Id. The previous code used
// DateTime.UtcNow.Ticks as a fake id.
var giftId = giftPackageId ?? DateTime.UtcNow.Ticks;
return new
{
// BalanceResponseDTO required trio.
Balance = balance,
CurrencyType = currencyType,
// The 2023-03-21 build renames the base DTO's third member to
// "Platform" on the whole BalanceUpdateResponse family — its
// generated contract table lists exactly BalanceUpdates / Balance /
// CurrencyType / Platform and has NO "BalanceType"
// (RecNet.Runtime/KILGEFOENFN.txt:319/346/362/386, identical in
// BBEFAELEANL/IBAPHBCOBHL/ALGKCAKCFAJ/AIEJFLGKIGN). Emitting only
// BalanceType left Platform defaulted to 0 on the client. Both are
// shipped: the 2020.12 watch reads BalanceType and unknown members
// are ignored on both readers.
Platform = 0,
BalanceType = 0,
// PurchaseBalanceUpdateResponseDTO.BalanceUpdates =
// List>. Note the Data member here is
// a LIST (…_PurchaseBalanceUpdateResponseDTO`1_NestedType_PKKNEGNNABE.txt:83)
// — the plain BalanceUpdateResponseDTO family uses a single object.
BalanceUpdates = new[]
{
new
{
UpdateResponse = 0, // UpdateResponseTypes.Success
Data = grantedSlug is null
? Array.Empty