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) : 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 keys = new[] { $"challenge:{mapId}:0", $"challenge:{mapId}:1", $"challenge:{mapId}:2" };
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 = false,
StartAt = start,
EndAt = start.AddDays(7),
ServerTime = now,
ChallengeThemeString = "Weekly",
Challenges = new[]
{
new { ChallengeId = mapId * 10 + 0, Name = "Visit any room",
Config = "{\"Goal\":1}", Description = "Step into any room this week.",
Tooltip = "", Complete = Done(0) },
new { ChallengeId = mapId * 10 + 1, Name = "Cheer a player",
Config = "{\"Goal\":1}", Description = "Send a cheer to anyone you meet this week.",
Tooltip = "", Complete = Done(1) },
new { ChallengeId = mapId * 10 + 2, Name = "Finish an activity",
Config = "{\"Goal\":1}", Description = "Complete any activity this week.",
Tooltip = "", Complete = Done(2) },
},
Gift = new
{
GiftDropId = mapId,
AvatarItemDesc = "",
ConsumableItemDesc = "",
EquipmentPrefabName = "",
EquipmentModificationGuid = "",
Xp = 100,
Level = 1,
GiftContext = 0,
GiftRarity = 0,
},
});
}
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 index = challengeId >= mapId * 10 && challengeId < mapId * 10 + 10
? challengeId - mapId * 10
: Math.Abs(challengeId % 10);
index = Math.Clamp(index, 0, 2);
var key = $"challenge:{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 = new[] { $"challenge:{mapId}:0", $"challenge:{mapId}:1", $"challenge:{mapId}:2" };
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: 250,
tokens: 250,
reason: $"weekly_challenge:{mapId}");
}
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