using Campus_live_rewrite.api.dbs; using Campus_live_rewrite.api.server.Classes.db_class; using LiteDB; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace Campus_live_rewrite.api.server { /// /// The weekly challenges, shown on the WEBSITE (/challenges) and tracked by the server. /// /// The 2025 client has no weekly challenge screen and never asks for one, so unlike RecFlare /// (whose 2023 client evaluates rule trees and reports back) nothing in the game tracks these. /// The server does it from what it does see: every finished game posts a PostGameActivity /// reward request (APIController.RequestGameReward), and the player's presence says which /// room and subroom it was in. So every challenge is "finish N games in X" - wins and enemies /// defeated aren't visible to the server. /// /// The week (Wednesday 21:00 UTC to Wednesday) picks five challenges from /// with a seeded PRNG (RecFlare's mulberry32 + Knuth spread): the same five for everyone, /// recomputed identically after a restart. Finishing three of them sends the week's reward as /// an in-game gift box: the item chosen in the admin panel (Challenges tab - any store skin or /// clothing item), else a store skin rolled by the week's seed. A player who already owns it /// gets the fallback box (tokens) instead. Progress lives in Data/DBs/WeeklyChallenges.db; /// admin choices in Data/weekly-rewards.json. /// public static class WeeklyChallenge { private static readonly DateTime Epoch = new(2020, 1, 1, 21, 0, 0, DateTimeKind.Utc); // a Wednesday private static readonly TimeSpan Week = TimeSpan.FromDays(7); private const int MapIdBase = 1000; private const int PerWeek = 5; private const int MaxQuests = 2; public const int RequiredForGift = 3; private const long FallbackTokens = 500; private const string FallbackName = "4-Star Box"; private const string GiftMessage = "Weekly challenge complete!"; /// /// One challenge: finish games in any of (room ids; /// empty = anywhere), optionally only in . keeps a /// week from asking for the same activity twice. The id is the position in the pool, so add /// new ones at the END. /// public sealed record Def(string Key, string Text, int Target, long[] Rooms, long[] SubRooms, string Group, long LinkRoom, bool Quest = false); private static Def G(string key, string text, int target, string group, long[] rooms, long[]? subs = null, bool quest = false) => new(key, text, target, rooms, subs ?? Array.Empty(), group, rooms.Length > 0 ? rooms[0] : 0, quest); // Room / subroom ids are the stock Rec Room Originals (Data/exported_rooms.json). public static readonly Def[] Pool = { G("Paintball", "Finish {0} games of Paintball", 5, "paintball", new long[] { 10, 11 }), G("PaintballRiver", "Finish {0} Paintball games on River", 3, "paintball", new long[] { 10, 11 }, new long[] { 10, 16 }), G("PaintballHomestead","Finish {0} Paintball games on Homestead", 3, "paintball", new long[] { 10, 11 }, new long[] { 11, 17 }), G("PaintballQuarry", "Finish {0} Paintball games on Quarry", 3, "paintball", new long[] { 10, 11 }, new long[] { 12, 18 }), G("PaintballClearcut", "Finish {0} Paintball games on Clear Cut", 3, "paintball", new long[] { 10, 11 }, new long[] { 13, 19 }), G("PaintballSpillway", "Finish {0} Paintball games on Spillway", 3, "paintball", new long[] { 10, 11 }, new long[] { 14, 20 }), G("PaintballDriveIn", "Finish {0} Paintball games on Drive-in", 3, "paintball", new long[] { 10, 11 }, new long[] { 15, 21 }), G("Dodgeball", "Finish {0} games of Dodgeball", 5, "dodgeball", new long[] { 8, 36 }), G("Soccer", "Finish {0} games of Soccer", 3, "soccer", new long[] { 17 }), G("LaserTag", "Finish {0} games of Laser Tag", 5, "lasertag", new long[] { 18 }), G("LaserTagHangar", "Finish {0} Laser Tag games in Hangar", 3, "lasertag", new long[] { 18 }, new long[] { 28 }), G("LaserTagCyberJunk", "Finish {0} Laser Tag games in CyberJunk City",3, "lasertag", new long[] { 18 }, new long[] { 29 }), G("Paddleball", "Finish {0} games of Paddleball", 3, "paddleball",new long[] { 9 }), G("Bowling", "Finish {0} games of Bowling", 2, "bowling", new long[] { 38 }), G("DiscGolfLake", "Finish a round of Disc Golf: Lake", 1, "discgolf", new long[] { 6 }), G("DiscGolfPropulsion","Finish a round of Disc Golf: Propulsion", 1, "discgolf", new long[] { 7 }), G("Charades", "Finish {0} games of 3D Charades", 2, "charades", new long[] { 3, 4 }), G("StuntRunner", "Finish {0} runs of Stunt Runner", 3, "stuntrunner",new long[] { 40 }), G("RecRoyaleSquads", "Finish {0} games of Rec Royale: Squads", 2, "royale", new long[] { 19 }), G("RecRoyaleSolos", "Finish {0} games of Rec Royale: Solos", 2, "royale", new long[] { 20 }), G("GoldenTrophy", "Beat the Quest for the Golden Trophy", 1, "q-golden", new long[] { 12 }, quest: true), G("Jumbotron", "Beat The Rise of Jumbotron", 1, "q-jumbo", new long[] { 14 }, quest: true), G("CrimsonCauldron", "Beat the Curse of the Crimson Cauldron", 1, "q-crimson", new long[] { 15 }, quest: true), G("IsleOfLostSkulls", "Beat The Isle of Lost Skulls", 1, "q-isle", new long[] { 16 }, quest: true), G("Crescendo", "Beat Crescendo of the Blood Moon", 1, "q-crescendo",new long[] { 37 }, quest: true), G("AnyGames", "Finish {0} games of anything", 10, "any", Array.Empty()), }; public static string TextOf(Def d) => string.Format(d.Text, d.Target); // ---- the week --------------------------------------------------------------------------- public static int WeekIndex(DateTime now) => (int)Math.Floor((now - Epoch).TotalMilliseconds / Week.TotalMilliseconds); public static int MapIdFor(int weekIndex) => MapIdBase + weekIndex; public static int CurrentMapId(DateTime now) => MapIdFor(WeekIndex(now)); public static DateTime StartOf(int mapId) => Epoch + TimeSpan.FromTicks(Week.Ticks * (mapId - MapIdBase)); public static DateTime EndOf(int mapId) => StartOf(mapId) + Week; private static Func Mulberry32(uint seed) { uint a = seed; return () => { unchecked { a += 0x6D2B79F5; uint t = a; t = (t ^ (t >> 15)) * (t | 1); t ^= t + (t ^ (t >> 7)) * (t | 61); return (t ^ (t >> 14)) / 4294967296.0; } }; } private static uint SeedFor(int mapId, uint salt) => unchecked(((uint)mapId ^ salt) * 2654435761u); /// The week's five (challenge id, definition) - one per activity group, at most two quests. public static List<(int Id, Def Def)> ChallengesFor(int mapId) { var random = Mulberry32(SeedFor(mapId, 0x5EED)); var all = Pool.Select((d, i) => (Id: i + 1, Def: d)).ToList(); for (int i = all.Count - 1; i > 0; i--) { int j = (int)Math.Floor(random() * (i + 1)); (all[i], all[j]) = (all[j], all[i]); } var picked = new List<(int, Def)>(); var groups = new HashSet(); int quests = 0; foreach (var c in all) { if (picked.Count == PerWeek) break; if (groups.Contains(c.Def.Group) || (c.Def.Quest && quests >= MaxQuests)) continue; picked.Add(c); groups.Add(c.Def.Group); if (c.Def.Quest) quests++; } return picked; } // ---- reward items: the general store's skins and clothing --------------------------------- public sealed class RewardItem { public int GiftDropId; public string FriendlyName = ""; public string Kind = ""; // "skin" or "clothing" public string ThumbnailImageName = ""; public int Rarity; public string Prefab = "", Guid = ""; public string AvatarItemDesc = ""; public int AvatarItemType; } private static List? _items; private static readonly object _lock = new(); /// /// Name -> picture of the first-party custom items (FirstPartyUGC.json). The store rows still /// carry rec.net's old thumbnail names, whose files are gone, but almost every store clothing /// item is also a first-party item with its OWN render on this server (2,249 of 2,403). /// Skins have none - the game draws those itself. /// private static Dictionary FirstPartyPictures() { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); try { string f = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "FirstPartyUGC.json"); if (!File.Exists(f)) return map; var root = JToken.Parse(File.ReadAllText(f)); var arr = root as JArray ?? root["Results"] as JArray ?? new JArray(); foreach (var it in arr.OfType()) { string name = ((string?)it["Name"] ?? "").Trim(); if (name.Length == 0 || map.ContainsKey(name)) continue; var pics = new List { (string?)it["ThumbnailImageFilename"] }; foreach (var s in it["CurrentSaves"] as JArray ?? new JArray()) pics.Add((string?)s["ThumbnailFileName"]); string? pic = pics.FirstOrDefault(p => !string.IsNullOrWhiteSpace(p) && ItemImages.Exists(p)); if (pic != null) map[name] = pic; } } catch (Exception ex) { Console.WriteLine($"[weeklychallenge] FirstPartyUGC.json unreadable: {ex.Message}"); } return map; } /// Every skin and clothing item the general store (3.json) sells - what a reward can be. public static List RewardItems() { lock (_lock) { if (_items != null) return _items; var list = new List(); var pictures = FirstPartyPictures(); try { string f = Path.Combine(StoreCatalog.StoreDirectory, "3.json"); foreach (var it in JObject.Parse(File.ReadAllText(f))["StoreItems"] as JArray ?? new JArray()) { if (it["GiftDrop"] is not JObject gd || (bool?)gd["IsQuery"] == true) continue; int id = (int?)gd["GiftDropId"] ?? 0; if (id == 0 || list.Any(x => x.GiftDropId == id)) continue; string guid = (string?)gd["EquipmentModificationGuid"] ?? "", prefab = (string?)gd["EquipmentPrefabName"] ?? ""; string desc = (string?)gd["AvatarItemDesc"] ?? ""; var item = new RewardItem { GiftDropId = id, FriendlyName = (string?)gd["FriendlyName"] ?? "", ThumbnailImageName = (string?)gd["ThumbnailImageName"] ?? "", Rarity = (int?)gd["Rarity"] ?? 0, }; if (guid.Length > 0 && prefab.Length > 0) { if (prefab.StartsWith("[Sandbox_")) continue; // die recolours item.Kind = "skin"; item.Prefab = prefab; item.Guid = SkinOwnership.CanonicalGuid(prefab, guid) ?? guid; } else if (desc.Length > 0) { item.Kind = "clothing"; item.AvatarItemDesc = desc; item.AvatarItemType = (int?)gd["AvatarItemType"] ?? 0; } else continue; // A picture that is really here: the store's own (rarely), else the item's // first-party render; none for a skin (the website shows a plain tile). if (!ItemImages.Exists(item.ThumbnailImageName)) item.ThumbnailImageName = pictures.TryGetValue(item.FriendlyName.Trim(), out var pic) ? pic : ""; list.Add(item); } } catch (Exception ex) { Console.WriteLine($"[weeklychallenge] could not read the store: {ex.Message}"); } if (list.Count > 0) _items = list; // a failed read is retried, not pinned empty return list; } } public static RewardItem? FindItem(int giftDropId) => RewardItems().FirstOrDefault(i => i.GiftDropId == giftDropId); // ---- admin-chosen rewards: Data/weekly-rewards.json { "": giftDropId } ------------- private static string ChoicesPath => Path.Combine(Environment.CurrentDirectory, "Data", "weekly-rewards.json"); private static Dictionary LoadChoices() { try { return File.Exists(ChoicesPath) ? JsonConvert.DeserializeObject>(File.ReadAllText(ChoicesPath)) ?? new() : new(); } catch (Exception ex) { Console.WriteLine($"[weeklychallenge] {ChoicesPath} unreadable: {ex.Message}"); return new(); } } /// Set (giftDropId > 0) or clear (0) the reward chosen for a week. public static void SetChoice(int mapId, int giftDropId) { lock (_lock) { var c = LoadChoices(); if (giftDropId > 0) c[mapId.ToString()] = giftDropId; else c.Remove(mapId.ToString()); // Old weeks are dropped so the file doesn't grow forever. foreach (var k in c.Keys.ToList()) if (int.TryParse(k, out int m) && m < CurrentMapId(DateTime.UtcNow) - 4) c.Remove(k); File.WriteAllText(ChoicesPath, JsonConvert.SerializeObject(c, Formatting.Indented)); } } public static bool IsChosen(int mapId) => LoadChoices().ContainsKey(mapId.ToString()); /// The week's reward: the admin's choice, else a store skin picked by the week's seed. public static RewardItem? RewardFor(int mapId) { if (LoadChoices().TryGetValue(mapId.ToString(), out int chosen) && FindItem(chosen) is RewardItem it) return it; var skins = RewardItems().Where(i => i.Kind == "skin").ToList(); if (skins.Count == 0) return null; return skins[(int)Math.Floor(Mulberry32(SeedFor(mapId, 0x9E3779B9))() * skins.Count)]; } // ---- progress: Data/DBs/WeeklyChallenges.db ------------------------------------------------ public class Progress { [BsonId] public string Id { get; set; } = ""; // "{player}|{mapId}" public ulong PlayerId { get; set; } public int MapId { get; set; } public Dictionary Counts { get; set; } = new(); public DateTime? RewardSentAt { get; set; } public string RewardName { get; set; } = ""; } private static readonly LiteDatabase _db = new(Path.Combine(Environment.CurrentDirectory, "Data", "DBs", "WeeklyChallenges.db")); private static ILiteCollection Col => _db.GetCollection("progress"); public static Progress ProgressFor(ulong playerId, int mapId) => Col.FindById($"{playerId}|{mapId}") ?? new Progress { Id = $"{playerId}|{mapId}", PlayerId = playerId, MapId = mapId }; public static int CountOf(Progress p, int challengeId) => p.Counts.TryGetValue(challengeId.ToString(), out int n) ? n : 0; public static int Completed(Progress p, List<(int Id, Def Def)> week) => week.Count(c => CountOf(p, c.Id) >= c.Def.Target); /// Every player's progress on a week (admin numbers). public static List AllFor(int mapId) => Col.Find(x => x.MapId == mapId).ToList(); public static void Reset(ulong playerId, int mapId) => Col.Delete($"{playerId}|{mapId}"); /// /// A game just finished for this player (a PostGameActivity that passed the reward rate /// limit). Counts it toward every matching challenge of the week, and sends the reward the /// moment the third challenge completes. Never throws - the reward request must succeed. /// public static void RecordGame(ulong playerId) { try { var inst = HeartbeatDB.GetPlayerHeartbeat(playerId)?.roomInstance; if (inst == null || inst.roomId == 0) return; int mapId = CurrentMapId(DateTime.UtcNow); var week = ChallengesFor(mapId); lock (_lock) { var p = ProgressFor(playerId, mapId); var moved = new List(); foreach (var (id, d) in week) { if (d.Rooms.Length > 0 && !d.Rooms.Contains(inst.roomId)) continue; if (d.SubRooms.Length > 0 && !d.SubRooms.Contains(inst.subRoomId)) continue; int n = CountOf(p, id); if (n >= d.Target) continue; p.Counts[id.ToString()] = n + 1; moved.Add($"{d.Key} {n + 1}/{d.Target}"); } if (moved.Count == 0) return; Col.Upsert(p); Console.WriteLine($"[weeklychallenge] {playerId} finished a game in room {inst.roomId}/{inst.subRoomId}: {string.Join(", ", moved)}"); if (p.RewardSentAt == null && Completed(p, week) >= RequiredForGift) SendReward(p, mapId); } } catch (Exception ex) { Console.WriteLine($"[weeklychallenge] {playerId}: counting a game failed: {ex.Message}"); } } /// Hand over the week's reward (or the fallback box if they own it) as a gift box. private static void SendReward(Progress p, int mapId) { var item = RewardFor(mapId); ulong player = p.PlayerId; bool duplicate = item == null || (item.Kind == "skin" && PlayerDB.OwnedEquipmentSkins(player).Contains(PlayerDB.EquipmentSkinKey(item.Prefab, item.Guid))) || (item.Kind == "clothing" && PlayerDB.GetOwnedAvatarItemKeys(player).Contains(PlayerDB.NormalizeAvatarItemDesc(item.AvatarItemDesc))); PendingGiftBox box = !duplicate ? new PendingGiftBox { GiftDropId = item!.GiftDropId, FriendlyName = item.FriendlyName, Rarity = item.Rarity, AvatarItemDesc = item.Kind == "clothing" ? item.AvatarItemDesc : null, AvatarItemType = item.AvatarItemType, EquipmentPrefabName = item.Kind == "skin" ? item.Prefab : null, EquipmentModificationGuid = item.Kind == "skin" ? item.Guid : null, Message = GiftMessage, FromAccountId = 0, } : new PendingGiftBox { FriendlyName = FallbackName, ThumbnailImageName = ConfigCampus.UseClientProgressionIcons ? "" : QuestRewards.TokenThumbnail, CurrencyType = (int)Classes.CurrencyType.RecCenterTokens, Currency = FallbackTokens, Rarity = QuestRewards.TokenRarity(FallbackTokens), PayOnOpen = true, Message = GiftMessage, FromAccountId = 0, }; // The reward is EARNED, so a skin unlocks (the exception to "skins come from the store // or an admin gift"); clothing enters the wardrobe in the same write. if (!PlayerDB.AddPendingGiftBoxFull(player, box, grantSkin: !duplicate && item!.Kind == "skin")) { Console.WriteLine($"[weeklychallenge] {player}: could not store the week {mapId} reward"); return; } p.RewardSentAt = DateTime.UtcNow; p.RewardName = box.FriendlyName ?? ""; Col.Upsert(p); GiftBoxes.Push(player, box, GiftBoxes.FrameReceived, $"weekly challenge {mapId} reward"); Console.WriteLine($"[weeklychallenge] {player} completed week {mapId} -> " + (duplicate ? $"already owns '{item?.FriendlyName}', sent the {FallbackName} ({FallbackTokens} tokens)" : $"'{box.FriendlyName}' ({item!.Kind})")); } public static void Load() { int map = CurrentMapId(DateTime.UtcNow); var r = RewardFor(map); Console.WriteLine($"[weeklychallenge] week {map} ({StartOf(map):yyyy-MM-dd HH:mm} - {EndOf(map):yyyy-MM-dd HH:mm} UTC): " + string.Join(", ", ChallengesFor(map).Select(c => c.Def.Key)) + $"; reward '{r?.FriendlyName}'{(IsChosen(map) ? " (chosen)" : "")}"); } // ---- the game's own weekly-challenge routes (the 2025 client never calls them) ------------- public static JObject Build(ulong playerId) { int map = CurrentMapId(DateTime.UtcNow); var week = ChallengesFor(map); var p = ProgressFor(playerId, map); var r = RewardFor(map); return JObject.FromObject(new { ChallengeMapId = map, CompletedRequired = false, StartAt = StartOf(map).ToString("yyyy-MM-ddTHH:mm:ss"), EndAt = EndOf(map).ToString("yyyy-MM-ddTHH:mm:ss"), ServerTime = DateTime.UtcNow.ToString("o"), Challenges = week.Select(c => new { ChallengeId = c.Id, Name = c.Def.Key, Config = "", Description = TextOf(c.Def), Tooltip = "", Complete = CountOf(p, c.Id) >= c.Def.Target }), Gift = new { GiftDropId = r?.GiftDropId ?? 0, AvatarItemDesc = r?.AvatarItemDesc ?? "", AvatarItemType = r?.AvatarItemType ?? 0, ConsumableItemDesc = "", EquipmentPrefabName = r?.Prefab ?? "", EquipmentModificationGuid = r?.Guid ?? "", StorefrontType = 0, Xp = 0, Level = 0, GiftContext = 0, GiftRarity = r?.Rarity ?? 0 }, FallbackGiftName = FallbackName, ChallengeThemeString = r?.FriendlyName ?? "" }); } } }