Files
Radicade/Server/ProgressionEvents/WeeklyChallenges.cs
T
2026-10-16 23:33:13 -07:00

211 lines
10 KiB
C#

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<WeeklyChallengeMap>(JsonSerializer.Serialize(definition))!;
if (state == null) return map;
var previous = JsonSerializer.Deserialize<WeeklyChallengeMap>(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<bool>() ?? 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<bool>(); continue; }
if (p.Key == "ipc") { _ = p.Value!.GetValue<bool>(); continue; }
if (p.Key is "in" or "ex")
{
if (p.Value?.GetValue<bool>() != 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<WeeklyChallengeMap> 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<WeeklyChallengeMap> 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<WeeklyChallengeMap>(scheduled.Value)!;
map.ServerTime = now;
return map;
}
public static bool IsSupportedDefinition(string config)
{
try
{
var node = JsonNode.Parse(config)!.AsObject();
if (node["ct"]?.GetValue<int>() != 0 || node["rc"] is not JsonArray { Count: 1 } rc) return false;
var predicate = rc[0]!.AsObject();
if (predicate["ct"]?.GetValue<int>() != 6 || predicate["vs"] is not JsonArray { Count: 1 } values) return false;
var eventId = values[0]!.GetValue<int>();
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<bool>() ?? false);
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException or NullReferenceException)
{ return false; }
}
public static async Task<string?> 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<WeeklyChallengeMap>(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(); }
}
}