using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.ProgressionEvents;
[ApiController]
public class ProgressionEventsController(
DorkNetDbContext db,
ServerSettingsService serverSettings,
IConfiguration config) : ControllerBase
{
/// CurrencyType enum value the rest of the server uses for
/// RecCenter tokens (mirrors ProgressionController.RecCenterTokens ).
private const int RecCenterTokens = 2;
[HttpGet("api/progressionEvents")]
[Authorize]
public async Task Progress()
{
var me = this.RequireCurrentPlayerId();
var rows = await db.ObjectiveProgress
.Where(o => o.PlayerId == me && o.Key.StartsWith("progressionEvent:"))
.OrderByDescending(o => o.ClearedAt)
.Take(100)
.Select(o => new
{
EventKey = o.Key,
o.IsCompleted,
o.ClearedAt,
})
.ToListAsync();
return Ok(rows);
}
[HttpGet("api/progressionEvents/event/{eventId:long}")]
[AllowAnonymous]
public async Task Event(long eventId)
{
var activeId = CurrentProgressionEventId();
if (eventId != activeId)
return NotFound();
var weekly = await serverSettings.GetWeeklyChallengesAsync();
var (start, end) = CurrentWeekWindow();
// The boost fields must agree with api/progressionEvents/{id}/xpboosts:
// ProgressionEventsPurchasableXpBoostModel.Set() takes the guid the
// event advertises here and looks it up in the xpboosts list, and
// ProgressionEventsModel.GetPurchasableXpBoostId() gates the whole
// boost sheet on PurchasableXpBoostId being non-null. Deriving both
// from one catalogue keeps them from drifting.
var boosts = BoostCatalogue(activeId);
var headline = boosts.Count > 0 ? boosts[0] : null;
return Ok(new
{
ProgressionEventId = activeId,
Name = "Weekly Progression",
Rewards = BuildRewards(weekly.Reward, activeId),
KeepsakeRoomLists = Array.Empty(),
StartTime = start,
EndTime = end,
CollectionEndTime = end,
UsesBoost = headline is not null,
BoostDailyGameplayMinutesLimit = 0,
// ProgressionEventDTO.BoostXpMultiplier is a float at +0x50
// (dump.cs:1198957) — emit a real number, not an integer token.
BoostXpMultiplier = (double)(headline?.XpMultiplier ?? 1),
PurchasableXpBoostId = headline?.Id,
ActiveExperiment = string.Empty,
ChallengesIconImageName = string.Empty,
RewardsPipImageName = string.Empty,
EventInfoImageName = string.Empty,
});
}
[HttpGet("api/progressionEvents/active")]
[AllowAnonymous]
public IActionResult Active() => Ok(CurrentProgressionEventId());
///
/// The current player's progress record for a given progression event.
/// The 2023 client fetches this during InitialRoomLoad
/// (api/progressionEvents/record/{id} , where {id} is the
/// yyyyMMdd event id). A 404 here faults the room-load coroutine
/// with a NullReferenceException and traps the client in a dorm
/// matchmaking loop, so we always return a non-null record.
///
[HttpGet("api/progressionEvents/record/{progressionEventId:long}")]
[Authorize]
public async Task Record(long progressionEventId)
{
var me = this.RequireCurrentPlayerId();
// Count the reward-claim sentinels this player owns FOR THIS EVENT
// (written by Collect below). The previous query counted every
// `progressionEvent:` row, which also matches the xpBoost purchase
// sentinels StorefrontsBuyController writes — a boost purchase
// silently advanced the claimed-reward pip.
var claimPrefix = RewardClaimPrefix(progressionEventId);
var collected = await db.ObjectiveProgress
.CountAsync(o => o.PlayerId == me
&& o.IsCompleted
&& o.Key.StartsWith(claimPrefix));
return Ok(new
{
ProgressionEventId = progressionEventId,
Xp = 0,
ClaimedRewardIndex = collected > 0 ? collected - 1 : -1,
PurchasedXpBoostCount = 0,
DailyBoostGameplayMinutes = 0,
XpBoostExpiresAt = (DateTime?)null,
});
}
// ── Reward collection ────────────────────────────────────────────────
///
/// POST api/progressionEvents/collect/{eventId}/{rewardIndex} —
/// claim one reward chest off the progression track.
///
/// Binary evidence: RecNet.Runtime/CDBFONFHJDO.txt:1208
/// (GKANPKEGBGE(Int64 eventId, Int32 rewardIndex) ) formats
/// "{0}/collect/{1}/{2}" against "api/progressionEvents"
/// (ISIL 041/043) and moves 2 = HTTPMethods.Post into the
/// verb argument at ISIL 055. No request body is passed — the two route
/// segments are the whole request.
///
/// The return type is FGLDKEJLAKB<FFFIMAGLKEG/FHMABOHAEED> ,
/// and FHMABOHAEED is the client's GiftPackage DTO — the very
/// same object GiftManager.RunConsumeGift /
/// GiftManager.DequeueGift take
/// (Assembly-CSharp/GiftManager.txt:3226,3395 ), whose wire keys are
/// Id / FromPlayerId / ConsumableItemDesc / AvatarItemType /
/// AvatarItemDesc / EquipmentPrefabName / EquipmentModificationGuid /
/// CurrencyType / Currency / Xp / GiftContext / GiftRarity / Message /
/// Platform / PlatformsToSpawnOn / BalanceType
/// (RecNet.Runtime/FKANCKLCCDI.txt:1051-1390 ).
///
/// That means the client will POST the returned Id back to
/// api/avatar/v2/gifts/consume , so this MUST persist a real
/// rather than synthesise a wire blob —
/// an unpersisted Id 404s on consume and the player loses the reward.
/// The payload itself comes from the admin-configured weekly reward
/// ( ), i.e. exactly what
/// advertises on the track, so the chest hands over
/// what the UI promised. XP / tokens / level ride on the package and are
/// granted by the existing consume path, not here — granting in both
/// places would double-pay.
///
/// Idempotent: the claim sentinel is an
/// whose Key embeds the created gift id, so a retried or duplicated POST
/// returns the original package instead of minting a second one.
/// No GiftPackageReceived push is sent (unlike
/// avatar/v2/gifts/generate ): the RRUI reward flow already spawns
/// the box from this response, and a push would spawn a second one.
///
[HttpPost("api/progressionEvents/collect/{eventId:long}/{rewardIndex:int}")]
[Authorize]
public async Task Collect(long eventId, int rewardIndex)
{
var me = this.RequireCurrentPlayerId();
// Only the live event's track is claimable — the client only ever
// renders the active event, and letting an arbitrary id through
// would let a replayed request re-roll last week's chest.
var activeId = CurrentProgressionEventId();
if (eventId != activeId) return NotFound();
var weekly = await serverSettings.GetWeeklyChallengesAsync();
var rewards = BuildRewards(weekly.Reward, activeId);
if (rewardIndex < 0 || rewardIndex >= rewards.Count) return NotFound();
// Already claimed? The sentinel key carries the gift id so we can
// hand back the same package (the client may retry after a dropped
// response, and it needs a consumable Id either way).
var sentinelPrefix = $"{RewardClaimPrefix(eventId)}{rewardIndex}:gift:";
var sentinel = await db.ObjectiveProgress
.FirstOrDefaultAsync(o => o.PlayerId == me && o.Key.StartsWith(sentinelPrefix));
if (sentinel is not null)
{
if (long.TryParse(sentinel.Key[sentinelPrefix.Length..], out var priorId))
{
var prior = await db.GiftPackages
.FirstOrDefaultAsync(g => g.Id == priorId && g.RecipientPlayerId == me);
if (prior is not null)
return Ok(global::DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(prior));
}
// Sentinel exists but its package was purged — drop the stale
// marker and fall through to re-issue, otherwise the player is
// permanently locked out of a reward they never received.
db.ObjectiveProgress.Remove(sentinel);
await db.SaveChangesAsync();
}
var reward = weekly.Reward;
var gift = new GiftPackageEntity
{
RecipientPlayerId = me,
FromPlayerId = null,
// The weekly reward config carries a raw avatar desc, which is
// always an outfit item (AvatarItemType 0); hair dyes are
// configured through ConsumableItemDesc instead. Leave the type
// null when there is no avatar payload so the client doesn't
// render an empty outfit tile.
AvatarItemType = string.IsNullOrWhiteSpace(reward.AvatarItemDesc) ? (int?)null : 0,
AvatarItemDescOrHairDyeDesc = reward.AvatarItemDesc,
ConsumableItemDesc = reward.ConsumableItemDesc,
EquipmentPrefabName = reward.EquipmentPrefabName,
EquipmentModificationGuid = reward.EquipmentModificationGuid,
CurrencyType = reward.Tokens > 0 ? RecCenterTokens : 0,
Currency = reward.Tokens,
Xp = reward.Xp,
Level = reward.Level,
GiftContext = reward.GiftContext,
GiftRarity = reward.GiftRarity,
Message = $"Progression event reward {rewardIndex + 1}",
Platform = -1,
PackageVariant = "Standard",
PackageMaterial = string.Empty,
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
await db.SaveChangesAsync();
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = me,
Key = $"{sentinelPrefix}{gift.Id}",
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return Ok(global::DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift));
}
// ── Purchasable XP boosts ────────────────────────────────────────────
///
/// GET api/progressionEvents/{eventId}/xpboosts — the boost
/// offers the client's purchase sheet renders.
///
/// Binary evidence: RecNet.Runtime/CDBFONFHJDO.txt:1465
/// (HKMBEFABIBD(Int64 eventId) ) formats
/// "{0}/{1}/xpboosts" against "api/progressionEvents"
/// (ISIL 026/028) and moves 0 = HTTPMethods.Get into the
/// verb argument at ISIL 034. The declared return type is
/// List<ProgressionEventPurchasableXpBoostDTO> , i.e. a bare
/// JSON array — no paged container.
///
/// Element keys and their order come from the generated Utf8Json
/// formatter RecNet.Runtime/OFGIPBHIAGF.txt:605-698 :
/// ProgressionEventPurchasableXpBoostId (Guid), Cost (Int32),
/// XpMultiplier (Int32 — get_XpMultiplierFromBoost reads the DTO's
/// +0x24 int field, NOT a float), XpCap (Int32), LookbackDurationTicks
/// (Int64), CooldownDurationTicks (Int64).
///
/// The boost id must be STABLE across calls: the client caches it from
/// here, passes it to previewEarnedXp , and posts it as
/// purchasableXpBoostId to
/// api/storefronts/v1/buyProgressionEventXpBoost (which keys its
/// purchase sentinel on it). See .
///
[HttpGet("api/progressionEvents/{eventId:long}/xpboosts")]
[AllowAnonymous]
public IActionResult XpBoosts(long eventId) =>
Ok(BoostCatalogue(eventId).Select(b => new
{
ProgressionEventPurchasableXpBoostId = b.Id,
b.Cost,
b.XpMultiplier,
b.XpCap,
b.LookbackDurationTicks,
b.CooldownDurationTicks,
}));
///
/// GET api/progressionEvents/{eventId}/xpboosts/{boostId}/previewEarnedXp
/// — how much XP buying this boost right now would hand the caller.
///
/// Binary evidence: RecNet.Runtime/CDBFONFHJDO.txt:1612
/// (GBNFGMOGLNG(Int64 eventId, Guid boostId) ) formats
/// "{0}/{1}/xpboosts/{2}/previewEarnedXp" against
/// "api/progressionEvents" (ISIL 036/038) and moves 0 =
/// HTTPMethods.Get into the verb argument at ISIL 049. The return
/// type is FGLDKEJLAKB<System.Int32> — a BARE integer body,
/// not an object; the value lands in
/// ProgressionEventsPurchasableXpBoostModel.get_XpEarnedFromBoost
/// and is echoed back as the expectedXp form field of
/// buyProgressionEventXpBoost , which grants exactly that much.
///
/// Cooldown is real state: buyProgressionEventXpBoost stamps
/// progressionEvent:{eventId}:xpBoost:{guid:N} in ObjectiveProgress
/// on every purchase, so a boost still inside its
/// CooldownDurationTicks window previews as 0 and the client's
/// get_XPBoostIsAvailableForPurchase keeps the button disabled.
///
/// Fidelity note: live Rec Room multiplied the XP the player actually
/// earned during LookbackDurationTicks . DorkNet has no timestamped
/// XP ledger ( mutates
/// PlayerEntity.XP in place), so a boost here pays its configured
/// XpCap — the cap is the payout, and XpMultiplier is the
/// figure the sheet renders as "Nx".
///
[HttpGet("api/progressionEvents/{eventId:long}/xpboosts/{boostId:guid}/previewEarnedXp")]
[Authorize]
public async Task PreviewEarnedXp(long eventId, Guid boostId)
{
var me = this.RequireCurrentPlayerId();
var offer = BoostCatalogue(eventId).FirstOrDefault(b => b.Id == boostId);
if (offer is null) return Ok(0);
// Same key StorefrontsBuyController.BuyProgressionEventXpBoost writes.
var purchaseKey = $"progressionEvent:{eventId}:xpBoost:{boostId:N}";
var lastPurchase = await db.ObjectiveProgress
.Where(o => o.PlayerId == me && o.Key == purchaseKey)
.Select(o => o.ClearedAt)
.FirstOrDefaultAsync();
if (offer.CooldownDurationTicks > 0
&& lastPurchase is DateTime at
&& DateTime.UtcNow - at < TimeSpan.FromTicks(offer.CooldownDurationTicks))
{
return Ok(0);
}
return Ok(offer.XpCap);
}
// ── Shared shape helpers ─────────────────────────────────────────────
/// One pip on the progression track. Field types are taken from
/// ProgressionEventRewardDTO (dump.cs:1199338): ProgressionEventRewardId
/// is Int64 at +0x10 but GiftDropId is Int32 at +0x18 (the audit doc
/// says Int64 — the binary disagrees), then string ImageName, Int32 Xp,
/// Int32 RewardIndex, bool IsBonus.
public sealed record EventReward(
long ProgressionEventRewardId,
int GiftDropId,
string ImageName,
int Xp,
int RewardIndex,
bool IsBonus);
/// The progression track's reward list. One source of truth for
/// (which renders the pips) and
/// (which bounds-checks the claimed index), so the two can't drift into a
/// state where the client can see a chest the server refuses to open.
private static IReadOnlyList BuildRewards(WeeklyChallengeReward reward, long activeId) =>
[
new EventReward(
ProgressionEventRewardId: activeId * 1000L,
// yyyyMMdd fits Int32 comfortably, so the event id doubles as a
// stable placeholder drop id when no reward drop is configured.
GiftDropId: reward.GiftDropId != 0 ? reward.GiftDropId : (int)activeId,
ImageName: string.Empty,
Xp: reward.Xp,
RewardIndex: 0,
IsBonus: false),
];
private static string RewardClaimPrefix(long eventId) => $"progressionEvent:{eventId}:reward:";
// ── XP-boost catalogue ───────────────────────────────────────────────
public sealed record XpBoostOffer(
Guid Id,
int Cost,
int XpMultiplier,
int XpCap,
long LookbackDurationTicks,
long CooldownDurationTicks);
/// The boosts this deployment sells for a given event.
///
/// Configured under ProgressionEvents:XpBoosts as an array of
/// {Cost, XpMultiplier, XpCap, LookbackMinutes, CooldownMinutes} ;
/// with nothing configured we fall back to a single built-in offer (same
/// pattern as
/// and the built-in NUX checklist). Set
/// ProgressionEvents:EnableXpBoosts to false to sell none — the
/// event then reports UsesBoost=false and the sheet stays shut.
private IReadOnlyList BoostCatalogue(long eventId)
{
if (!config.GetValue("ProgressionEvents:EnableXpBoosts", true))
return [];
var configured = config.GetSection("ProgressionEvents:XpBoosts").GetChildren().ToList();
if (configured.Count == 0)
{
return
[
new XpBoostOffer(
Id: OfferId(eventId, 0),
Cost: 250,
XpMultiplier: 2,
XpCap: 500,
LookbackDurationTicks: TimeSpan.FromHours(1).Ticks,
CooldownDurationTicks: TimeSpan.FromHours(24).Ticks),
];
}
var offers = new List(configured.Count);
for (var i = 0; i < configured.Count; i++)
{
var section = configured[i];
var cap = Math.Max(0, section.GetValue("XpCap", 500));
offers.Add(new XpBoostOffer(
Id: OfferId(eventId, i),
Cost: Math.Max(0, section.GetValue("Cost", 250)),
XpMultiplier: Math.Max(1, section.GetValue("XpMultiplier", 2)),
XpCap: cap,
LookbackDurationTicks: TimeSpan.FromMinutes(
Math.Max(0, section.GetValue("LookbackMinutes", 60))).Ticks,
CooldownDurationTicks: TimeSpan.FromMinutes(
Math.Max(0, section.GetValue("CooldownMinutes", 1440))).Ticks));
}
return offers;
}
/// Deterministic per-(event, slot) boost id. The client caches
/// the guid from and replays it to
/// and to
/// storefronts/v1/buyProgressionEventXpBoost , whose purchase /
/// cooldown sentinel is keyed on it — a freshly generated guid per request
/// would make every preview miss its own catalogue entry and reset the
/// cooldown on every call. Derived rather than stored because the boost
/// catalogue itself lives in configuration, not the database.
private static Guid OfferId(long eventId, int index) =>
new(System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes($"dorknet:progressionEventXpBoost:{eventId}:{index}"))
.AsSpan(0, 16));
private static long CurrentProgressionEventId()
{
var (start, _) = CurrentWeekWindow();
return long.Parse(start.ToString("yyyyMMdd"));
}
private static (DateTime Start, DateTime End) CurrentWeekWindow()
{
var start = DateTime.UtcNow.Date.AddDays(-(int)DateTime.UtcNow.DayOfWeek);
return (start, start.AddDays(7));
}
}