using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; using Campus_live_rewrite.api.dbs; using Campus_live_rewrite.api.server; using Campus_live_rewrite.api.server.Classes; using Campus_live_rewrite.api.server.Classes.db_class; namespace Campus_live_rewrite.Controllers { /// /// Admin panel > Challenges: the daily challenge set (api/server/DailyChallenges), its reward, /// today's completion numbers, and per-player progress (look up / reset). Developer-only, like /// every /admin/api route. /// public class AdminChallengesController : Controller { private ContentResult J(object o, int status = 200) => new ContentResult { Content = Newtonsoft.Json.JsonConvert.SerializeObject(o), ContentType = "application/json", StatusCode = status }; private static object Describe(DailyObjective d) => new { type = d.Type, score = d.Score, name = DailyChallenges.Types.TryGetValue(d.Type, out var n) ? n : $"Type {d.Type}", tutorialOnly = DailyChallenges.TutorialOnly.Contains(d.Type) }; [HttpGet("/admin/api/challenges")] public ContentResult Get() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; var sets = DailyChallenges.Sets; bool sameEveryDay = sets.All(s => s.Select(d => (d.Type, d.Score)).SequenceEqual(sets[0].Select(d => (d.Type, d.Score)))); // Today's numbers, from every player's stored progress (UTC day). int started = 0, finished = 0, objectivesDone = 0; try { foreach (var p in PlayerDB.PlayerDBFile.GetCollection("players").FindAll()) { var ex = p.player_Extra; if (ex?.ObjectiveGroups == null) continue; var today = ex.ObjectiveGroups.Where(g => !DailyChallenges.IsStale(g.ClearedAt)).Select(g => g.Group).ToHashSet(); if (today.Count == 0) continue; int done = ex.Objectives?.Count(o => today.Contains(o.Group) && o.IsCompleted) ?? 0; objectivesDone += done; if (done > 0) started++; if (ex.ObjectiveGroups.Any(g => today.Contains(g.Group) && g.IsCompleted)) finished++; } } catch (Exception ex) { Console.WriteLine($"[admin-challenges] stats scan failed: {ex.Message}"); } var dayNames = new[] { "Day 1", "Day 2", "Day 3", "Day 4", "Day 5", "Day 6", "Day 7" }; return J(new { success = true, source = DailyChallenges.Source, rewardEnabled = DailyChallenges.RewardEnabled, xpPerObjective = ConfigCampus.ObjectiveCompletionXp, sameEveryDay, sets = sets.Select((s, i) => new { day = dayNames[i], objectives = s.Select(Describe) }), resetsAtUtc = DailyChallenges.PeriodStartUtc.AddDays(1).ToString("o"), today = new { started, finished, objectivesDone }, types = DailyChallenges.Types.Select(kv => new { type = kv.Key, name = kv.Value, tutorialOnly = DailyChallenges.TutorialOnly.Contains(kv.Key) }), weekly = new { inThisBuild = false, note = "This client build has no weekly challenge screen and never requests one (no /api/challenge route in the game)." } }); } /// /// Body: { rewardEnabled, xpPerObjective, sets: [[{type,score},...], ...] } - one set (used /// every day) or up to seven (one per day). 1-6 objectives per set. /// [HttpPost("/admin/api/challenges/save")] public async Task Save() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; JObject body; try { using var r = new StreamReader(Request.Body); body = JObject.Parse(await r.ReadToEndAsync()); } catch { return J(new { success = false, message = "Unreadable request." }); } bool rewardEnabled = body.Value("rewardEnabled") ?? true; int xp = Math.Clamp(body.Value("xpPerObjective") ?? ConfigCampus.ObjectiveCompletionXp, 0, 100000); if (body["sets"] is not JArray arr || arr.Count == 0 || arr.Count > 7) return J(new { success = false, message = "Give between 1 and 7 sets." }); var sets = new List>(); for (int i = 0; i < arr.Count; i++) { if (arr[i] is not JArray set || set.Count == 0 || set.Count > 6) return J(new { success = false, message = $"Set {i + 1} needs 1 to 6 challenges." }); var list = new List(); foreach (var o in set.OfType()) { int type = o.Value("type") ?? 0, score = o.Value("score") ?? 1; if (!DailyChallenges.Types.ContainsKey(type)) return J(new { success = false, message = $"Set {i + 1}: unknown challenge type {type}." }); if (score < 1 || score > 1000) return J(new { success = false, message = $"Set {i + 1}: the target must be 1 to 1000." }); list.Add(new DailyObjective { Type = type, Score = score }); } sets.Add(list); } // Every day must have the SAME number of challenges: the client reports progress as // group 0 whatever the day, so "all done" is counted against one size for every day. if (sets.Select(x => x.Count).Distinct().Count() > 1) return J(new { success = false, message = "Every day needs the same number of challenges." }); DailyChallenges.Save(sets, rewardEnabled, xp); Console.WriteLine($"[admin-challenges] {account.Id} saved {sets.Count} daily set(s), reward {(rewardEnabled ? "on" : "off")}, {xp} XP each: " + string.Join(" | ", sets.Select(s => string.Join(",", s.Select(d => $"{d.Type}x{d.Score}"))))); return J(new { success = true, message = "Saved. Players get the new challenges on their next login." }); } [HttpPost("/admin/api/challenges/reset_defaults")] public ContentResult ResetDefaults() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; DailyChallenges.ResetToDefaults(); Console.WriteLine($"[admin-challenges] {account.Id} reset the daily challenges to the defaults"); return J(new { success = true, message = "Back to the defaults (Rec Center, custom room, finish an activity)." }); } private static ulong ResolvePlayer(string? p) { p = (p ?? "").Trim().TrimStart('@'); if (p.Length == 0) return 0; return ulong.TryParse(p, out var id) ? id : PlayerDB.GetPlayerIdByName(p); } /// One player's progress today: which challenges are done and whether the group is complete. [HttpGet("/admin/api/challenges/player")] public ContentResult PlayerProgress() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; ulong id = ResolvePlayer(Request.Query["player"]); if (id == 0) return J(new { success = false, message = "No such player." }); var (objectives, groups) = PlayerDB.GetObjectivesProgress(id); var set = DailyChallenges.Sets[0]; var g = groups.FirstOrDefault(x => x.Group == 0); return J(new { success = true, id, name = PlayerDB.GetPlayerNameById(id), completedToday = g?.IsCompleted ?? false, objectives = set.Select((d, i) => { var o = objectives.FirstOrDefault(x => x.Group == 0 && x.Index == i); return new { name = DailyChallenges.Types.TryGetValue(d.Type, out var n) ? n : $"Type {d.Type}", target = d.Score, progress = o?.Progress ?? 0, done = o?.IsCompleted ?? false }; }) }); } // ================= Limited-time events (ProgressionEvents) ================= private static string Status(ProgressionEvents.EventDef e) { var now = DateTime.UtcNow; if (!e.Enabled) return "off"; if (now < e.StartTime) return "scheduled"; if (now <= e.EndTime) return e.DevelopersOnly ? "live (developers only)" : "live"; if (now <= (e.CollectionEndTime ?? e.EndTime)) return "collecting only"; return "ended"; } [HttpGet("/admin/api/progression_events")] public ContentResult EventsList() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; var (minutes, xp, cap) = ProgressionEvents.Rule(); return J(new { success = true, rule = new { minutes, xp, cap }, events = ProgressionEvents.All().Select(e => new { def = e, status = Status(e), participants = ProgressionEvents.Participants(e.Id) }) }); } /// Create or replace one event (matched by Id). Body: the EventDef JSON. [HttpPost("/admin/api/progression_events/save")] public async Task EventsSave() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; ProgressionEvents.EventDef? def; try { using var r = new StreamReader(Request.Body); def = Newtonsoft.Json.JsonConvert.DeserializeObject(await r.ReadToEndAsync()); } catch (Exception ex) { return J(new { success = false, message = "Unreadable event: " + ex.Message }); } if (def == null) return J(new { success = false, message = "Unreadable event." }); def.Name = (def.Name ?? "").Trim(); if (def.Name.Length == 0 || def.Name.Length > 60) return J(new { success = false, message = "Give the event a name (up to 60 characters)." }); if (!Guid.TryParse(def.Id, out _)) def.Id = Guid.NewGuid().ToString(); def.StartTime = DateTime.SpecifyKind(def.StartTime, DateTimeKind.Utc); def.EndTime = DateTime.SpecifyKind(def.EndTime, DateTimeKind.Utc); if (def.CollectionEndTime is { } ce) def.CollectionEndTime = DateTime.SpecifyKind(ce, DateTimeKind.Utc); if (def.EndTime <= def.StartTime) return J(new { success = false, message = "The end must be after the start." }); if (def.CollectionEndTime is { } c && c < def.EndTime) return J(new { success = false, message = "Collection must end on or after the event end." }); if (def.Steps.Count == 0 || def.Steps.Count > 60) return J(new { success = false, message = "The track needs 1 to 60 steps." }); var items = ProgressionEvents.StoreItems().ToDictionary(i => i.GiftDropId); for (int i = 0; i < def.Steps.Count; i++) { var s = def.Steps[i]; if (s.Xp < 1 || s.Xp > 100000) return J(new { success = false, message = $"Step {i + 1}: XP must be 1 to 100000." }); foreach (var rw in new[] { s.Free, s.Premium }) { if (rw == null) continue; if (!Guid.TryParse(rw.Id, out _)) rw.Id = Guid.NewGuid().ToString(); if (rw.Kind == "tokens" && (rw.Tokens < 1 || rw.Tokens > 100000)) return J(new { success = false, message = $"Step {i + 1}: tokens must be 1 to 100000." }); if (rw.Kind == "item") { if (!items.TryGetValue(rw.GiftDropId, out var it)) return J(new { success = false, message = $"Step {i + 1}: pick an item from the store." }); rw.AvatarItemDesc = it.AvatarItemDesc; rw.AvatarItemType = it.AvatarItemType; rw.FriendlyName = it.FriendlyName; rw.Rarity = it.Rarity; if (string.IsNullOrWhiteSpace(rw.ImageName)) rw.ImageName = it.ThumbnailImageName; } if (rw.Kind != "tokens" && rw.Kind != "item") rw.Kind = "none"; } } var all = ProgressionEvents.All().ToList(); int idx = all.FindIndex(e => e.Id == def.Id); if (idx >= 0) all[idx] = def; else all.Add(def); ProgressionEvents.SaveAll(all); Console.WriteLine($"[admin-events] {account.Id} saved event '{def.Name}' ({def.Id}): {Status(def)}, {def.Steps.Count} steps"); return J(new { success = true, id = def.Id, message = $"Saved '{def.Name}' ({Status(def)})." }); } [HttpPost("/admin/api/progression_events/delete")] public ContentResult EventsDelete() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; string id = Request.HasFormContentType ? Request.Form["id"].ToString() : Request.Query["id"].ToString(); var all = ProgressionEvents.All().ToList(); int n = all.RemoveAll(e => e.Id == id); ProgressionEvents.SaveAll(all); Console.WriteLine($"[admin-events] {account.Id} deleted event {id} ({n})"); return J(new { success = n > 0, message = n > 0 ? "Deleted." : "No such event." }); } /// Store items to pick a reward from (name search). [HttpGet("/admin/api/progression_events/items")] public ContentResult EventsItems() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; string q = (Request.Query["q"].ToString() ?? "").Trim(); var hits = ProgressionEvents.StoreItems() .Where(i => q.Length == 0 || i.FriendlyName.Contains(q, StringComparison.OrdinalIgnoreCase)) .OrderBy(i => i.FriendlyName).Take(40) .Select(i => new { i.GiftDropId, i.FriendlyName, i.Rarity, i.ThumbnailImageName }); return J(new { success = true, items = hits }); } /// A player's record on an event. ?id=event&player= [HttpGet("/admin/api/progression_events/player")] public ContentResult EventsPlayer() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; var e = ProgressionEvents.Find(Request.Query["id"]); ulong pid = ResolvePlayer(Request.Query["player"]); if (e == null || pid == 0) return J(new { success = false, message = e == null ? "No such event." : "No such player." }); var r = ProgressionEvents.GetRecord(e.Id, pid); return J(new { success = true, name = PlayerDB.GetPlayerNameById(pid), id = pid, xp = r.Xp, xpToday = r.XpToday, minutesToday = (int)(r.SecondsToday / 60), collected = r.Collected.Count }); } /// Testing aids: give a player event XP, or wipe their record. Form: id, player, xp / reset=1. [HttpPost("/admin/api/progression_events/player")] public ContentResult EventsPlayerEdit() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; var e = ProgressionEvents.Find(Request.Form["id"]); ulong pid = ResolvePlayer(Request.Form["player"]); if (e == null || pid == 0) return J(new { success = false, message = e == null ? "No such event." : "No such player." }); if (Request.Form["reset"] == "1") { ProgressionEvents.ResetPlayer(e.Id, pid); Console.WriteLine($"[admin-events] {account.Id} reset {pid}'s record on '{e.Name}'"); return J(new { success = true, message = "Record reset." }); } if (!int.TryParse(Request.Form["xp"], out int xp) || xp == 0 || Math.Abs(xp) > 100000) return J(new { success = false, message = "XP must be a non-zero number." }); var r = ProgressionEvents.GetRecord(e.Id, pid); r.Xp = Math.Max(0, r.Xp + xp); ProgressionEvents.SaveRecord(r); ProgressionEvents.PushRecord(e, r); Console.WriteLine($"[admin-events] {account.Id} gave {pid} {xp} XP on '{e.Name}' -> {r.Xp}"); return J(new { success = true, message = $"@{PlayerDB.GetPlayerNameById(pid)} now has {r.Xp} XP." }); } /// Wipe a player's daily progress so today's challenges (and their reward) can be done again. [HttpPost("/admin/api/challenges/reset_player")] public ContentResult ResetPlayer() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; ulong id = ResolvePlayer(Request.HasFormContentType ? Request.Form["player"].ToString() : Request.Query["player"].ToString()); if (id == 0) return J(new { success = false, message = "No such player." }); var col = PlayerDB.PlayerDBFile.GetCollection("players"); var rec = col.FindOne(x => x.playerid == id); if (rec?.player_Extra == null) return J(new { success = false, message = "No such player." }); rec.player_Extra.Objectives = new List(); rec.player_Extra.ObjectiveGroups = new List(); col.Update(rec); Console.WriteLine($"[admin-challenges] {account.Id} reset {id}'s daily challenge progress"); return J(new { success = true, message = $"Reset @{PlayerDB.GetPlayerNameById(id)}'s challenges. They show fresh on their next login." }); } } }