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)); } }