Import december-2020-12-18 from private repo (scrubbed 2026-05-27)

Targets Rec Room 2020.12.18 client.

Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
  the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces the dorknet-clientpatch-{key}.zip
  Release asset the launcher fetches.
- .github/workflows/release.yml — auto-publishes both Release assets
  on v1-december-* tag push.

Scrubbed via maintainer-side scrub-for-public.ps1: same drop rules as
the prior import, plus build-patcher-zip.ps1 added to the tools/
allow-list so it survives the scrub.
This commit is contained in:
Alexa
2026-05-27 01:04:12 +03:00
commit 4edf25b2d5
375 changed files with 201291 additions and 0 deletions
@@ -0,0 +1,186 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Auth;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Store;
/// <summary>
/// api.rec.net/api/purchase/v1/* — real purchase flow that verifies
/// the player has enough currency, deducts it, and grants the item
/// to their inventory. Replaces the old "ack-everything" stubs in
/// AllEndpointsController that let the watch think it bought
/// something but never actually persisted the transaction.
///
/// The 2020 client follows a three-step flow when the player taps
/// "buy" on a Shop item:
/// 1. POST initiatepurchase — server validates the item exists +
/// player has funds, returns a transaction id (we just echo
/// back the item id; no real reservation needed for a private
/// server's threat model).
/// 2. POST processpurchase — server actually deducts currency,
/// grants the item to the avatar's InventoryJson list, returns
/// the new balance.
/// 3. POST completepurchase — final ack; client uses this to
/// flip its local UI state from "purchasing…" to "owned".
/// We keep all three so the watch's flow doesn't deadlock waiting
/// for one or another, but the actual mutation happens in
/// processpurchase. initiate and complete just validate + ack.
/// </summary>
[ApiController]
[Authorize]
public class PurchaseController(StoreService store, ILogger<PurchaseController> logger) : ControllerBase
{
public sealed record PurchaseRequest(long? ItemId, string? Slug, int? Quantity);
/// <summary>POST initiatepurchase — validate the item exists and
/// the caller has enough currency, but don't mutate yet. Returns
/// the resolved item id so the watch can echo it back on
/// processpurchase.</summary>
[HttpPost("api/purchase/v1/initiatepurchase")]
[HttpPost("api/purchase/v2/initiatepurchase")]
[HttpPost("api/purchase/v3/initiatepurchase")]
public async Task<IActionResult> Initiate([FromForm] PurchaseRequest body)
{
var item = await ResolveItemAsync(body);
if (item is null) return Ok(new { success = false, error = "item_not_found" });
return Ok(new
{
success = true,
error = "",
ItemId = item.Id,
Slug = item.Slug,
// Hand back a token the watch may include in
// processpurchase. We accept anything on the way in, so
// this is informational.
TransactionId = $"txn-{item.Id}-{DateTime.UtcNow.Ticks}",
});
}
/// <summary>POST processpurchase — atomic charge + grant. The
/// real work. Returns success+balance on completion or the
/// specific error code on failure (insufficient_funds,
/// item_not_available, etc.) so the watch can show the right UI
/// message.</summary>
[HttpPost("api/purchase/v1/processpurchase")]
[HttpPost("api/purchase/v2/processpurchase")]
[HttpPost("api/purchase/v3/processpurchase")]
public async Task<IActionResult> Process([FromForm] PurchaseRequest body)
{
var pid = this.RequireCurrentPlayerId();
var item = await ResolveItemAsync(body);
if (item is null) return Ok(new { success = false, error = "item_not_found" });
var result = await store.PurchaseAsync(pid, item.Id);
logger.LogInformation(
"[purchase] player={Pid} item={Item} success={Success} error={Error} balance={Balance}",
pid, item.Slug, result.Success, result.Error ?? "", result.Balance ?? 0);
return Ok(new
{
success = result.Success,
error = result.Error ?? "",
Balance = result.Balance ?? 0,
CurrencyType = item.CurrencyType,
ItemId = item.Id,
Slug = result.Slug ?? item.Slug,
});
}
/// <summary>POST completepurchase — final ack. Client flips its
/// UI to "owned" state and refreshes the avatar selector.</summary>
[HttpPost("api/purchase/v1/completepurchase")]
[HttpPost("api/purchase/v2/completepurchase")]
[HttpPost("api/purchase/v3/completepurchase")]
public IActionResult Complete() =>
Ok(new { success = true, error = "" });
/// <summary>POST cancelpurchase — client retreated mid-flow
/// (closed the panel, network blip, etc.). Nothing to roll back
/// since we don't reserve in initiate.</summary>
[HttpPost("api/purchase/v1/cancelpurchase")]
[HttpPost("api/purchase/v2/cancelpurchase")]
[HttpPost("api/purchase/v3/cancelpurchase")]
public IActionResult Cancel() =>
Ok(new { success = true, error = "" });
/// <summary>POST cleanuppending — client startup probe to release
/// any stale "in-flight" purchases from a crashed previous
/// session. We don't track in-flight state, so always-ack is
/// correct.</summary>
[HttpPost("api/purchase/v1/cleanuppending")]
[HttpPost("api/purchase/v2/cleanuppending")]
[HttpPost("api/purchase/v3/cleanuppending")]
public IActionResult CleanupPending() =>
Ok(new { success = true, error = "" });
/// <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
/// subscription object keeps the client out of platform IAP UI.</summary>
[HttpPost("/subscription/v1")]
[HttpPost("/subscription/v1/purchase")]
[HttpPost("/subscription/v1/buy")]
[HttpPost("/subscription/v1/cancel")]
[HttpPost("/api/subscription/v1")]
[HttpPost("/api/subscription/v1/purchase")]
[HttpPost("/api/subscription/v1/buy")]
[HttpPost("/api/subscription/v1/cancel")]
public IActionResult SubscriptionAck()
{
var pid = this.CurrentPlayerId() ?? 0;
logger.LogInformation("[subscription] ack requested by player {Pid}", pid);
return Ok(SubscriptionResponse(pid));
}
[HttpGet("/subscription/v1")]
[HttpGet("/subscription/v1/current")]
[HttpGet("/subscription/v1/mine")]
[HttpGet("/api/subscription/v1")]
[HttpGet("/api/subscription/v1/current")]
[HttpGet("/api/subscription/v1/mine")]
public IActionResult SubscriptionStatus() =>
Ok(SubscriptionResponse(this.CurrentPlayerId() ?? 0));
private static Dictionary<string, object?> SubscriptionResponse(long playerId)
{
var now = DateTime.UtcNow;
var pascal = new Dictionary<string, object?>
{
["SubscriptionId"] = $"dorknet-rrplus-{playerId}",
["RecNetPlayerId"] = playerId,
["IsActive"] = true,
["StartedAt"] = now,
["CurrentPeriodEnd"] = now.AddYears(10),
["RenewalDate"] = now.AddYears(10),
};
var camel = new Dictionary<string, object?>
{
["subscriptionId"] = $"dorknet-rrplus-{playerId}",
["recNetPlayerId"] = playerId,
["isActive"] = true,
["startedAt"] = now,
["currentPeriodEnd"] = now.AddYears(10),
["renewalDate"] = now.AddYears(10),
};
return new Dictionary<string, object?>
{
["success"] = true,
["error"] = "",
["IsSubscribed"] = true,
["isSubscribed"] = true,
["Active"] = true,
["active"] = true,
["Subscription"] = pascal,
["subscription"] = camel,
};
}
private async Task<Data.Entities.StoreItemEntity?> ResolveItemAsync(PurchaseRequest body)
{
if (body.ItemId is long id && id > 0)
return await store.GetByIdAsync(id);
if (!string.IsNullOrWhiteSpace(body.Slug))
return await store.GetBySlugAsync(body.Slug);
return null;
}
}
@@ -0,0 +1,422 @@
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;
/// <summary>
/// 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
/// <c>RecNet.RequestPurchaseItemDTO</c>:
/// <c>StorefrontType, PurchasableItemId, CurrencyType,
/// CouponConsumablePlayerMappingId, Gift</c>.
///
/// Response is <c>PurchaseBalanceUpdateResponseDTO</c>:
/// <c>BalanceUpdates: List&lt;BalanceUpdateDTO&gt;</c>. The client
/// applies these balance deltas optimistically + reads the optional
/// gift package from inventory after.
///
/// The primary purchase URL (<c>api/purchase/v1/*</c>) is handled
/// elsewhere by PurchaseController; this just covers the v2 surface.
/// </summary>
[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;
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<IActionResult> 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);
if (StoreService.TryGetHairDyePayload(item.Slug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
{
avatarItemType = 1;
inventoryAvatarDesc = hairDyeColorGuid;
consumableItemDesc = hairDyeConsumableDesc;
}
var gift = new GiftPackageEntity
{
RecipientPlayerId = recipient,
FromPlayerId = isGift && !giftTarget!.Anonymous ? (int?)pid : null,
AvatarItemType = string.IsNullOrEmpty(inventoryAvatarDesc) ? null : avatarItemType,
AvatarItemDescOrHairDyeDesc = inventoryAvatarDesc,
ConsumableItemDesc = consumableItemDesc,
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,
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));
}
[HttpPost("api/storefronts/v2/buyTier")]
public async Task<IActionResult> BuyTier([FromBody] RequestPurchaseItemDto req)
{
// Season-pass tier: no per-item table, so we just deduct a
// small fixed cost (1 token) per tier and ack. A full
// season-pass system would have a SeasonProgressEntity.
var pid = Me;
const int TierCost = (int)StoreService.StoreItemPrice;
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
if (balance < TierCost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
var newBalance = await level.GrantCurrencyAsync(pid, req.CurrencyType, -TierCost,
$"buyTier:{req.PurchasableItemId}");
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType));
}
[HttpPost("api/storefronts/v2/buyElite")]
public async Task<IActionResult> BuyElite([FromBody] RequestPurchaseItemDto req)
{
// Elite-tier purchase (paid premium track). Without real
// money flow on a private server we just ack.
var pid = Me;
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
}
/// <summary>GET <c>api/storefronts/v1/buyRoomKey</c> — purchase a
/// persisted room key. The 2020.12 client sends roomKeyId and the
/// price it displayed so we reject stale-price purchases.</summary>
[HttpGet("api/storefronts/v1/buyRoomKey")]
public async Task<IActionResult> 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));
}
// ── 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<List<string>>(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<GiftPackage> 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<long>("Balance") / GetKey<int>("CurrencyType")
// / GetKey<int>("BalanceType") — REQUIRED, KeyNotFoundException
// on miss. Mirror the trio on the OUTER object.
//
// Then it reads BalanceUpdates: List<BalanceUpdateDTO<GiftPackage>>.
// Each inner BalanceUpdateDTO has UpdateResponse + Data, where
// Data is List<GiftPackage>. 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<long>("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 <RunConsumeGift> 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
// <paramref name="giftPackageId"/> = the saved
// GiftPackageEntity.Id. The previous code used
// DateTime.UtcNow.Ticks as a fake id.
var giftId = giftPackageId ?? DateTime.UtcNow.Ticks;
// Pull the GUID off the slug for wardrobe-* purchases so the
// watch's GiftPackage receives the AvatarItem GUID and can
// render the unlock animation. dorknet-* slugs leave the desc
// empty (those are non-wardrobe SKUs).
var avatarItemType = 0;
var avatarItemDesc = string.Empty;
var consumableItemDesc = string.Empty;
if (grantedSlug is not null)
{
StoreService.TryGetAvatarItemPayload(grantedSlug, out avatarItemType, out avatarItemDesc);
StoreService.TryGetConsumableItemDesc(grantedSlug, out consumableItemDesc);
if (StoreService.TryGetHairDyePayload(grantedSlug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
{
avatarItemType = 1;
avatarItemDesc = hairDyeColorGuid;
consumableItemDesc = hairDyeConsumableDesc;
}
}
var giftPackage = new
{
// REQUIRED via Util.GetKey — KeyNotFoundException on miss.
// Verified keys at NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946:
// Id, Message, FromPlayerId, AvatarItemType, AvatarItemDesc,
// ConsumableItemDesc, EquipmentPrefabName,
// EquipmentModificationGuid, Platform, PlatformsToSpawnOn,
// BalanceType, CurrencyType, Currency, Xp, GiftRarity,
// GiftContext. Missing any → "Failed to download gifts:
// Malformed Response" (and on a purchase path, the buy
// times out because the response never deserializes).
Id = giftId,
Platform = 0,
PlatformsToSpawnOn = -1,
BalanceType = 0,
Xp = 0,
Level = 1,
GiftRarity = 0,
GiftContext = 0,
// Optional — surfaced to the watch's gift-preview UI.
FromPlayerId = (int?)null,
AvatarItemType = string.IsNullOrEmpty(avatarItemDesc) ? (int?)null : avatarItemType,
AvatarItemDesc = avatarItemDesc,
AvatarItemDescOrHairDyeDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc),
ConsumableItemDesc = consumableItemDesc,
EquipmentPrefabName = string.Empty,
EquipmentModificationGuid = string.Empty,
CurrencyType = currencyType,
Currency = 0,
Message = string.Empty,
Consumed = false,
ErrorMessage = string.Empty,
};
return new
{
// BalanceResponseDTO required trio.
Balance = balance,
CurrencyType = currencyType,
BalanceType = 0,
// PurchaseBalanceUpdateResponseDTO.BalanceUpdates =
// List<BalanceUpdateDTO<GiftPackage>>.
BalanceUpdates = new[]
{
new
{
UpdateResponse = 0, // UpdateResponseTypes.Success
Data = grantedSlug is null
? Array.Empty<object>()
: new object[] { giftPackage },
},
},
};
}
private static object RoomKeyPurchaseResponse(RoomKeyStatus status, RoomKeyEntity? key, long balance) => new
{
RoomKeyResponse = RoomKeysController.RoomKeyResponse(status, key),
BalanceUpdateResponse = new
{
Balance = balance,
CurrencyType = 2,
BalanceType = 0,
BalanceUpdates = Array.Empty<object>(),
},
};
}
@@ -0,0 +1,515 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Store;
/// <summary>
/// api.rec.net/api/storefronts/v* — real implementation backed by the
/// <see cref="StoreService"/> catalog (StoreItems table). Replaces the
/// previous AllEndpointsController stubs that returned empty lists,
/// which made the watch's Shop tab look broken.
///
/// Wire shape: every endpoint returns either a list of items (for
/// catalog endpoints) or a StorefrontDTO with a StoreItems array.
/// </summary>
[ApiController]
public class StorefrontsController(
StoreService store,
DorkNetDbContext db,
IConfiguration config,
LevelService level,
DomainConfig domain) : ControllerBase
{
/// <summary>GET api/storefronts/v3/all — full active catalog. The
/// watch's Shop tab uses this to populate every category.</summary>
[HttpGet("api/storefronts/v1/all")]
[HttpGet("api/storefronts/v2/all")]
[HttpGet("api/storefronts/v3/all")]
[AllowAnonymous]
public async Task<IActionResult> All()
{
var items = await store.GetAllActiveAsync();
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
}
/// <summary>GET api/storefronts/v3/skus — alias for the full
/// catalog. Some 2020 client paths call this instead of /all
/// during the watch's "browse all SKUs" flow.</summary>
[HttpGet("api/storefronts/v3/skus")]
[AllowAnonymous]
public async Task<IActionResult> Skus()
{
var items = await store.GetAllActiveAsync();
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
}
/// <summary>GET <c>api/storefronts/v1/p2p/betaEnabled</c> —
/// raw Boolean gate for player-to-player sales. Returning false
/// disables the beta affordance without lying about catalog state.</summary>
[HttpGet("api/storefronts/v1/p2p/betaEnabled")]
[AllowAnonymous]
public IActionResult P2PBetaEnabled() => Content("false", "application/json");
/// <summary>GET <c>api/storefronts/v1/trialInvention/duration</c>.
/// The 2020.12 client's public promise projects the Duration
/// property from this object, so the wire body is not a bare int.</summary>
[HttpGet("api/storefronts/v1/trialInvention/duration")]
[AllowAnonymous]
public IActionResult TrialInventionDuration() => Ok(new
{
Duration = Math.Max(0, config.GetValue("Store:TrialInventionDurationSeconds", 300)),
});
/// <summary>GET api/storefronts/v3/season — items currently in the
/// season-pass storefront. Empty for now since we don't run
/// seasons; returning an empty list rather than the catch-all so
/// the deserialiser sees the right shape.</summary>
[HttpGet("api/storefronts/v3/season")]
[AllowAnonymous]
public async Task<IActionResult> Season()
{
var items = await store.GetActiveByStorefrontAsync("season:1");
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
}
/// <summary>GET api/storefronts/v3/giftdrop — list of available
/// gift-drop storefront ids. The watch then queries each one via
/// /giftdropstore/{id} below to render the rotating shelf.</summary>
[HttpGet("api/storefronts/v3/giftdrop")]
[AllowAnonymous]
public IActionResult GiftDropList() =>
Ok(StoreService.GetStorefrontDefinitions()
.Where(s => s.StorefrontType is > 0)
.Select(s => s.StorefrontType!.Value)
.Distinct()
.OrderBy(i => i)
.ToArray());
/// <summary>GET api/storefronts/v3/giftdropstore/{id} — one
/// storefront shelf. The <c>{id}</c> is a value from the
/// <c>RecNet.StorefrontTypes</c> enum (verified against
/// Cpp2IL_CS/.../RecNet/StorefrontTypes.cs):
/// None=0, LaserTag=1, RecCenter=2, <b>Watch=3</b>,
/// Quest_*=100..103, RecRoyale=200, Cafe=300, Paintball=400..406,
/// Bowling=500, StuntRunner=600, DormMirror=700.
/// The Watch storefront (id=3) is the in-game Shop's Featured /
/// Clothing / Other grid. That grid assumes every gift drop has a
/// renderable AvatarItemDesc, so only wardrobe-backed rows are
/// surfaced there. Other storefronts use the canonical
/// <c>giftdrop:{id}</c> key plus the shared <c>rro</c> and
/// <c>all</c> admin keys.
/// </summary>
[HttpGet("api/storefronts/v3/giftdropstore/{id:int}")]
[AllowAnonymous]
public async Task<IActionResult> GiftDropStore(int id)
{
var (key, items) = id switch
{
3 => ($"watch", await store.GetRenderableGiftDropItemsAsync()),
_ => (StoreService.StorefrontKeyForType(id), await store.GetRoomStorefrontItemsAsync(id)),
};
return Ok(StoreService.ToStorefrontDto(key, items, domain.Apex, id));
}
/// <summary>GET api/storefronts/v1/balance/{type} — currency
/// balance for the caller. Wire shape is a list of
/// {CurrencyType, Balance, BalanceType, Platform} entries. The
/// watch displays the Balance value next to item prices.</summary>
[HttpGet("api/storefronts/v1/balance/{currencyType:int}")]
[HttpGet("api/storefronts/v2/balance/{currencyType:int}")]
[HttpGet("api/storefronts/v3/balance/{currencyType:int}")]
[HttpGet("api/storefronts/v4/balance/{currencyType:int}")]
[HttpGet("api/storefronts/v4/balance")]
[Authorize]
public async Task<IActionResult> Balance(int currencyType = 2,
[FromServices] LevelService level = null!)
{
var pid = this.RequireCurrentPlayerId();
var balance = await level.GetBalanceAsync(pid, currencyType);
return Ok(new[]
{
new
{
CurrencyType = currencyType,
BalanceType = 0,
Balance = balance,
Platform = 0,
},
});
}
/// <summary>GET <c>api/storefronts/v{1-4}/balanceAddType/{type}/{tier}</c>
/// — purchase-tier metadata for a currency. The watch uses this
/// to populate the "add tokens" tier list. Empty for a private
/// server with no IAP.</summary>
[HttpGet("api/storefronts/v1/balanceAddType/{currencyType:int}/{tierId:int}")]
[HttpGet("api/storefronts/v2/balanceAddType/{currencyType:int}/{tierId:int}")]
[HttpGet("api/storefronts/v3/balanceAddType/{currencyType:int}/{tierId:int}")]
[HttpGet("api/storefronts/v4/balanceAddType/{currencyType:int}/{tierId:int}")]
public IActionResult BalanceAddType(int currencyType, int tierId)
=> Ok(BalanceAddConfig(currencyType, tierId));
/// <summary>GET <c>api/storefronts/v2/balance</c> — every currency
/// balance for the caller in a single response. The watch's
/// wallet UI uses this to render all four currency chips at once.
/// CurrencyType per <c>CurrencyType.cs</c>: 0=Token, 1=Cheer,
/// 2=Coin, 3=Premium.</summary>
[HttpGet("api/storefronts/v2/balance")]
[Authorize]
public async Task<IActionResult> AllBalances()
{
var pid = this.RequireCurrentPlayerId();
var coin = await level.GetBalanceAsync(pid, 2);
var token = await level.GetBalanceAsync(pid, 0);
return Ok(new[]
{
new { CurrencyType = 2, BalanceType = 0, Balance = coin, Platform = 0 },
new { CurrencyType = 0, BalanceType = 0, Balance = token, Platform = 0 },
new { CurrencyType = 1, BalanceType = 0, Balance = 0L, Platform = 0 },
new { CurrencyType = 3, BalanceType = 0, Balance = 0L, Platform = 0 },
});
}
/// <summary>GET <c>api/storefronts/v1/current</c> — single active
/// storefront the watch's "today's deals" panel renders. Returns
/// the <c>main</c> storefront's items as a single PurchasableItem
/// list (bounded to keep older watches snappy).</summary>
[HttpGet("api/storefronts/v1/current")]
public async Task<IActionResult> Current()
{
if (!config.GetValue("Store:EnableWatchGiftDrops", true))
return Ok(StoreService.ToStorefrontDto("Main", Array.Empty<StoreItemEntity>(), domain.Apex));
IQueryable<StoreItemEntity> query = db.StoreItems
.Where(s => s.IsActive && s.Storefront == "main")
.OrderByDescending(s => s.UpdatedAt);
var max = config.GetValue("Store:MaxCurrentStorefrontItems", 96);
if (max > 0) query = query.Take(max);
var rows = await query.ToListAsync();
return Ok(StoreService.ToStorefrontDto("Main", rows, domain.Apex));
}
/// <summary>GET <c>api/storefronts/v1/season/{seasonId}</c> —
/// season-pass tier list, sourced from StoreItems tagged
/// <c>Storefront = "season:{id}"</c>.</summary>
[HttpGet("api/storefronts/v1/season/{seasonId:int}")]
public async Task<IActionResult> Season(int seasonId)
{
var key = $"season:{seasonId}";
var tiers = await db.StoreItems
.Where(s => s.IsActive && s.Storefront == key)
.OrderBy(s => s.Price)
.Select(s => new
{
TierId = s.Id,
s.DisplayName,
s.Description,
s.Price,
CurrencyType = s.CurrencyType,
s.ImageName,
})
.ToListAsync();
return Ok(new
{
SeasonId = seasonId,
SeasonType = seasonId,
Active = tiers.Count > 0,
StartsAt = DateTime.UtcNow.AddDays(-30),
EndsAt = DateTime.UtcNow.AddDays(30),
Tiers = tiers,
});
}
/// <summary>GET <c>api/storefronts/v1/objectives</c> — season
/// objective progress for the caller, keyed
/// <c>season:{n}</c>.</summary>
[HttpGet("api/storefronts/v1/objectives")]
public async Task<IActionResult> Objectives()
{
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(Array.Empty<object>());
var rows = await db.ObjectiveProgress
.Where(o => o.PlayerId == me && o.Key.StartsWith("season:"))
.Select(o => new { Key = o.Key, IsCompleted = o.IsCompleted, ClearedAt = o.ClearedAt })
.ToListAsync();
return Ok(rows);
}
/// <summary>POST <c>api/storefronts/v1/objectives</c> —
/// <c>Storefronts.CompleteObjectives</c> uploads completion
/// records and expects a <c>BalanceUpdateResponseDTO</c> back.
/// We ship the wrapper with the required Balance/CurrencyType/
/// BalanceType trio so <c>BalanceResponseDTO.Deserialize</c>
/// doesn't throw, plus an empty <c>BalanceUpdates</c>.</summary>
[HttpPost("api/storefronts/v1/objectives")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
[Authorize]
public async Task<IActionResult> CompleteObjectives()
{
var pid = this.RequireCurrentPlayerId();
var records = await ReadStorefrontObjectiveRecordsAsync();
var total = 0;
foreach (var record in records ?? new())
{
if (record.CompletionPercentage <= 0f) continue;
var rewardKey = $"storefront-objective:{record.ObjectiveType}:{DateTime.UtcNow:yyyyMMdd}";
if (await MarkRewardOnceAsync(pid, rewardKey))
{
var award = record.ObjectiveType switch
{
10 or 11 or 12 => 25,
13 => 150,
14 => 50,
15 => 75,
4000 or 4001 => 50,
_ => Math.Max(5, (int)Math.Round(25 * Math.Clamp(record.CompletionPercentage, 0f, 1f))),
};
total += award;
}
}
if (total > 0)
{
await level.AwardXpAsync(pid, total, "storefront-objectives");
await level.GrantCurrencyAsync(pid, 2, total, "storefront-objectives");
}
var balance = await level.GetBalanceAsync(pid, 2);
return Ok(BalanceUpdateResponse(balance, 2, new[]
{
RewardModification(100, total, total, currentCount: total > 0 ? 1 : 0),
}));
}
public sealed class StorefrontObjectiveCompletionRecord
{
public int ObjectiveType { get; set; }
public float CompletionPercentage { get; set; }
public long? RoomId { get; set; }
}
public sealed class GrantBalanceRewardDto
{
public int CurrencyType { get; set; } = 2;
public List<GrantBalanceRequest> BalanceAdds { get; set; } = new();
}
public sealed class GrantBalanceRequest
{
public float Multiplier { get; set; } = 1f;
public int BalanceAddType { get; set; } = 1;
}
[HttpPost("api/storefronts/v2/balance")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
[Authorize]
public async Task<IActionResult> ModifyBalance()
{
var body = await ReadGrantBalanceRewardAsync();
var pid = this.RequireCurrentPlayerId();
var currencyType = body.CurrencyType == 0 ? 2 : body.CurrencyType;
var modifications = new List<object>();
var total = 0;
foreach (var add in body.BalanceAdds ?? new())
{
var config = BalanceAddConfigValues(currencyType, add.BalanceAddType);
var multiplier = Math.Clamp(add.Multiplier <= 0 ? 1f : add.Multiplier, 0f, config.MaxPartialMultiplier);
var baseAward = config.IgnorePartialMultiplier
? config.BaseAward
: (int)Math.Round(config.BaseAward * multiplier);
baseAward = Math.Max(0, baseAward);
if (baseAward == 0) continue;
total += baseAward;
modifications.Add(RewardModification(add.BalanceAddType, baseAward, baseAward, currentCount: 1));
}
if (total > 0)
{
await level.GrantCurrencyAsync(pid, currencyType, total, "storefront-balance-reward");
await level.AwardXpAsync(pid, Math.Max(10, total / 2), "storefront-balance-reward");
}
var balance = await level.GetBalanceAsync(pid, currencyType);
return Ok(BalanceUpdateResponse(balance, currencyType, modifications));
}
public sealed class BalanceAddTypeRequest { public long Amount { get; set; } }
/// <summary>POST <c>api/storefronts/v1/balanceAddType/{type}/{playerId}</c>
/// — admin-only currency credit. Grants via
/// <see cref="LevelService.GrantCurrencyAsync"/>.</summary>
[HttpPost("api/storefronts/v1/balanceAddType/{type:int}/{playerId:long}")]
[Authorize]
public async Task<IActionResult> BalanceAddTypeAdmin(int type, long playerId, [FromBody] BalanceAddTypeRequest? body)
{
var me = this.RequireCurrentPlayerId();
var isAdmin = await db.Players.Where(p => p.Id == me).Select(p => p.IsAdmin).FirstOrDefaultAsync();
if (!isAdmin) return Forbid();
var amount = body?.Amount ?? 0L;
if (amount == 0) return Ok(new { Added = 0L });
var newBalance = await level.GrantCurrencyAsync(playerId, type, amount, $"balanceAddType:{me}");
return Ok(new
{
CurrencyType = type,
Balance = newBalance,
BalanceType = 0,
Platform = 0,
});
}
private static object BalanceAddConfig(int currencyType, int balanceAddType)
{
var cfg = BalanceAddConfigValues(currencyType, balanceAddType);
return new
{
CurrencyType = currencyType,
BalanceAddType = balanceAddType,
cfg.BaseAward,
BonusAwardMin = 0,
BonusAwardMax = 0,
RateLimitType = 0,
IgnorePartialMultiplier = cfg.IgnorePartialMultiplier,
MaxPartialMultiplier = (int)Math.Ceiling(cfg.MaxPartialMultiplier),
RateLimit = 0,
BalanceInGiftBox = false,
};
}
private static (int BaseAward, float MaxPartialMultiplier, bool IgnorePartialMultiplier)
BalanceAddConfigValues(int currencyType, int balanceAddType) => balanceAddType switch
{
1 => (100, 10f, false), // DirectBalanceWithMultiplier: Stunt Runner / banked loot.
10 => (25, 1f, true), // NUXChallenge.
11 => (100, 1f, true), // AllNUXChallenges.
100 => (25, 1f, true), // DailyChallenge.
101 => (150, 1f, true), // AllDailyChallenges.
200 => (50, 1f, true), // FinishActivity.
1000 => (100, 1f, true), // WonGame.
1001 => (25, 1f, true), // LostGame.
_ => (25, 1f, true),
};
private async Task<GrantBalanceRewardDto> ReadGrantBalanceRewardAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var dto = new GrantBalanceRewardDto
{
CurrencyType = int.TryParse(form["CurrencyType"], out var ct) ? ct : 2,
};
if (int.TryParse(form["BalanceAddType"], out var bat) || int.TryParse(form["balanceAddType"], out bat))
{
dto.BalanceAdds.Add(new GrantBalanceRequest
{
BalanceAddType = bat,
Multiplier = float.TryParse(form["Multiplier"], out var mult) ? mult : 1f,
});
}
return dto;
}
try
{
var dto = await JsonSerializer.DeserializeAsync<GrantBalanceRewardDto>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return dto ?? new GrantBalanceRewardDto();
}
catch
{
return new GrantBalanceRewardDto();
}
}
private async Task<List<StorefrontObjectiveCompletionRecord>> ReadStorefrontObjectiveRecordsAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
if (!int.TryParse(form["objectiveType"], out var objectiveType)
&& !int.TryParse(form["ObjectiveType"], out objectiveType))
{
return new();
}
return new()
{
new StorefrontObjectiveCompletionRecord
{
ObjectiveType = objectiveType,
CompletionPercentage = float.TryParse(form["completionPercentage"], out var pct)
|| float.TryParse(form["CompletionPercentage"], out pct)
? pct
: 1f,
RoomId = long.TryParse(form["roomId"], out var roomId)
|| long.TryParse(form["RoomId"], out roomId)
? roomId
: null,
},
};
}
try
{
var rows = await JsonSerializer.DeserializeAsync<List<StorefrontObjectiveCompletionRecord>>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return rows ?? new();
}
catch
{
return new();
}
}
private async Task<bool> MarkRewardOnceAsync(long playerId, string rewardKey)
{
var key = $"reward:{rewardKey}";
if (await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == playerId && o.Key == key && o.IsCompleted))
return false;
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = playerId,
Key = key,
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return true;
}
private static object RewardModification(int balanceAddType, int baseAward, int total, int currentCount) => new
{
BalanceAddType = balanceAddType,
BaseAward = baseAward,
BonusAward = 0,
RateLimit = 0,
CurrentCount = currentCount,
Total = total,
BalanceType = 0,
BalanceInGiftBox = false,
};
private static object BalanceUpdateResponse(long balance, int currencyType, IEnumerable<object> modifications) => new
{
Balance = balance,
CurrencyType = currencyType,
BalanceType = 0,
BalanceUpdates = new[]
{
new
{
UpdateResponse = 0,
Data = modifications.Where(m => m is not null).ToArray(),
},
},
};
}