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