using System.Data; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Storefront; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Avatar; using RadicadeServer.RecNet.Enums.Store; using RadicadeServer.RecNet.Progression.Challenges; namespace RadicadeServer.Utils; public static class WeeklyChallenges { public const int Required = 3; private static readonly DateTime Epoch = new(2026, 10, 7, 0, 0, 0, DateTimeKind.Utc); private static readonly SemaphoreSlim[] Gates = Enumerable.Range(0, 128).Select(_ => new SemaphoreSlim(1)).ToArray(); public static WeeklyChallengeMap CreateMap(DateTime now) { var week = (int)Math.Floor((now.ToUniversalTime() - Epoch).TotalDays / 7); var start = Epoch.AddDays(week * 7); return new WeeklyChallengeMap { ChallengeMapId = 100000 + week, StartAt = start, EndAt = start.AddDays(7), ServerTime = now, Enabled = false }; } public static WeeklyChallengeMap MergeProgress(WeeklyChallengeMap definition, WeeklyChallengeState? state) { var map = JsonSerializer.Deserialize(JsonSerializer.Serialize(definition))!; if (state == null) return map; var previous = JsonSerializer.Deserialize(state.MapJson)!; foreach (var goal in map.Challenges) { var saved = previous.Challenges.FirstOrDefault(x => x.ChallengeId == goal.ChallengeId); if (saved != null && TryMergeConfig(goal.Config, saved.Config, saved.Complete, out var config)) { goal.Config = config; goal.Complete = saved.Complete; } } map.CompletedRequired = state.RewardGiftDropId.HasValue || map.Challenges.Count(x => x.Complete) >= Required; return map; } public static bool TryMergeConfig(string original, string submitted, bool complete, out string merged) { merged = original; if (submitted.Length > 16384) return false; try { var expected = JsonNode.Parse(original)!.AsObject(); var actual = JsonNode.Parse(submitted, documentOptions: new JsonDocumentOptions { MaxDepth = 16 })!.AsObject(); if (!ValidateNode(expected, actual) || (actual["c"]?.GetValue() ?? false) != complete) return false; merged = actual.ToJsonString(); return true; } catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException or NullReferenceException) { return false; } } private static bool ValidateNode(JsonObject expected, JsonObject actual) { foreach (var p in actual) { if (p.Key == "c") { _ = p.Value!.GetValue(); continue; } if (p.Key == "ipc") { _ = p.Value!.GetValue(); continue; } if (p.Key is "in" or "ex") { if (p.Value?.GetValue() != true) return false; continue; } if (p.Key == "wc" && p.Value is JsonArray { Count: 0 }) continue; if (!expected.ContainsKey(p.Key)) return false; } foreach (var p in expected) { if (p.Key is "c" or "ipc" or "in" or "ex" or "wc") continue; if (p.Key == "rc") { if (p.Value is not JsonArray left || actual[p.Key] is not JsonArray right || left.Count != right.Count) return false; for (var i = 0; i < left.Count; i++) if (!ValidateNode(left[i]!.AsObject(), right[i]!.AsObject())) return false; } else if (!JsonNode.DeepEquals(p.Value, actual[p.Key])) return false; } return true; } public static async Task GetAsync(RecDBContext db, long playerId, DateTime now) { var current = await GetDefinitionAsync(db, now); var state = await db.WeeklyChallenges.AsNoTracking().SingleOrDefaultAsync(x => x.PlayerId == playerId && x.ChallengeMapId == current.ChallengeMapId); var map = MergeProgress(current, state); if (!map.Enabled) { map.Challenges.Clear(); map.Gift = new(); } map.ServerTime = now; return map; } public static string ScheduleKey(int mapId) => $"WeeklyChallengeMap:{mapId}"; public static async Task GetDefinitionAsync(RecDBContext db, DateTime now) { var defaults = CreateMap(now); var key = ScheduleKey(defaults.ChallengeMapId); var scheduled = await db.ServerConfig.AsNoTracking().SingleOrDefaultAsync(x => x.Key == key); var map = scheduled == null ? defaults : JsonSerializer.Deserialize(scheduled.Value)!; map.ServerTime = now; return map; } public static bool IsSupportedDefinition(string config) { try { var node = JsonNode.Parse(config)!.AsObject(); if (node["ct"]?.GetValue() != 0 || node["rc"] is not JsonArray { Count: 1 } rc) return false; var predicate = rc[0]!.AsObject(); if (predicate["ct"]?.GetValue() != 6 || predicate["vs"] is not JsonArray { Count: 1 } values) return false; var eventId = values[0]!.GetValue(); if (eventId is not (2 or 4 or 5 or 8 or 10)) return false; var canonical = JsonSerializer.Serialize(new { ct = 0, rc = new[] { new { ct = 6, vs = new[] { eventId } } } }); return TryMergeConfig(canonical, config, false, out _) && !(predicate["c"]?.GetValue() ?? false); } catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException or NullReferenceException) { return false; } } public static async Task UpdateAsync(RecDBContext db, long playerId, int mapId, int challengeId, string config, bool complete, DateTime now) { var current = await GetDefinitionAsync(db, now); if (mapId != current.ChallengeMapId) return "stale_challenge_map"; var gate = Gates[(int)((ulong)playerId % (uint)Gates.Length)]; await gate.WaitAsync(); Gift? notification = null; try { var error = await db.Database.CreateExecutionStrategy().ExecuteAsync(async () => { db.ChangeTracker.Clear(); notification = null; await using var tx = db.Database.IsSqlServer() ? await db.Database.BeginTransactionAsync(IsolationLevel.Serializable) : null; var scheduleKey = ScheduleKey(mapId); var scheduleQuery = db.Database.IsSqlServer() ? db.ServerConfig.FromSqlInterpolated($"SELECT * FROM [ServerConfig] WITH (UPDLOCK, HOLDLOCK) WHERE [Key] = {scheduleKey}") : db.ServerConfig.Where(x => x.Key == scheduleKey); var schedule = await scheduleQuery.AsNoTracking().SingleOrDefaultAsync(); if (schedule == null) return "weekly_not_published"; current = JsonSerializer.Deserialize(schedule.Value)!; if (!current.Enabled) return "weekly_not_published"; var query = db.Database.IsSqlServer() ? db.WeeklyChallenges.FromSqlInterpolated($"SELECT * FROM [WeeklyChallenges] WITH (UPDLOCK, HOLDLOCK) WHERE [PlayerId] = {playerId} AND [ChallengeMapId] = {mapId}") : db.WeeklyChallenges.Where(x => x.PlayerId == playerId && x.ChallengeMapId == mapId); var state = await query.SingleOrDefaultAsync(); var map = MergeProgress(current, state); var challenge = map.Challenges.SingleOrDefault(x => x.ChallengeId == challengeId); if (challenge == null) return "unknown_challenge"; if (!TryMergeConfig(challenge.Config, config, complete, out var merged)) return "invalid_challenge_config"; if (!challenge.Complete) { challenge.Config = merged; challenge.Complete = complete; } if (state == null) { state = new WeeklyChallengeState { PlayerId = playerId, ChallengeMapId = mapId }; db.WeeklyChallenges.Add(state); } map.CompletedRequired = state.RewardGiftDropId.HasValue || map.Challenges.Count(x => x.Complete) >= Required; state.MapJson = JsonSerializer.Serialize(map); if (map.CompletedRequired && !state.RewardGiftDropId.HasValue) { var gift = map.Gift.Clone(); var unlocks = await db.Unlocks.SingleOrDefaultAsync(x => x.AccountId == playerId); var pending = await db.Gifts.SingleOrDefaultAsync(x => x.AccountId == playerId); var pendingGifts = StorefrontShared.ParseGiftList(pending?.Data); var owned = !string.IsNullOrWhiteSpace(gift.AvatarItemDesc) ? unlocks?.GetAvatarItems().Any(x => LevelUpRewardPool.OwnershipKey(x.AvatarItemDesc) == LevelUpRewardPool.OwnershipKey(gift.AvatarItemDesc)) == true || pendingGifts.Any(x => !string.IsNullOrWhiteSpace(x.AvatarItemDesc) && LevelUpRewardPool.OwnershipKey(x.AvatarItemDesc) == LevelUpRewardPool.OwnershipKey(gift.AvatarItemDesc)) : unlocks?.GetEquipment()?.Any(x => x.PrefabName == gift.EquipmentPrefabName && x.ModificationGuid == gift.EquipmentModificationGuid) == true || pendingGifts.Any(x => x.EquipmentPrefabName == gift.EquipmentPrefabName && x.EquipmentModificationGuid == gift.EquipmentModificationGuid); if (owned) gift = RroRewardPools.TokenGift(GiftContext.All_Weekly_Challenge_Complete, map.FallbackTokens, "Weekly Challenge Complete!"); gift.GiftDropId = StorefrontShared.NewGiftDropId(); state.RewardGiftDropId = gift.GiftDropId; await StorefrontShared.PersistGiftBoxAsync(db, playerId, gift, notify: false); notification = gift; } else await db.SaveChangesAsync(); if (tx != null) await tx.CommitAsync(); return (string?)null; }); if (error == null && notification != null) await StorefrontShared.SendGiftPackageNotificationAsync(playerId, notification); return error; } finally { gate.Release(); } } }