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) : 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 challengeDefs = weekly.Challenges;
var keys = challengeDefs.Select(c => ChallengeProgressKey(mapId, c.Index)).ToArray();
var rows = pid is long me
? 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(keys[idx], out var v) && v;
return Ok(new
{
ChallengeMapId = mapId,
CompletedRequired = weekly.CompletedRequired,
StartAt = start,
EndAt = start.AddDays(7),
ServerTime = now,
// 2020.12 ChallengeMap.Deserialize (FNEEJMCEPOL.PPGFHEDFBEA) reads
// FallbackGiftName as a required key. Missing → KeyNotFoundException
// → "Received malformed RecNet response" → blocks the weekly-challenge
// promise chain at boot.
FallbackGiftName = string.Empty,
ChallengeThemeString = "Weekly",
Challenges = challengeDefs.Select(c => new
{
ChallengeId = ChallengeId(mapId, c.Index),
c.Name,
c.Config,
c.Description,
c.Tooltip,
Complete = Done(c.Index),
}),
Gift = new
{
GiftDropId = weekly.Reward.GiftDropId != 0 ? weekly.Reward.GiftDropId : mapId,
weekly.Reward.AvatarItemDesc,
weekly.Reward.ConsumableItemDesc,
weekly.Reward.EquipmentPrefabName,
weekly.Reward.EquipmentModificationGuid,
weekly.Reward.Xp,
weekly.Reward.Level,
weekly.Reward.GiftContext,
weekly.Reward.GiftRarity,
},
});
}
public sealed class UpdateChallengeRequest
{
public int Id { get; set; }
public int ChallengeMapId { get; set; }
public int ChallengeId { get; set; }
public int Progress { get; set; }
public string? Config { get; set; }
public bool? Complete { get; set; }
}
[HttpPost("api/challenge/v1/updateProgress")]
[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);
}
var shouldComplete = req.Complete ?? true;
if (shouldComplete && !row.IsCompleted)
{
row.IsCompleted = true;
row.ClearedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
var weeklyRewarded = false;
var allKeys = weekly.Challenges
.Select(c => c.Index)
.Select(i => ChallengeProgressKey(mapId, i))
.ToArray();
var completeCount = await db.ObjectiveProgress
.CountAsync(o => o.PlayerId == pid && allKeys.Contains(o.Key) && o.IsCompleted);
if (completeCount >= allKeys.Length)
{
weeklyRewarded = await GrantRewardOnceAsync(
pid,
$"weekly-challenge:{mapId}",
xp: weekly.Reward.Xp,
tokens: weekly.Reward.Tokens,
reason: $"weekly_challenge:{mapId}");
}
return Ok(new
{
ChallengeMapId = mapId,
ChallengeId = challengeId,
Config = req.Config ?? string.Empty,
Complete = row.IsCompleted,
Progress = Math.Max(req.Progress, row.IsCompleted ? 10 : 0),
IsCompleted = row.IsCompleted,
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
{
// 2020.12 NMEKJOABIFC.PPGFHEDFBEA: HasClaimedReward (not IsRewarded).
req.Index,
req.Group,
Progress = row.IsCompleted ? 1f : req.Progress,
VisualProgress = row.IsCompleted ? 1f : req.Progress,
IsCompleted = row.IsCompleted,
HasClaimedReward = isRewarded,
});
}
[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