using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using DorkNet.Server.Auth; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.ProgressionApi; /// /// api.rec.net/api/{challenge,checklist,objectives}/* — daily /// challenges, onboarding checklist, and the full objectives surface /// (myprogress, cleargroup, updateobjective, completegroup). All /// concepts share the same persistence model: /// rows keyed by a prefix: /// challenge:{id}, checklist:{id}, group:{id}, /// obj:{group}:{index}. /// [ApiController] public class ProgressionController( DorkNetDbContext db, LevelService level, ServerSettingsService serverSettings, StoreService store) : ControllerBase { private long Me => this.RequireCurrentPlayerId(); private long? MeOrNull => this.CurrentPlayerId(); private const int RecCenterTokens = 2; // ── Challenges ─────────────────────────────────────────────────────── /// GET api/challenge/v{1,2}/getCurrent — returns /// the active daily-challenge map (NOT a flat challenge object). /// Verified against /// Cpp2IL_ISIL/.../RecNet/Challenges.txt:105-184 /// (GetCurrentChallenges hits this URL and parses as /// ChallengeMap). Wire shape per /// RecNet/ChallengeMap.txt:540-616: /// /// ChallengeMapId (int, required), CompletedRequired /// (bool, required), StartAt/EndAt/ServerTime /// (DateTime, required), Challenges (List<RecNetChallenge>, /// required), Gift (ChallengeGift, required), /// ChallengeThemeString (string, optional). /// /// Each RecNetChallenge requires only ChallengeId; /// Name/Config/Description/Tooltip/Complete /// are optional. Each ChallengeGift requires GiftDropId, /// Xp, Level, GiftContext, GiftRarity. /// /// Previous flat-challenge response crashed /// ChallengeMap.Deserialize at Util.GetKey("ChallengeMapId"). [HttpGet("api/challenge/v1/getCurrent")] [HttpGet("api/challenge/v2/getCurrent")] public async Task CurrentChallenge() { var pid = MeOrNull; var now = DateTime.UtcNow; var start = now.Date.AddDays(-(int)now.DayOfWeek); var mapId = CurrentChallengeMapId(); var weekly = await serverSettings.GetWeeklyChallengesAsync(); var defs = weekly.Challenges; var keys = defs.Select(c => ChallengeProgressKey(mapId, c.Index)).ToArray(); var rows = pid is long me && keys.Length > 0 ? await db.ObjectiveProgress .Where(o => o.PlayerId == me && keys.Contains(o.Key)) .ToDictionaryAsync(o => o.Key, o => o.IsCompleted) : new Dictionary(); bool Done(int idx) => rows.TryGetValue(ChallengeProgressKey(mapId, idx), out var v) && v; var reward = weekly.Reward; return Ok(new { ChallengeMapId = mapId, CompletedRequired = weekly.CompletedRequired, StartAt = start, EndAt = start.AddDays(7), ServerTime = now, ChallengeThemeString = "Weekly", Challenges = defs.Select(c => new { ChallengeId = ChallengeId(mapId, c.Index), c.Name, c.Config, c.Description, c.Tooltip, Complete = Done(c.Index), }), Gift = BuildGiftDto(mapId, reward), }); } /// Project a onto the /// 2020.03 ChallengeGift wire shape (dump.cs ChallengeGift, /// TypeDefIndex 11455). AvatarItemType is sent as /// Outfit (0) whenever an AvatarItemDesc is present and /// null otherwise — the client's gift-card render binds the /// avatar preview through it, and an AvatarItemDesc with no /// AvatarItemType leaves that binding half-populated (same /// failure mode StoreService documents for the shop grid). private static object BuildGiftDto(int mapId, WeeklyChallengeReward reward) => new { GiftDropId = reward.GiftDropId != 0 ? reward.GiftDropId : mapId, reward.AvatarItemDesc, AvatarItemType = string.IsNullOrEmpty(reward.AvatarItemDesc) ? (int?)null : StoreAvatarItemTypeOutfit, reward.ConsumableItemDesc, reward.EquipmentPrefabName, reward.EquipmentModificationGuid, Xp = reward.Xp, reward.Level, GiftContext = reward.GiftContext, GiftRarity = reward.GiftRarity, }; /// AvatarItemType.Outfit — kept in sync with /// StoreService's constant of the same value. private const int StoreAvatarItemTypeOutfit = 0; // ObjectiveProgress row key for a weekly challenge slot. private static string ChallengeProgressKey(int mapId, int index) => $"challenge:{mapId}:{index}"; // Stable per-map ChallengeId the watch round-trips: mapId*10 + slot // index. Caps the slate at 10 challenges (NormalizeWeeklyChallenges // enforces the same Take(10)). private static int ChallengeId(int mapId, int index) => mapId * 10 + index; // Recover the slot index from whatever the watch echoes back. Prefer // the mapId*10 + index encoding; fall back to the low digit for // clients that send a bare id. Clamp into the configured range. private static int ChallengeIndex(int mapId, int challengeId, int challengeCount) { if (challengeId >= mapId * 10 && challengeId < mapId * 10 + 10) return Math.Clamp(challengeId - mapId * 10, 0, challengeCount - 1); return Math.Clamp(Math.Abs(challengeId % 10), 0, challengeCount - 1); } public sealed class UpdateChallengeRequest { public int Id { get; set; } public int ChallengeMapId { get; set; } public int ChallengeId { get; set; } public int Progress { get; set; } } [HttpPost("api/challenge/v2/updateProgress")] [Authorize] public async Task UpdateChallengeProgress([FromForm] UpdateChallengeRequest formReq) { var pid = Me; var req = await ReadChallengeRequestAsync(formReq); var mapId = req.ChallengeMapId != 0 ? req.ChallengeMapId : CurrentChallengeMapId(); var challengeId = req.ChallengeId != 0 ? req.ChallengeId : req.Id; var weekly = await serverSettings.GetWeeklyChallengesAsync(); var challengeCount = Math.Max(1, weekly.Challenges.Count); var index = ChallengeIndex(mapId, challengeId, challengeCount); var key = ChallengeProgressKey(mapId, index); var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key); if (row is null) { row = new ObjectiveProgressEntity { PlayerId = pid, Key = key }; db.ObjectiveProgress.Add(row); } if (!row.IsCompleted) { row.IsCompleted = true; row.ClearedAt = DateTime.UtcNow; } await db.SaveChangesAsync(); var weeklyRewarded = false; var allKeys = weekly.Challenges .Select(c => ChallengeProgressKey(mapId, c.Index)) .ToArray(); var completeCount = await db.ObjectiveProgress .CountAsync(o => o.PlayerId == pid && allKeys.Contains(o.Key) && o.IsCompleted); if (completeCount >= allKeys.Length) { var reward = weekly.Reward; weeklyRewarded = await GrantRewardOnceAsync( pid, $"weekly-challenge:{mapId}", xp: reward.Xp, tokens: reward.Tokens, reason: $"weekly_challenge:{mapId}"); // Land the actual skin/consumable, not just XP + tokens — the // December build only granted currency, so configured item // rewards showed on the watch but never reached the player's // inventory. Gated behind the same once-only sentinel so a // re-submit doesn't re-grant: only grant the item on the boot // transition where currency was also granted. if (weeklyRewarded && !string.IsNullOrWhiteSpace(reward.Slug)) await store.GrantItemFreeBySlugAsync(pid, reward.Slug); } return Ok(new { ChallengeMapId = mapId, ChallengeId = challengeId, Progress = Math.Max(req.Progress, 10), IsCompleted = true, ClearedAt = row.ClearedAt, Rewarded = weeklyRewarded, }); } // ── Checklist ──────────────────────────────────────────────────────── /// GET api/checklist/v{1,2}/current — returns /// a flat List<ChecklistObjective>, NOT a wrapper /// object. Wire shape per /// Cpp2IL_CS/.../RecNet/Checklist.cs: /// Order(int), Objective(ObjectiveType int), Count(int), /// CreditAmount(int). All four keys are required /// (Util.GetKey, not OrDefault). /// /// Completion state is tracked separately by the client via /// rows it cross-references — we /// don't put a per-item "done" flag on the wire; the client /// matches by Order. Returning an empty list ([]) is also valid /// per the deserialiser (List wrapper accepts empty). [HttpGet("api/checklist/v1/current")] [HttpGet("api/checklist/v2/current")] public IActionResult CurrentChecklist() { // Default NUX checklist for a brand-new account. Each row // maps an in-game step to an ObjectiveType enum value // (decompiled from ProgressionManager.cs). return Ok(new[] { new { Order = 0, Objective = 38 /* SaveOutfitSlot */, Count = 1, CreditAmount = 25 }, new { Order = 1, Objective = 32 /* VisitACustomRoom */, Count = 1, CreditAmount = 25 }, new { Order = 2, Objective = 2 /* AddAFriend */, Count = 1, CreditAmount = 25 }, new { Order = 3, Objective = 30 /* GoToRecCenter */, Count = 1, CreditAmount = 25 }, new { Order = 4, Objective = 6 /* CheerAPlayer */, Count = 1, CreditAmount = 25 }, }); } public sealed class CompleteChecklistRequest { public int Id { get; set; } public int ItemIndex { get; set; } } [HttpPost("api/checklist/v1/complete")] [HttpPost("api/checklist/v2/complete")] [Authorize] public async Task CompleteChecklist([FromBody] CompleteChecklistRequest req) { var pid = Me; var id = req.ItemIndex != 0 ? req.ItemIndex : req.Id; var key = $"checklist:{id}"; var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key); if (row is null) { row = new ObjectiveProgressEntity { PlayerId = pid, Key = key }; db.ObjectiveProgress.Add(row); } row.IsCompleted = true; row.ClearedAt = DateTime.UtcNow; await db.SaveChangesAsync(); var rewarded = await GrantRewardOnceAsync( pid, $"checklist:{id}", xp: 25, tokens: 25, reason: $"checklist:{id}"); var balance = await level.GetBalanceAsync(pid, RecCenterTokens); return Ok(BalanceUpdateResponse(balance, RecCenterTokens, rewarded ? 25 : 0, 303)); } // ── Objectives bulk-complete (real persistence) ────────────────────── /// POST api/objectives/v1/completegroup — replaces /// the previous shape-stub in MissingEndpointsController. Now /// upserts a real row keyed /// by group:{id}. public sealed class UpdateObjectiveRequest { public int Group { get; set; } public int Index { get; set; } public float Progress { get; set; } public bool IsCompleted { get; set; } } /// POST api/objectives/v1/updateobjective — per- /// objective progress update. Was previously caught by the /// namespace wildcard returning []; now persists to /// keyed /// obj:{group}:{index} so progress survives sessions. /// Wire shape from RecNet.Objectives.UpdateObjectiveProgress. [HttpPost("api/objectives/v1/updateobjective")] [Authorize] public async Task UpdateObjective([FromBody] UpdateObjectiveRequest req) { var pid = Me; var key = $"obj:{req.Group}:{req.Index}"; var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key); if (row is null) { row = new ObjectiveProgressEntity { PlayerId = pid, Key = key }; db.ObjectiveProgress.Add(row); } if (req.IsCompleted || req.Progress >= 1f) { row.IsCompleted = true; row.ClearedAt ??= DateTime.UtcNow; } await db.SaveChangesAsync(); var rewardKey = $"obj:{req.Group}:{req.Index}"; var isRewarded = await IsRewardedAsync(pid, rewardKey); return Ok(new { req.Group, req.Index, Progress = row.IsCompleted ? 1f : req.Progress, VisualProgress = row.IsCompleted ? 1f : req.Progress, IsCompleted = row.IsCompleted, IsRewarded = isRewarded, IsDirty = false, }); } [HttpPost("api/objectives/v1/completegroup")] [Authorize] public async Task CompleteGroup([FromBody] CompleteGroupBody body) { var pid = Me; var key = $"group:{body.Group}"; var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key); if (row is null) { row = new ObjectiveProgressEntity { PlayerId = pid, Key = key }; db.ObjectiveProgress.Add(row); } row.IsCompleted = true; row.ClearedAt = DateTime.UtcNow; await db.SaveChangesAsync(); var rewarded = await GrantObjectiveGroupRewardAsync(pid, body.Group); return Ok(new { Group = body.Group, IsCompleted = true, ClearedAt = row.ClearedAt!.Value.ToString("o"), RequiresCompleteOnServer = false, IsRewarded = true, Rewarded = rewarded, }); } public sealed class CompleteGroupBody { public int Group { get; set; } } /// GET api/objectives/v1/myprogress + /// api/players/v2/objectives — the watch's daily/weekly /// objectives checklist. Returns /// {Objectives: [...], ObjectiveGroups: [...]} per /// Objectives.MyProgress.Deserialize (RVA 0x14510A0). /// Empty lists are acceptable; both keys must be present. [HttpGet("api/objectives/v1/myprogress")] [HttpGet("api/players/v2/objectives")] public async Task MyObjectiveProgress() { var pid = this.CurrentPlayerId(); if (pid is not long me) return Ok(new { Objectives = Array.Empty(), ObjectiveGroups = Array.Empty() }); var rows = await db.ObjectiveProgress .Where(o => o.PlayerId == me) .ToListAsync(); var rewardKeys = rows .Where(o => o.Key.StartsWith("reward:")) .Select(o => o.Key["reward:".Length..]) .ToHashSet(StringComparer.Ordinal); var objectives = rows .Where(o => o.Key.StartsWith("obj:")) .Select(o => { var (grp, idx) = ParseObjectiveKey(o.Key); return new { Index = idx, Group = grp, Progress = o.IsCompleted ? 1f : 0f, VisualProgress = o.IsCompleted ? 1f : 0f, IsCompleted = o.IsCompleted, IsRewarded = rewardKeys.Contains(o.Key), IsDirty = false, }; }); var groups = rows .Where(o => o.Key.StartsWith("group:")) .Select(o => new { Group = int.TryParse(o.Key["group:".Length..], out var g) ? g : 0, IsCompleted = o.IsCompleted, ClearedAt = o.ClearedAt ?? new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc), RequiresCompleteOnServer = false, IsRewarded = rewardKeys.Contains(o.Key), }); return Ok(new { Objectives = objectives, ObjectiveGroups = groups }); } /// POST api/objectives/v1/cleargroup — flips a /// group's IsCompleted flag, used by the checklist's /// "claim group reward" button. Response is a single /// ObjectiveGroupProgress with Group/IsCompleted/ClearedAt /// required keys; RequiresCompleteOnServer is optional. [HttpPost("api/objectives/v1/cleargroup")] public async Task ClearGroup([FromForm(Name = "group")] int? group) { var pid = this.CurrentPlayerId(); var key = $"group:{group ?? 0}"; var now = DateTime.UtcNow; if (pid is long me) { var row = await db.ObjectiveProgress .FirstOrDefaultAsync(o => o.PlayerId == me && o.Key == key); if (row is null) { row = new ObjectiveProgressEntity { PlayerId = me, Key = key }; db.ObjectiveProgress.Add(row); } row.IsCompleted = true; row.ClearedAt = now; await db.SaveChangesAsync(); await GrantObjectiveGroupRewardAsync(me, group ?? 0); } return Ok(new { Group = group ?? 0, IsCompleted = true, ClearedAt = now.ToString("o"), RequiresCompleteOnServer = false, IsRewarded = true, }); } /// POST api/players/v2/objectives — batched /// ProgressionManager completion records. These are separate from /// ObjectiveProgress rows: they grant XP/currency for daily /// objective events and must be idempotent per UTC day. [HttpPost("api/players/v2/objectives")] [Authorize] public async Task CompletePlayerObjectives([FromBody] List? records) { var pid = Me; var grantedXp = 0; var grantedTokens = 0; foreach (var record in records ?? new()) { var (xp, tokens, reason) = RewardForObjectiveType(record.ObjectiveType, record.AdditionalXp); if (xp <= 0 && tokens <= 0) continue; if (await GrantRewardOnceAsync(pid, $"{reason}:{DateTime.UtcNow:yyyyMMdd}", xp, tokens, reason)) { grantedXp += xp; grantedTokens += tokens; } } var balance = await level.GetBalanceAsync(pid, RecCenterTokens); return Ok(new { GrantedXp = grantedXp, GrantedCurrency = grantedTokens, CurrencyType = RecCenterTokens, Balance = balance, BalanceType = 0, }); } public sealed class PlayerObjectiveCompletionRecord { public int ObjectiveType { get; set; } public bool InParty { get; set; } public int AdditionalXp { get; set; } } private static (int Group, int Index) ParseObjectiveKey(string key) { const string prefix = "obj:"; if (!key.StartsWith(prefix)) return (-1, -1); var rest = key[prefix.Length..]; var colon = rest.IndexOf(':'); if (colon <= 0) return (-1, -1); var grp = int.TryParse(rest[..colon], out var g) ? g : -1; var idx = int.TryParse(rest[(colon + 1)..], out var i) ? i : -1; return (grp, idx); } private static int CurrentChallengeMapId() => (int)((DateTime.UtcNow - new DateTime(2020, 1, 1)).Days / 7 % 52 + 1); private async Task ReadChallengeRequestAsync(UpdateChallengeRequest formReq) { if (Request.HasFormContentType || formReq.Id != 0 || formReq.ChallengeId != 0 || formReq.ChallengeMapId != 0) return formReq; try { var body = await System.Text.Json.JsonSerializer.DeserializeAsync( Request.Body, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); return body ?? formReq; } catch { return formReq; } } private async Task GrantObjectiveGroupRewardAsync(long playerId, int group) { var (xp, tokens, name) = group switch { 0 => (150, 150, "daily-objectives"), 1 => (100, 100, "new-player-punch-card"), 2 => (75, 75, "orientation-checklist"), _ => (50, 50, $"objective-group-{group}"), }; return await GrantRewardOnceAsync(playerId, $"group:{group}", xp, tokens, name); } private static (int Xp, int Tokens, string Reason) RewardForObjectiveType(int objectiveType, int additionalXp) { var xp = Math.Max(additionalXp, 0); return objectiveType switch { 10 or 11 or 12 => (Math.Max(xp, 25), 25, $"daily-objective:{objectiveType}"), 13 => (Math.Max(xp, 150), 150, "all-daily-objectives"), 14 => (Math.Max(xp, 50), 50, "complete-any-daily"), 15 => (Math.Max(xp, 75), 75, "complete-any-weekly"), 25 => (Math.Max(xp, 25), 25, "nux-punchcard-objective"), 26 => (Math.Max(xp, 100), 100, "nux-all-punchcard-objectives"), _ => (xp, 0, $"objective:{objectiveType}"), }; } private async Task IsRewardedAsync(long playerId, string rewardKey) => await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == playerId && o.Key == $"reward:{rewardKey}" && o.IsCompleted); private async Task GrantRewardOnceAsync(long playerId, string rewardKey, int xp, int tokens, string reason) { var sentinelKey = $"reward:{rewardKey}"; var exists = await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == playerId && o.Key == sentinelKey && o.IsCompleted); if (exists) return false; db.ObjectiveProgress.Add(new ObjectiveProgressEntity { PlayerId = playerId, Key = sentinelKey, IsCompleted = true, ClearedAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); if (xp > 0) await level.AwardXpAsync(playerId, xp, reason); if (tokens > 0) await level.GrantCurrencyAsync(playerId, RecCenterTokens, tokens, reason); return true; } private static object BalanceUpdateResponse(long balance, int currencyType, int delta, int balanceAddType) => new { Balance = balance, CurrencyType = currencyType, BalanceType = 0, BalanceUpdates = new[] { new { UpdateResponse = 0, Data = delta <= 0 ? Array.Empty() : new object[] { new { BalanceAddType = balanceAddType, BaseAward = delta, BonusAward = 0, RateLimit = 0, CurrentCount = 1, Total = delta, BalanceType = 0, BalanceInGiftBox = false, }, }, }, }, }; }