Radicade Public Code

This commit is contained in:
JogmansonReal
2026-10-16 23:33:13 -07:00
commit 55175acdf6
832 changed files with 363261 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
namespace RadicadeServer.RecNet.Progression.Challenges
{
public class Challenge
{
public int Index { get; set; }
public int Group { get; set; }
public float Progress { get; set; }
public float VisualProgress { get; set; }
public bool IsCompleted { get; set; }
public bool HasClaimedReward { get; set; }
}
}
@@ -0,0 +1,9 @@
namespace RadicadeServer.RecNet.Progression.Challenges
{
public class ChallengeGroups
{
public int Group { get; set; }
public bool IsCompleted { get; set; }
public DateTime ClearedAt { get; set; }
}
}
@@ -0,0 +1,8 @@
namespace RadicadeServer.RecNet.Progression.Challenges
{
public class ChallengeProgress
{
public List<ChallengeGroups> ObjectiveGroups { get; set; } = new();
public List<Challenge> Objectives { get; set; } = new();
}
}
+51
View File
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using System.Text.Json;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.Utils;
namespace RadicadeServer.API.Progression.Challenges.V2;
[Route("api/challenge/v2")]
[Route("econ/api/challenge/v2")]
[Authorize]
public class ChallengeV2(RecDBContext db) : Controller
{
[HttpGet("getCurrent")]
public async Task<IActionResult> GetCurrent()
{
var playerId = User.GetUserId();
if (playerId == null) return Unauthorized();
return Ok(await WeeklyChallenges.GetAsync(db, playerId.Value, DateTime.UtcNow));
}
[HttpPost("updateProgress")]
[RequestSizeLimit(32768)]
public async Task<IActionResult> UpdateProgress()
{
var playerId = User.GetUserId();
if (playerId == null) return Unauthorized();
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
if (Request.HasFormContentType)
foreach (var field in await Request.ReadFormAsync()) fields[field.Key] = field.Value.ToString();
else
{
using var json = await JsonDocument.ParseAsync(Request.Body);
if (json.RootElement.ValueKind != JsonValueKind.Object) return BadRequest();
foreach (var field in json.RootElement.EnumerateObject()) fields[field.Name] = field.Value.ToString();
}
}
catch (Exception ex) when (ex is JsonException or InvalidDataException) { return BadRequest(); }
if (!fields.TryGetValue("ChallengeMapId", out var mapRaw) || !int.TryParse(mapRaw, out var mapId)
|| !fields.TryGetValue("ChallengeId", out var idRaw) || !int.TryParse(idRaw, out var challengeId)
|| !fields.TryGetValue("Complete", out var completeRaw) || !bool.TryParse(completeRaw, out var complete)
|| !fields.TryGetValue("Config", out var config)) return BadRequest();
var error = await WeeklyChallenges.UpdateAsync(db, playerId.Value, mapId, challengeId, config, complete, DateTime.UtcNow);
if (error == "stale_challenge_map") return Conflict(new { error });
return error == null ? Ok() : BadRequest(new { error });
}
}
+37
View File
@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.RecNet.Responses;
namespace RadicadeServer.API.Progression
{
[Route("econ/api/checklist")]
[Route("api/checklist")]
public class ChecklistAPI : Controller
{
private static readonly object[] DefaultChecklist =
{
new { Order = 1, Objective = 32, Count = 1, CreditAmount = 25 },
new { Order = 2, Objective = 2, Count = 1, CreditAmount = 25 },
new { Order = 3, Objective = 30, Count = 1, CreditAmount = 25 },
new { Order = 4, Objective = 6, Count = 1, CreditAmount = 25 },
};
[HttpGet("v1/current"), Authorize]
[HttpGet("v2/current"), Authorize]
public IActionResult Current()
{
return Ok(DefaultChecklist);
}
[HttpPost("v1/complete"), Authorize]
[HttpPost("v2/complete"), Authorize]
public IActionResult Complete()
{
return Ok(new Success(true, null, new
{
BalanceUpdates = Array.Empty<object>(),
CurrencyBalances = Array.Empty<object>(),
}));
}
}
}
@@ -0,0 +1,102 @@
namespace RadicadeServer.RecNet.Enums.Challenges
{
public enum ObjectiveTypes
{
Default = -1,
FirstSessionOfDay = 1,
AddAFriend,
PartyUp,
AllOtherChallenges,
LevelUp,
CheerAPlayer,
PointedAtPlayer,
CheerARoom,
SubscribeToPlayer,
DailyObjective1,
DailyObjective2,
DailyObjective3,
AllDailyObjectives,
CompleteAnyDaily,
CompleteAnyWeekly,
OOBE_GoToLockerRoom = 20,
OOBE_GoToActivity,
OOBE_FinishActivity,
NUX_PunchcardObjective = 25,
NUX_AllPunchcardObjectives,
GoToRecCenter = 30,
FinishActivity,
VisitACustomRoom,
CreateACustomRoom,
ScoreBasketInRecCenter = 35,
UploadPhotoToRecNet,
UpdatePlayerBio,
SaveOutfitSlot,
PurchaseClothingItem,
PurchaseNonClothingItem,
CharadesGames = 100,
CharadesWinsPerformer,
CharadesWinsGuesser,
DiscGolfWins = 200,
DiscGolfGames,
DiscGolfHolesUnderPar,
DodgeballWins = 300,
DodgeballGames,
DodgeballHits,
PaddleballGames = 400,
PaddleballWins,
PaddleballScores,
PaintballAnyModeGames = 500,
PaintballAnyModeWins,
PaintballAnyModeHits,
PaintballCTFWins = 600,
PaintballCTFGames,
PaintballCTFHits,
PaintballFlagCaptures,
PaintballTeamBattleWins = 700,
PaintballTeamBattleGames,
PaintballTeamBattleHits,
PaintballFreeForAllWins = 710,
PaintballFreeForAllGames,
PaintballFreeForAllHits,
SoccerWins = 800,
SoccerGames,
SoccerGoals,
BowlingGames = 900,
BowlingWins,
BowlingStrike,
QuestGames = 1000,
QuestWins,
QuestPlayerRevives,
QuestEnemyKills,
QuestGames_Goblin1 = 1010,
QuestWins_Goblin1,
QuestPlayerRevives_Goblin1,
QuestEnemyKills_Goblin1,
QuestGames_Goblin2 = 1020,
QuestWins_Goblin2,
QuestPlayerRevives_Goblin2,
QuestEnemyKills_Goblin2,
QuestGames_Scifi1 = 1030,
QuestWins_Scifi1,
QuestPlayerRevives_Scifi1,
QuestEnemyKills_Scifi1,
QuestGames_Pirate1 = 1040,
QuestWins_Pirate1,
QuestPlayerRevives_Pirate1,
QuestEnemyKills_Pirate1,
QuestGames_Dracula1 = 1050,
QuestWins_Dracula1,
QuestPlayerRevives_Dracula1,
QuestEnemyKills_Dracula1,
ArenaGames = 2000,
ArenaWins,
ArenaPlayerRevives,
ArenaHeroTags,
ArenaBotTags,
RecRoyaleGames = 3000,
RecRoyaleWins,
RecRoyaleTags,
StuntRunnerGames = 4000,
StuntRunnerWins
}
}
+603
View File
@@ -0,0 +1,603 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
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.Moderation;
using RadicadeServer.RecNet.Progression;
using RadicadeServer.RecNet.Storefront;
using RadicadeServer.Utils;
namespace RadicadeServer.API.GameRewards.V1
{
public class GameRewardRequestDTO
{
public RewardType RewardType { get; set; }
public string Message { get; set; } = string.Empty;
public GiftContext? GiftContext { get; set; }
}
public class GameRewardSelectDTO
{
public long RewardSelectionId { get; set; }
public long GiftDropId { get; set; }
}
[Route("api/gamerewards/v1")]
[Route("econ/api/gamerewards/v1")]
public class GameRewardsAPI(RecDBContext db) : Controller
{
[HttpGet("pending")]
public async Task<List<GameReward>> GetPendingGameRewards()
{
var userId = User.GetUserId();
if (userId == null) return new List<GameReward>();
var data = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == userId.Value);
var progression = data?.GetProgressionJson();
var pending = progression?.PendingGameRewards;
if (pending == null) return new List<GameReward>();
var changed = false;
var forgotten = pending.RemoveAll(r => r.Consumed && r.ConsumedAt is { } at && at < DateTime.UtcNow.AddDays(-1));
changed |= forgotten > 0;
var outstanding = pending.Where(r => !r.Consumed).ToList();
foreach (var reward in outstanding)
changed |= ClampRewardIds(reward);
if (changed && data != null)
{
data.Progress = progression!.ToJson();
await db.SaveChangesAsync();
}
return outstanding;
}
private static bool ClampRewardIds(GameReward reward)
{
var changed = false;
void Clamp(Gift? gift)
{
if (gift == null || (gift.GiftDropId >= int.MinValue && gift.GiftDropId <= int.MaxValue)) return;
gift.GiftDropId = StorefrontShared.NewGiftDropId();
changed = true;
}
if (reward.RewardSelectionId > int.MaxValue || reward.RewardSelectionId < int.MinValue)
{
_ = reward.RewardSelectionId;
}
Clamp(reward.GiftDrop1);
Clamp(reward.GiftDrop2);
Clamp(reward.GiftDrop3);
return changed;
}
[HttpPost("request"), Authorize]
public async Task<ActionResult<OkResponse>> RequestReward()
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var dto = await ReadRewardBodyAsync();
if (dto == null) return BadRequest();
if (dto.RewardType == RewardType.WeeklyCompleteButHaveSkin)
return Ok(new OkResponse { Success = true });
Trace(userId.Value, $"claimed rewardType={dto.RewardType} ({(int)dto.RewardType}) giftContext={dto.GiftContext?.ToString() ?? "none"} body={_rawBody}");
var data = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == userId.Value);
if (data == null)
{
data = new ProgressionData { PlayerId = userId.Value };
db.Progression.Add(data);
}
var json = data.GetProgressionJson() ?? new ProgressionJson();
json.PendingGameRewards ??= new List<GameReward>();
var ledger = RewardDay.For(json, DateTime.UtcNow);
var completion = IsCompletion(dto.RewardType);
GameReward? reward = null;
if (completion)
{
var rewardType = RewardDay.CompletionType(ledger, dto.RewardType);
var paid = rewardType == RewardType.FirstActivityOfDay
? RewardDay.TryTakeFirstActivity(ledger)
: RewardDay.TryTakeActivityBox(ledger);
if (paid)
{
dto.Message = CompletionMessage(rewardType, dto.Message);
reward = await BuildRewardAsync(userId.Value, dto, rewardType);
}
}
else if (dto.RewardType == RewardType.DailyChallengesComplete)
{
if (RewardDay.TryTakeDailyChallengeBox(ledger))
{
dto.Message = string.IsNullOrWhiteSpace(dto.Message) ? "Daily Challenge Reward" : dto.Message;
reward = await BuildDailyChallengeRewardAsync(userId.Value, dto);
}
}
else
{
reward = BuildReward(dto.RewardType, dto.Message,
BonusContext(dto.RewardType) ?? dto.GiftContext ?? GiftContext.GameRewards_Tokens);
}
if (reward != null)
json.PendingGameRewards.Add(reward);
data.Progress = json.ToJson();
await db.SaveChangesAsync();
if (completion)
{
await ProgressionAwardService.AwardXpAsync(db, userId.Value,
ProgressionAwardService.XpPerGameCompleted, "game completed");
}
if (reward == null) return Ok(new OkResponse { Success = true });
if (RroRewardPools.IsQuestContext(reward.GiftContext))
{
await DeliverQuestRewardAsync(userId.Value, reward);
data.Progress = json.ToJson();
await db.SaveChangesAsync();
return Ok(new OkResponse { Success = true });
}
await PushRewardSelectionAsync(userId.Value, reward);
return Ok(new OkResponse { Success = true });
}
private async Task DeliverQuestRewardAsync(long playerId, GameReward reward)
{
var box = reward.GiftDrop1;
if (box == null) return;
if (box.GiftDropId == 0) box.GiftDropId = StorefrontShared.NewGiftDropId();
var already = await GameRewardRedemption.TakeRecentlyHandedOverAsync(
db, playerId, reward.GiftContext, TimeSpan.FromMinutes(10));
if (already == null)
await StorefrontShared.PersistGiftBoxAsync(db, playerId, box, notify: true);
reward.Consumed = true;
reward.ConsumedGiftDropId = already?.GiftDropId ?? box.GiftDropId;
reward.ConsumedAt = DateTime.UtcNow;
RewardTrace.Log(playerId, $"stage=DeliveredAsQuestBox context={RewardTrace.Context(reward.GiftContext)} " +
$"selectionId={reward.RewardSelectionId} alreadyHandedOver={already != null} box={RewardTrace.Box(box)} " +
"a quest's reward is the box its run generated, not a selection.");
}
private static string CompletionMessage(RewardType rewardType, string? claimed) => rewardType switch
{
RewardType.FirstActivityOfDay => "First Game of the Day",
RewardType.PostQuestActivity => string.IsNullOrWhiteSpace(claimed) ? "Quest Complete" : claimed,
_ => "Game Reward"
};
private async Task<GameReward> BuildDailyChallengeRewardAsync(long playerId, GameRewardRequestDTO dto)
{
var owned = await LevelUpGiftService.CollectOwnedKeysAsync(db, playerId, GiftContext.Daily_Challenge_Complete);
var drops = RroRewardPools.BuildDailyChallengeSelection(owned: owned);
if (drops.Count == 0)
{
return BuildReward(dto.RewardType, dto.Message, GiftContext.Daily_Challenge_Complete);
}
foreach (var drop in drops)
drop.GiftDropId = StorefrontShared.NewGiftDropId();
return new GameReward
{
RewardSelectionId = StorefrontShared.NewGiftDropId(),
Message = string.IsNullOrWhiteSpace(dto.Message) ? "Daily Challenge Reward" : dto.Message,
GiftContext = GiftContext.Daily_Challenge_Complete,
RewardType = dto.RewardType,
GiftDrop1 = drops[0],
GiftDrop2 = drops.Count > 1 ? drops[1] : drops[0],
GiftDrop3 = drops.Count > 2 ? drops[2] : drops[0],
CreatedAt = DateTime.UtcNow
};
}
[HttpPost("select"), Authorize]
public async Task<ActionResult<OkResponse>> SelectReward()
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var dto = await ReadSelectBodyAsync();
if (dto == null || dto.RewardSelectionId <= 0 || dto.GiftDropId <= 0) return BadRequest();
var data = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == userId.Value);
if (data == null) return NotFound();
var json = data.GetProgressionJson();
var reward = json?.PendingGameRewards?.FirstOrDefault(r => r.RewardSelectionId == dto.RewardSelectionId);
if (reward == null) return NotFound();
var drops = new[] { reward.GiftDrop1, reward.GiftDrop2, reward.GiftDrop3 }
.Where(g => g != null)
.ToList();
if (reward.Consumed)
{
return Ok(new OkResponse { Success = true });
}
var gift = drops.FirstOrDefault(g => g.GiftDropId == dto.GiftDropId);
if (gift == null)
{
var available = string.Join(", ", drops.Select(d => d.GiftDropId));
gift = drops.FirstOrDefault();
if (gift == null) return NotFound();
}
await StorefrontShared.PersistGiftBoxAsync(db, userId.Value, gift, notify: true);
reward.Consumed = true;
reward.ConsumedGiftDropId = gift.GiftDropId;
reward.ConsumedAt = DateTime.UtcNow;
data.Progress = json!.ToJson();
await db.SaveChangesAsync();
return Ok(new OkResponse { Success = true });
}
private async Task<GameRewardRequestDTO?> ReadRewardBodyAsync()
{
var fields = await ReadBodyFieldsAsync();
if (fields.Count == 0) return null;
var dto = new GameRewardRequestDTO();
if (fields.TryGetValue("rewardType", out var rewardTypeRaw))
{
if (int.TryParse(rewardTypeRaw, out var rewardTypeInt) && Enum.IsDefined(typeof(RewardType), rewardTypeInt))
dto.RewardType = (RewardType)rewardTypeInt;
else if (Enum.TryParse<RewardType>(rewardTypeRaw, true, out var rewardTypeEnum))
dto.RewardType = rewardTypeEnum;
}
if (fields.TryGetValue("Message", out var message))
dto.Message = message;
dto.GiftContext = ReadClaimedContext(fields);
return dto;
}
private static GiftContext? ReadClaimedContext(IReadOnlyDictionary<string, string> fields)
{
if (!fields.TryGetValue("giftContext", out var raw) || string.IsNullOrWhiteSpace(raw)) return null;
if (int.TryParse(raw, out var numeric) && Enum.IsDefined(typeof(GiftContext), numeric))
return (GiftContext)numeric;
return Enum.TryParse<GiftContext>(raw.Trim(), true, out var named) ? named : null;
}
private async Task<GameRewardSelectDTO?> ReadSelectBodyAsync()
{
var fields = await ReadBodyFieldsAsync();
if (fields.Count == 0) return null;
var dto = new GameRewardSelectDTO();
if (fields.TryGetValue("rewardSelectionId", out var selectionRaw) && long.TryParse(selectionRaw, out var selectionId))
dto.RewardSelectionId = selectionId;
if (fields.TryGetValue("giftDropId", out var dropRaw) && long.TryParse(dropRaw, out var dropId))
dto.GiftDropId = dropId;
return dto;
}
private async Task<Dictionary<string, string>> ReadBodyFieldsAsync()
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var raw = (await reader.ReadToEndAsync()).Trim();
Request.Body.Position = 0;
_rawBody = raw;
if (raw.Length == 0) return fields;
if (raw.StartsWith('{'))
{
using var doc = System.Text.Json.JsonDocument.Parse(raw);
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
fields[prop.Name] = prop.Value.ValueKind == System.Text.Json.JsonValueKind.String
? prop.Value.GetString() ?? ""
: prop.Value.ToString();
}
}
}
else
{
foreach (var pair in raw.Split('&'))
{
var kv = pair.Split('=', 2);
if (kv.Length < 2) continue;
fields[Uri.UnescapeDataString(kv[0])] = Uri.UnescapeDataString(kv[1]);
}
}
}
catch (Exception)
{
}
return fields;
}
private async Task<GameReward> BuildRewardAsync(long playerId, GameRewardRequestDTO dto, RewardType rewardType)
{
var lastRoom = await MatchmakingUtils.GetLastVisitedRoomNameAsync(db, playerId);
var roomName = lastRoom;
var claimedContext = dto.GiftContext;
if (claimedContext.HasValue
&& RroRewardPools.RoomForContext(claimedContext.Value) is { } claimedRoom
&& !RroRewardPools.ContextsForRoom(roomName).Values.Contains(claimedContext.Value))
{
roomName = claimedRoom;
}
if (!RroRewardPools.IsRewardActivity(roomName)
&& await MatchmakingUtils.GetLastRewardActivityRoomNameAsync(db, playerId, ActivityVisitWindow) is { } activityRoom)
{
Trace(playerId, $"lastRoom={lastRoom} is not an activity this server pays; {activityRoom} is the newest activity they were placed into, so that is the activity this completion is answered from.");
roomName = activityRoom;
}
var paidByRoom = RroRewardPools.ContextsForRoom(roomName);
var paidList = string.Join(", ", paidByRoom.Select(x => x.Key + "=" + (int)x.Value));
Trace(playerId, "rewardType=" + rewardType
+ " claimedContext=" + dto.GiftContext + " lastRoom=" + lastRoom
+ " activity=" + roomName
+ " isRewardActivity=" + RroRewardPools.IsRewardActivity(roomName) + " roomPays=[" + paidList + "]");
if (!RroRewardPools.IsRewardActivity(roomName))
{
Trace(playerId, "room=" + roomName + " is not an activity this server pays rewards for; paying the token package.");
return BuildReward(rewardType, dto.Message, dto.GiftContext ?? GiftContext.GameRewards_Tokens);
}
var paid = RroRewardPools.ContextsForRoom(roomName);
var claimed = dto.GiftContext;
GiftContext context;
if (claimed.HasValue && paid.Values.Contains(claimed.Value))
{
context = claimed.Value;
}
else if (RroRewardPools.TryFloorContext(roomName, out var floor))
{
context = floor;
}
else
{
return BuildReward(rewardType, dto.Message, GiftContext.GameRewards_Tokens);
}
if (RroRewardPools.PaysTickets(roomName))
{
var games = LaserTagGamesToday.AddOrUpdate((playerId, DateOnly.FromDateTime(DateTime.UtcNow)), 1, (_, count) => count + 1);
var tickets = RroRewardPools.LaserTagTicketsPerGame
+ (games <= RroRewardPools.LaserTagBonusGamesPerDay ? RroRewardPools.LaserTagFirstGamesBonus : 0);
return TicketReward(rewardType, dto.Message, GiftContext.Lasertag, tickets);
}
var selection = RroRewardPools.OffersSelection(roomName);
var owned = await LevelUpGiftService.CollectOwnedKeysAsync(db, playerId, context);
var fromOwnPool = !RroRewardPools.PaysNoItemReward(roomName)
&& Random.Shared.NextDouble() <= RroRewardPools.DropChanceFor(context);
var drops = fromOwnPool
? RroRewardPools.BuildDrops(context, selection, owned)
: new List<Gift>();
var source = context;
var reacquired = drops.Count == 0;
if (reacquired)
{
foreach (var weaker in RroRewardPools.LowerRanksOf(context))
{
drops = RroRewardPools.BuildDrops(weaker, selection, owned);
if (drops.Count == 0) continue;
break;
}
}
if (drops.Count == 0)
{
if (selection)
{
drops = RroRewardPools.BuildFallbackSelection(owned: owned, message: dto.Message);
}
else
{
drops = RroRewardPools.BuildFallbackDrops(selection: false, owned: owned);
}
}
if (drops.Count == 0)
{
RewardTrace.Error(playerId, $"stage=Fallback activity={roomName} rank={RewardRankOf(context, roomName)} " +
$"context={RewardTrace.Context(context)} pool={DescribeSets(context)} " +
$"EligibleItems={RroRewardPools.Resolve(context).Count} UnownedItems=0 " +
"Reason=neither the activity's pool nor the reacquisition pool has anything left to give; " +
"paying the token package as the last resort.");
return BuildReward(rewardType, dto.Message, context);
}
foreach (var drop in drops)
drop.GiftDropId = StorefrontShared.NewGiftDropId();
var reward = new GameReward
{
RewardSelectionId = StorefrontShared.NewGiftDropId(),
Message = string.IsNullOrWhiteSpace(dto.Message) ? "Game Reward" : dto.Message,
GiftContext = source,
RewardType = rewardType,
GiftDrop1 = drops[0],
GiftDrop2 = drops.Count > 1 ? drops[1] : drops[0],
GiftDrop3 = drops.Count > 2 ? drops[2] : drops[0],
CreatedAt = DateTime.UtcNow
};
var eligible = RroRewardPools.Resolve(context);
var unowned = eligible
.Where(item => !owned.Contains(LevelUpRewardPool.OwnershipKey(item.AvatarItemDesc)))
.ToList();
var boxKind = IsTokenBox(drops[0]) ? "Tokens" : "AvatarItem";
RewardTrace.Log(playerId, $"stage=Quest activity={roomName} rank={RewardRankOf(context, roomName)} " +
$"context={RewardTrace.Context(context)} source={RewardTrace.Context(source)} rewardType={rewardType}");
RewardTrace.Log(playerId, $"stage=RewardPool context={RewardTrace.Context(context)} " +
$"pool={DescribeSets(context)} EligibleItems={eligible.Count} {RewardTrace.Items(eligible)}");
RewardTrace.Log(playerId, $"stage=UnownedItems context={RewardTrace.Context(context)} UnownedItems={unowned.Count} {RewardTrace.Items(unowned)}");
RewardTrace.Log(playerId, $"stage=SelectedItem {RewardTrace.Boxes(drops)}");
RewardTrace.Log(playerId, $"stage=BoxCreated selectionId={reward.RewardSelectionId} " +
$"boxType={boxKind} BoxRewardItem=\"{drops[0].AvatarItemDesc}\"");
RewardTrace.Log(playerId, $"stage=GrantReward giftDropId={drops[0].GiftDropId} " +
$"name=\"{drops[0].FriendlyName}\" item=\"{drops[0].AvatarItemDesc}\" " +
$"consumable=\"{drops[0].ConsumableItemDesc}\" currency={drops[0].CurrencyType} {drops[0].Currency}");
RewardTrace.Log(playerId, $"stage=FinalReward rewardType={rewardType} context={RewardTrace.Context(source)} " +
$"boxType={boxKind} firstTime={!reacquired}");
return reward;
}
private static readonly ConcurrentDictionary<(long PlayerId, DateOnly Day), int> LaserTagGamesToday = new();
private static GameReward TicketReward(RewardType type, string message, GiftContext context, int tickets)
{
var box = new Gift
{
GiftDropId = StorefrontShared.NewGiftDropId(),
FriendlyName = "Laser Tag Tickets",
Tooltip = string.IsNullOrWhiteSpace(message) ? "Tickets for playing!" : message,
CurrencyType = CurrencyType.LaserTagTickets,
Currency = tickets,
Context = context,
Rarity = Rarity.Common
};
return new GameReward
{
RewardSelectionId = StorefrontShared.NewGiftDropId(),
Message = string.IsNullOrWhiteSpace(message) ? "Laser Tag Tickets" : message,
GiftContext = context,
RewardType = type,
GiftDrop1 = box,
GiftDrop2 = box,
GiftDrop3 = box,
CreatedAt = DateTime.UtcNow
};
}
private static bool IsCompletion(RewardType type) =>
type is RewardType.FirstActivityOfDay or RewardType.PostGameActivity or RewardType.PostQuestActivity;
private static GiftContext? BonusContext(RewardType type) => type switch
{
RewardType.DailyChallengesComplete => GiftContext.All_Daily_Challenges_Complete,
RewardType.WeeklyCompleteButHaveSkin => GiftContext.All_Weekly_Challenge_Complete,
RewardType.Levelup => GiftContext.LevelUp,
_ => null
};
private static GameReward BuildReward(RewardType type, string message, GiftContext context)
{
int baseTokens = type switch
{
RewardType.DailyChallengesComplete => 150,
RewardType.WeeklyCompleteButHaveSkin => 300,
RewardType.Levelup => 100,
_ => RroRewardPools.CompletionTokenAmount
};
return new GameReward
{
RewardSelectionId = StorefrontShared.NewGiftDropId(),
Message = string.IsNullOrWhiteSpace(message) ? "Tokens" : message,
GiftContext = context,
RewardType = type,
GiftDrop1 = RroRewardPools.TokenGift(context, baseTokens, message),
GiftDrop2 = RroRewardPools.TokenGift(context, baseTokens * 2, message),
GiftDrop3 = RroRewardPools.TokenGift(context, baseTokens * 3, message),
CreatedAt = DateTime.UtcNow
};
}
private static async Task PushRewardSelectionAsync(long playerId, GameReward reward)
{
var number = Convert.ToInt64(
RadicadeServer.RecNet.Enums.Messages.NotificationType.GiftPackageRewardSelectionReceived,
System.Globalization.CultureInfo.InvariantCulture)
.ToString(System.Globalization.CultureInfo.InvariantCulture);
foreach (var id in new[] { RewardSelectionNotificationName, number })
{
try
{
await RadicadeServer.API.Notifications.Notify.SendNotify(playerId, new RadicadeServer.RecNet.Notifications.Notification
{
Id = id,
Msg = reward
});
}
catch (Exception)
{
}
}
}
private static readonly TimeSpan ActivityVisitWindow = TimeSpan.FromHours(6);
private string? _rawBody;
private static void Trace(long playerId, string line) => RewardTrace.Log(playerId, line);
private static string RewardRankOf(GiftContext context, string? roomName)
{
foreach (var (rank, paid) in RroRewardPools.ContextsForRoom(roomName))
{
if (paid != context) continue;
return string.IsNullOrEmpty(rank) ? "unranked" : rank;
}
return RroRewardPools.IsDeprecated(context) ? "deprecated" : "not paid by this room";
}
private static string DescribeSets(GiftContext context)
{
var sets = RroRewardPools.SetsFor(context);
if (sets.Count == 0) return "<none configured>";
return string.Join(", ", sets.Select(set =>
string.IsNullOrEmpty(set.Colour) ? set.Family : $"{set.Family} {set.Colour}"));
}
private static bool IsTokenBox(Gift box) =>
string.IsNullOrWhiteSpace(box.AvatarItemDesc)
&& string.IsNullOrWhiteSpace(box.ConsumableItemDesc)
&& box.CurrencyType != CurrencyType.Invalid
&& box.Currency != 0;
private const string RewardSelectionNotificationName = "RewardSelectionReceived";
}
}
@@ -0,0 +1,9 @@
namespace RadicadeServer.RecNet.Leveling
{
public class LevelProgressionMap
{
public int Level { get; set; }
public int RequiredXp { get; set; }
public long? GiftDropId { get; set; }
}
}
@@ -0,0 +1,49 @@
using RadicadeServer.RecNet.Enums.Store;
namespace RadicadeServer.Utils
{
public static class LevelRewardTable
{
public enum RewardKind
{
None,
Consumable,
Clothing
}
public const int LastItemLevel = LevelUpGiftService.MaxLevel;
private static readonly HashSet<int> ConsumableLevels = [1, 3, 5, 7, 9];
private static readonly HashSet<int> ThreeStarLevels =
[22, 24, 26, 27, 28, 29, 30, 32, 33, 34, 35, 36, 37, 38, 39];
private static readonly HashSet<int> FourStarLevels =
[31, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49];
public static bool IsItemLevel(int level) => level >= 1 && level <= LastItemLevel;
public static RewardKind KindForLevel(int level)
{
if (level < 1 || level > LastItemLevel) return RewardKind.None;
return ConsumableLevels.Contains(level) ? RewardKind.Consumable : RewardKind.Clothing;
}
public static Rarity RarityForLevel(int level)
{
if (level == 50) return Rarity.Legendary;
if (FourStarLevels.Contains(level)) return Rarity.Epic;
if (ThreeStarLevels.Contains(level)) return Rarity.Rare;
return Rarity.Uncommon;
}
public static string StarLabel(Rarity rarity) => rarity switch
{
Rarity.Legendary => "5-star",
Rarity.Epic => "4-star",
Rarity.Rare => "3-star",
Rarity.Uncommon => "2-star",
_ => "1-star"
};
}
}
@@ -0,0 +1,290 @@
using Microsoft.EntityFrameworkCore;
using RadicadeServer.API.Storefront;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Enums.Players.Avatar;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.RecNet.Leveling;
using RadicadeServer.RecNet.Progression;
using RadicadeServer.RecNet.Store;
namespace RadicadeServer.Utils
{
public static class LevelUpGiftService
{
public static List<LevelProgressionMap> ParseMap()
{
var map = FirstTimeUtils.LevelProgressionMap.FromJson<List<LevelProgressionMap>>() ?? new List<LevelProgressionMap>();
return map.OrderBy(x => x.Level).ToList();
}
public const int MaxLevel = 50;
public static int ComputeLevel(List<LevelProgressionMap> map, int xp)
{
int cumulative = 0;
int level = 1;
foreach (var entry in map.OrderBy(x => x.Level))
{
if (entry.Level <= 0) continue;
cumulative += entry.RequiredXp;
if (xp < cumulative) break;
level = entry.Level;
}
return level > MaxLevel ? MaxLevel : level;
}
public static int ThresholdXpForLevel(List<LevelProgressionMap> map, int level)
{
int threshold = 0;
foreach (var entry in map.OrderBy(x => x.Level))
{
if (entry.Level <= 0 || entry.Level > level) continue;
threshold += entry.RequiredXp;
}
return threshold;
}
public static int RequiredXpForLevel(List<LevelProgressionMap> map, int level) =>
ThresholdXpForLevel(map, level) - ThresholdXpForLevel(map, level - 1);
public static int DisplayLevel(int level) => level > MaxLevel ? MaxLevel : level;
public static int XpIntoCurrentLevel(List<LevelProgressionMap> map, int level, int totalXp)
{
if (level >= MaxLevel) return RequiredXpForLevel(map, MaxLevel);
int reached = ComputeLevel(map, totalXp);
int from = level < reached ? level : reached;
int into = totalXp - ThresholdXpForLevel(map, from);
return into < 0 ? 0 : into;
}
public static int XpIntoCurrentLevel(ProgressionData data) =>
XpIntoCurrentLevel(ParseMap(), data.Level, data.XP);
public const bool LevelUpGiftDropsAreSeeded = false;
private static List<AvatarItem>? _avatarItems;
private static List<ConsumableItem>? _consumables;
internal static List<AvatarItem> ClothingCatalogue(string? json = null)
{
if (!string.IsNullOrWhiteSpace(json)) return ParseClothingCatalogue(json);
return _avatarItems ??= ParseClothingCatalogue(DBConfig.DefaultAvatarCatalog);
}
private static List<AvatarItem> ParseClothingCatalogue(string json) =>
(json.FromJson<List<AvatarItem>>() ?? new List<AvatarItem>())
.Where(x => x != null && !string.IsNullOrWhiteSpace(x.AvatarItemDesc) && !x.IsBaseAvatarItem)
.OrderBy(x => x.AvatarItemId)
.ThenBy(x => x.AvatarItemDesc, StringComparer.Ordinal)
.ToList();
private static List<ConsumableItem> ConsumableCatalogue(string? json = null)
{
if (!string.IsNullOrWhiteSpace(json)) return ParseConsumableCatalogue(json);
return _consumables ??= ParseConsumableCatalogue(DBConfig.DefaultConsumableItems);
}
private static List<ConsumableItem> ParseConsumableCatalogue(string json) =>
(json.FromJson<List<ConsumableItem>>() ?? new List<ConsumableItem>())
.Where(x => x != null && !string.IsNullOrWhiteSpace(x.ConsumableItemDesc))
.OrderBy(x => x.ConsumableItemDesc, StringComparer.Ordinal)
.ToList();
public static Gift? BuildItemReward(
int level,
string? avatarCatalogJson = null,
string? consumableCatalogJson = null,
ISet<string>? owned = null,
Random? random = null,
LevelUpRewardPool.Pool? pool = null)
{
if (!LevelRewardTable.IsItemLevel(level)) return null;
var ownedKeys = owned ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var rng = random ?? Random.Shared;
return LevelRewardTable.KindForLevel(level) == LevelRewardTable.RewardKind.Consumable
? BuildConsumableReward(level, consumableCatalogJson, ownedKeys, rng, pool)
: BuildClothingReward(level, avatarCatalogJson, consumableCatalogJson, ownedKeys, rng, pool);
}
private static Gift? BuildClothingReward(
int level,
string? avatarCatalogJson,
string? consumableCatalogJson,
ISet<string> owned,
Random random,
LevelUpRewardPool.Pool? pool)
{
var rarity = LevelRewardTable.RarityForLevel(level);
var pick = LevelUpRewardPool.PickAvatarItem(ClothingCatalogue(avatarCatalogJson), rarity, owned, random, pool);
if (pick == null)
{
return BuildConsumableReward(level, consumableCatalogJson, owned, random, pool);
}
var label = LevelRewardTable.StarLabel(rarity);
return new Gift
{
FriendlyName = $"Level {level} Reward - {label} {pick.FriendlyName}",
Tooltip = $"Congrats on getting to Level {level}!",
AvatarItemDesc = pick.AvatarItemDesc,
AvatarItemType = AvatarItemType.Outfit,
Rarity = rarity,
Currency = 0,
Xp = 0
};
}
private static Gift? BuildConsumableReward(
int level,
string? consumableCatalogJson,
ISet<string> owned,
Random random,
LevelUpRewardPool.Pool? pool)
{
var pick = LevelUpRewardPool.PickConsumable(ConsumableCatalogue(consumableCatalogJson), owned, random, pool);
if (pick == null)
{
return null;
}
var token = ConsumableCatalog.ToClientDesc(pick.ConsumableItemDesc);
if (string.IsNullOrWhiteSpace(token)) return null;
return new Gift
{
FriendlyName = $"Level {level} Reward - {pick.FriendlyName}",
Tooltip = $"Congrats on getting to Level {level}!",
ConsumableItemDesc = token,
Rarity = pick.Rarity,
Currency = 0,
Xp = 0
};
}
public static Gift? BuildConsumableGift(
string? consumableCatalogJson, ISet<string> owned, Random random, LevelUpRewardPool.Pool? pool = null)
{
var pick = LevelUpRewardPool.PickConsumable(ConsumableCatalogue(consumableCatalogJson), owned, random, pool);
if (pick == null) return null;
var token = ConsumableCatalog.ToClientDesc(pick.ConsumableItemDesc);
if (string.IsNullOrWhiteSpace(token)) return null;
return new Gift
{
FriendlyName = pick.FriendlyName,
Tooltip = pick.FriendlyName,
ConsumableItemDesc = token,
Rarity = pick.Rarity,
Currency = 0,
Xp = 0
};
}
public static async Task<HashSet<string>> CollectOwnedKeysAsync(
RecDBContext db, long playerId, GiftContext? pendingContext = GiftContext.LevelUp)
{
var owned = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == playerId);
if (unlocks != null)
{
foreach (var item in unlocks.GetAvatarItems())
if (!string.IsNullOrWhiteSpace(item?.AvatarItemDesc))
owned.Add(LevelUpRewardPool.OwnershipKey(item.AvatarItemDesc));
foreach (var item in unlocks.GetConsumables())
if (!string.IsNullOrWhiteSpace(item?.ConsumableItemDesc))
owned.Add(LevelUpRewardPool.OwnershipKey(item.ConsumableItemDesc));
}
var gifts = await db.Gifts.FirstOrDefaultAsync(x => x.AccountId == playerId);
foreach (var pending in StorefrontShared.ParseGiftList(gifts?.Data))
{
if (pending == null) continue;
if (pendingContext.HasValue && pending.Context != pendingContext.Value) continue;
Remember(owned, pending);
}
return owned;
}
private static void Remember(HashSet<string> owned, Gift gift)
{
if (!string.IsNullOrWhiteSpace(gift.AvatarItemDesc))
owned.Add(LevelUpRewardPool.OwnershipKey(gift.AvatarItemDesc));
if (!string.IsNullOrWhiteSpace(gift.ConsumableItemDesc))
owned.Add(LevelUpRewardPool.OwnershipKey(gift.ConsumableItemDesc));
}
public static async Task<List<Gift>> DeliverLevelUpGiftsAsync(RecDBContext db, long playerId, bool recomputeFromXp, int? fromLevel = null, bool notify = true)
{
var progression = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId);
if (progression == null) return new List<Gift>();
var map = ParseMap();
int oldLevel = fromLevel ?? progression.Level;
int target = progression.Level;
if (recomputeFromXp)
{
int computed = ComputeLevel(map, progression.XP);
if (computed > target) target = computed;
}
if (target <= oldLevel) return new List<Gift>();
var json = progression.GetProgressionJson() ?? new ProgressionJson();
var delivered = json.DeliveredLevelUpGiftLevels ?? new List<int>();
var owned = await CollectOwnedKeysAsync(db, playerId);
var random = Random.Shared;
var toPersist = new List<Gift>();
foreach (var entry in map.OrderBy(e => e.Level))
{
if (entry.Level <= oldLevel) continue;
if (entry.Level > target) break;
if (delivered.Contains(entry.Level)) continue;
var gift = LevelRewardTable.IsItemLevel(entry.Level)
? BuildItemReward(entry.Level, owned: owned, random: random)
: null;
if (gift == null) continue;
Remember(owned, gift);
gift.GiftDropId = StorefrontShared.NewGiftDropId();
gift.Context = GiftContext.LevelUp;
gift.FromPlayerId = 0;
toPersist.Add(gift);
delivered.Add(entry.Level);
}
if (toPersist.Count == 0) return new List<Gift>();
json.DeliveredLevelUpGiftLevels = delivered;
progression.Progress = json.ToJson();
progression.Level = target;
foreach (var gift in toPersist)
{
await StorefrontShared.PersistGiftBoxAsync(db, playerId, gift, notify: false);
}
await db.SaveChangesAsync();
if (notify)
{
foreach (var gift in toPersist)
await StorefrontShared.SendGiftPackageNotificationAsync(playerId, gift);
}
return toPersist;
}
}
}
@@ -0,0 +1,166 @@
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Enums.Store;
namespace RadicadeServer.Utils
{
public static class LevelUpRewardPool
{
public const string ConfigFileName = "levelup-rewards.json";
public sealed class Pool
{
public List<string> AvatarItems { get; set; } = new();
public List<string> Consumables { get; set; } = new();
}
private static readonly string[] DeniedPatterns =
[
"exclusive", "early access", "alpha", "art festival",
"rec con", "reccon", "rrcc", "raw data", "sfvrcc",
"contest", "trophy", "competition",
"moderator", "volunteer",
"video partner", "creator program", "partnered creator", "partner",
"bug bounty", "security", "ethical hackers", "gold notes",
"seller swag", "sponsored league",
"rro-", "rro specific",
"promo", "promotional", "sponsor", "collab", "licensed", "prom",
"mrbeast", "mr beast", "miraculous", "nba",
"class of", "graduation", "class)", "creator conference",
"for earning", "available until", "unlocked by visiting",
"unlocked during", "xbox", "launch reward", "launch exclusive",
"reward for", "given to", "space marine", "space visor",
"(gold)",
"limited", "never return", "founder", "founders",
"halloween", "holiday", "thanksgiving", "invasion", "elseware",
"new year", "carnivale", "reclympics", "rec rocks", "anniversary", "2020 glasses",
"developer", "dev only", "staff only", "admin only", "test item", "debug",
];
private static readonly string[] DeniedTags =
[
"halloween", "holidays", "thanksgiving", "invasion", "elseware", "reccon", "reccon2021",
];
private static Pool? _cached;
private static string? _cachedFrom;
public static void ResetCache()
{
_cached = null;
_cachedFrom = null;
}
public static Pool Load(string? json = null)
{
if (json != null) return Parse(json);
if (_cached != null) return _cached;
foreach (var directory in DBConfig.AvatarCatalogDirectories())
{
var path = Path.Join(directory, ConfigFileName);
if (!File.Exists(path)) continue;
try
{
_cached = Parse(File.ReadAllText(path));
_cachedFrom = path;
return _cached;
}
catch (Exception)
{
}
}
_cached = new Pool();
return _cached;
}
public static string? LoadedFrom => _cachedFrom;
private static Pool Parse(string json)
{
var pool = json.FromJson<Pool>() ?? new Pool();
pool.AvatarItems = Clean(pool.AvatarItems);
pool.Consumables = Clean(pool.Consumables);
return pool;
}
private static List<string> Clean(IEnumerable<string>? values) =>
(values ?? Enumerable.Empty<string>())
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
public static bool IsAlwaysDenied(string? name, string? tooltip, string? tag = null)
{
return Matches(name) || Matches(tooltip) || MatchesTag(tag);
static bool Matches(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return false;
return DeniedPatterns.Any(pattern =>
text.Contains(pattern, StringComparison.OrdinalIgnoreCase));
}
static bool MatchesTag(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return false;
var tags = text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return tags.Any(tag => DeniedTags.Contains(tag, StringComparer.OrdinalIgnoreCase));
}
}
public static bool IsAlwaysDenied(AvatarItem item) =>
item != null && IsAlwaysDenied(item.FriendlyName, item.Tooltip, item.TagList);
public static bool IsAlwaysDenied(ConsumableItem item) =>
item != null && IsAlwaysDenied(item.FriendlyName, item.Tooltip);
public static string OwnershipKey(string? description) =>
AvatarItemDescription.Normalize(description).Trim().ToLowerInvariant();
public static AvatarItem? PickAvatarItem(
IEnumerable<AvatarItem> catalogue, Rarity rarity, ISet<string> owned, Random random, Pool? pool = null)
{
var allowed = (pool ?? Load()).AvatarItems.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (allowed.Count == 0) return null;
var candidates = catalogue
.Where(item => item != null
&& !string.IsNullOrWhiteSpace(item.AvatarItemDesc)
&& !item.IsBaseAvatarItem
&& item.Rarity == (int)rarity
&& allowed.Contains(item.AvatarItemDesc)
&& !IsAlwaysDenied(item)
&& !owned.Contains(OwnershipKey(item.AvatarItemDesc)))
.OrderBy(item => item.AvatarItemDesc, StringComparer.Ordinal)
.ToList();
return candidates.Count == 0 ? null : candidates[random.Next(candidates.Count)];
}
public static ConsumableItem? PickConsumable(
IEnumerable<ConsumableItem> catalogue, ISet<string> owned, Random random, Pool? pool = null)
{
var configured = (pool ?? Load()).Consumables.ToHashSet(StringComparer.OrdinalIgnoreCase);
var candidates = catalogue
.Where(item => item != null
&& !string.IsNullOrWhiteSpace(item.ConsumableItemDesc)
&& (configured.Count == 0 || configured.Contains(item.ConsumableItemDesc))
&& !IsAlwaysDenied(item)
&& !owned.Contains(OwnershipKey(ConsumableCatalog.ToClientDesc(item.ConsumableItemDesc))))
.OrderBy(item => item.ConsumableItemDesc, StringComparer.Ordinal)
.ToList();
return candidates.Count == 0 ? null : candidates[random.Next(candidates.Count)];
}
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace RadicadeServer.RecNet.Progression.Objectives;
public class MyProgress
{
public List<ObjectiveProgress> Objectives { get; set; } = new();
public List<ObjectiveGroupProgress> ObjectiveGroups { get; set; } = new();
}
+18
View File
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
using RadicadeServer.RecNet.Enums.Challenges;
namespace RadicadeServer.RecNet.Progression.Objectives
{
public class Objective
{
public ObjectiveTypes type { get; set; } = ObjectiveTypes.ArenaWins;
public int score { get; set; } = 3;
public int xp { get; set; } = 10;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? descriptionOverride { get; set; } = null;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? tooltipOverride { get; set; } = null;
}
}
@@ -0,0 +1,17 @@
namespace RadicadeServer.RecNet.Progression.Objectives;
public class ObjectiveGroupProgress
{
public int Group { get; set; } = 0;
public bool IsCompleted { get; set; } = false;
public DateTime ClearedAt { get; set; } = DateTime.Now;
public bool RequiresCompleteOnServer { get; set; } = false;
public static ObjectiveGroupProgress Empty(int group)
{
return new ObjectiveGroupProgress { Group = group };
}
}
@@ -0,0 +1,6 @@
namespace RadicadeServer.RecNet.Progression.Objectives;
public class ObjectiveGroupRequest
{
public int Group { get; set; }
}
@@ -0,0 +1,17 @@
namespace RadicadeServer.RecNet.Progression.Objectives;
public class ObjectiveProgress
{
public int Index { get; set; } = 0;
public int Group { get; set; } = 0;
public float Progress { get; set; } = 0;
public float VisualProgress { get; set; } = 0;
public bool IsCompleted { get; set; } = false;
public bool IsRewarded { get; set; } = false;
public bool HasClaimedReward { get; set; } = false;
}
+148
View File
@@ -0,0 +1,148 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Config;
using RadicadeServer.RecNet.Leveling;
using RadicadeServer.RecNet.Progression;
using RadicadeServer.RecNet.Progression.Objectives;
using RadicadeServer.RecNet.Responses;
namespace RadicadeServer.API.Progression.Objectives.V1
{
[Route("api/objectives/v1")]
[Route("econ/api/objectives/v1")]
public class ObjectivesV1 : Controller
{
RecDBContext db;
public ObjectivesV1(RecDBContext context)
{
this.db = context;
}
[HttpGet("myprogress"), Authorize]
public async Task<ActionResult<MyProgress>> GetMyProgress()
{
var userId = User.GetUserId();
var data = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == userId);
if (data == null) return StatusCode(500);
var deserialized = data.GetObjectiveProgress();
if (deserialized == null) return StatusCode(500);
return Ok(deserialized);
}
[HttpPost("cleargroup"), Authorize]
public async Task<ActionResult<ObjectiveGroupProgress>> ClearObjectiveGroup([FromBody] ObjectiveGroupRequest? dto)
{
if (dto == null) return BadRequest();
var userId = User.GetUserId();
var data = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == userId);
if (data == null) return StatusCode(500);
ProgressionJson? progression = data.Progress.FromJson<ProgressionJson>();
if (progression == null) return StatusCode(500);
progression.ObjectiveProgress.ObjectiveGroups.RemoveAll(x => x.Group == dto.Group);
progression.ObjectiveProgress.Objectives.RemoveAll(x => x.Group == dto.Group);
data.Progress = progression.ToJson();
await db.SaveChangesAsync();
return Ok(ObjectiveGroupProgress.Empty(dto.Group));
}
[HttpPost("completegroup"), Authorize]
public async Task<ActionResult<ObjectiveGroupProgress>> CompleteObjectiveGroup([FromBody] ObjectiveGroupRequest? dto)
{
if (dto == null) return BadRequest();
var userId = User.GetUserId();
var data = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == userId);
if (data == null) return StatusCode(500);
ProgressionJson? progression = data.Progress.FromJson<ProgressionJson>();
if (progression == null) return StatusCode(500);
var groupIndex = progression.ObjectiveProgress.ObjectiveGroups.FindIndex(x => x.Group == dto.Group);
if (groupIndex >= 0)
{
progression.ObjectiveProgress.ObjectiveGroups[groupIndex].IsCompleted = true;
progression.ObjectiveProgress.ObjectiveGroups[groupIndex].RequiresCompleteOnServer = false;
}
var groupJson = progression.ObjectiveProgress.ObjectiveGroups[groupIndex].ToString();
data.Progress = progression.ToJson();
await db.SaveChangesAsync();
return Ok(groupJson);
}
[HttpPost("updateobjective"), Authorize]
public async Task<ActionResult<Success>> UpdateObjective([FromBody] ObjectiveProgress? progress)
{
MainConfig? config = await db.Config.FirstOrDefaultAsync();
if (config == null) return StatusCode(500);
if (progress == null) return BadRequest();
progress.IsRewarded = true;
progress.VisualProgress = progress.Progress;
try
{
var objectives = config.DailyObjectives.FromJson<Objective[][]>();
if (objectives != null
&& progress.Group >= 0 && progress.Group < objectives.Length
&& progress.Index >= 0 && progress.Index < objectives[progress.Group].Length)
{
var objective = objectives[progress.Group][progress.Index];
if (objective == null) return BadRequest();
if (!float.IsFinite(progress.Progress) || progress.Progress < 0)
progress.Progress = 0;
if (progress.Progress > objective.score)
progress.Progress = objective.score;
progress.IsCompleted = progress.Progress >= objective.score;
progress.VisualProgress = progress.Progress;
}
} catch
{
progress.IsCompleted = false;
}
var userId = User.GetUserId();
var data = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == userId);
if (data == null) return StatusCode(500);
ProgressionJson? progression = data.Progress.FromJson<ProgressionJson>();
if (progression == null) return StatusCode(500);
var existingIndex = progression.ObjectiveProgress.Objectives.FindIndex(x => x.Index == progress.Index);
if (existingIndex >= 0)
progression.ObjectiveProgress.Objectives.RemoveAt(existingIndex);
progression.ObjectiveProgress.Objectives.Add(progress);
data.Progress = progression.ToJson();
await db.SaveChangesAsync();
return Ok(Success.Ok);
}
}
}
@@ -0,0 +1,28 @@
using System.Text.Json.Serialization;
using RadicadeServer.Utils;
namespace RadicadeServer.RecNet.Progression
{
public class PlayerProgressionLevelUpdateNotification
{
public PlayerProgressionLevelUpdateNotification() { }
public PlayerProgressionLevelUpdateNotification(ProgressionData data)
{
Id = data.PlayerId;
PlayerId = data.PlayerId;
Level = LevelUpGiftService.DisplayLevel(data.Level);
XP = LevelUpGiftService.XpIntoCurrentLevel(data);
}
public long Id { get; set; }
public long PlayerId { get; set; }
public int Level { get; set; }
public int XP { get; set; }
[JsonPropertyName("id")] public long IdLower { get { return Id; } }
[JsonPropertyName("playerId")] public long PlayerIdLower { get { return PlayerId; } }
[JsonPropertyName("level")] public int LevelLower { get { return Level; } }
[JsonPropertyName("xp")] public int XpLower { get { return XP; } }
}
}
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Progression;
namespace RadicadeServer.Utils
{
public static class ProgressionAwardService
{
public const int XpPerGiftBoxOpened = 1;
public const int XpPerGameCompleted = 2;
public static async Task<int> AwardXpAsync(RecDBContext db, long playerId, int xp, string reason)
{
if (playerId <= 0 || xp <= 0) return 0;
var progression = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId);
if (progression == null)
{
progression = new ProgressionData { PlayerId = playerId };
await db.Progression.AddAsync(progression);
}
int granted = (int)Math.Min(xp, int.MaxValue);
if (granted <= 0)
{
await db.SaveChangesAsync();
return 0;
}
int oldLevel = progression.Level;
progression.XP = (int)Math.Min(progression.XP + granted, int.MaxValue);
await db.SaveChangesAsync();
int computedLevel = LevelUpGiftService.ComputeLevel(LevelUpGiftService.ParseMap(), progression.XP);
if (computedLevel > progression.Level) progression.Level = computedLevel;
await LevelUpGiftService.DeliverLevelUpGiftsAsync(db, playerId,
recomputeFromXp: true, fromLevel: oldLevel, notify: true);
await db.SaveChangesAsync();
await RoomProgressionService.SendProgressionLevelUpdateAsync(db, playerId, progression);
return granted;
}
}
}
+106
View File
@@ -0,0 +1,106 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Progression.Challenges;
using RadicadeServer.RecNet.Progression.Objectives;
using RadicadeServer.RecNet.Storefront;
namespace RadicadeServer.RecNet.Progression
{
public class ProgressionJson
{
public MyProgress ObjectiveProgress { get; set; } = new();
public List<ChallengeProgress> ChallengeProgress { get; set; } = new();
public DateTime? LastXpEarnedAt { get; set; }
public int XpEarnedToday { get; set; }
public DateTime? XpEarnedTodayDate { get; set; }
public DateTime? XpDailyCapReachedAt { get; set; }
public long? RoomXpRoomId { get; set; }
public int RoomXpEarned { get; set; }
public DateTime? RoomXpLastGrantAt { get; set; }
public List<int> DeliveredLevelUpGiftLevels { get; set; } = new();
public List<GameReward> PendingGameRewards { get; set; } = new();
public RadicadeServer.Utils.RewardDayLedger? RewardDay { get; set; }
}
public class ProgressionData
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
public long PlayerId { get; set; }
public int XP { get; set; } = 0;
public int Level { get; set; } = 1;
public string Progress { get; set; } = new ProgressionJson().ToJson();
public ChallengeProgress? GetChallengeProgress()
{
return Progress.FromJson<ProgressionJson>()?.ChallengeProgress.LastOrDefault();
}
public void SetChallengeProgress(ChallengeProgress progress)
{
var deserialized = Progress.FromJson<ProgressionJson>();
if (deserialized == null) return;
deserialized.ChallengeProgress.Add(progress);
Progress = deserialized.ToJson();
}
public List<ChallengeProgress>? GetChallengeProgresses()
{
return Progress.FromJson<ProgressionJson>()?.ChallengeProgress;
}
public MyProgress? GetObjectiveProgress()
{
return Progress.FromJson<ProgressionJson>()?.ObjectiveProgress;
}
public void AddObjectivesProgress(ObjectiveProgress progress)
{
var deserialized = Progress.FromJson<ProgressionJson>();
if (deserialized == null) return;
var existingProgress = deserialized.ObjectiveProgress.Objectives.Find(x => x.Group == progress.Group && x.Index == progress.Index);
if (existingProgress == null)
deserialized.ObjectiveProgress.Objectives.Add(progress);
else
existingProgress = progress;
Progress = deserialized.ToJson();
}
public ProgressionJson? GetProgressionJson()
{
return Progress.FromJson<ProgressionJson>();
}
public void RecordXpGrant(int amount)
{
if (amount <= 0) return;
var json = GetProgressionJson() ?? new ProgressionJson();
var today = DateTime.UtcNow.Date;
if (json.XpEarnedTodayDate != today)
{
json.XpEarnedTodayDate = today;
json.XpEarnedToday = 0;
}
json.XpEarnedToday += amount;
json.LastXpEarnedAt = DateTime.UtcNow;
Progress = json.ToJson();
}
}
}
+67
View File
@@ -0,0 +1,67 @@
using System.Globalization;
using RadicadeServer.RecNet.Enums.Store;
namespace RadicadeServer.Utils
{
public sealed class RewardDayLedger
{
public string Day { get; set; } = string.Empty;
public bool FirstActivityPaid { get; set; }
public bool DailyChallengePaid { get; set; }
public int ActivityBoxes { get; set; }
}
public static class RewardDay
{
public const int ActivityBoxesPerDay = 12;
public const int BoxesPerDay = 1 + 1 + ActivityBoxesPerDay;
public static string DayKey(DateTime nowUtc) =>
nowUtc.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
public static RewardDayLedger For(RecNet.Progression.ProgressionJson json, DateTime nowUtc)
{
var day = DayKey(nowUtc);
var ledger = json.RewardDay;
if (ledger == null || !string.Equals(ledger.Day, day, StringComparison.Ordinal))
{
ledger = new RewardDayLedger { Day = day };
json.RewardDay = ledger;
}
return ledger;
}
public static RewardType CompletionType(RewardDayLedger ledger, RewardType claimed)
{
if (claimed == RewardType.PostQuestActivity) return RewardType.PostQuestActivity;
return ledger.FirstActivityPaid ? RewardType.PostGameActivity : RewardType.FirstActivityOfDay;
}
public static bool TryTakeFirstActivity(RewardDayLedger ledger)
{
if (ledger.FirstActivityPaid) return false;
ledger.FirstActivityPaid = true;
return true;
}
public static bool TryTakeActivityBox(RewardDayLedger ledger)
{
if (ledger.ActivityBoxes >= ActivityBoxesPerDay) return false;
ledger.ActivityBoxes++;
return true;
}
public static bool TryTakeDailyChallengeBox(RewardDayLedger ledger)
{
if (ledger.DailyChallengePaid) return false;
ledger.DailyChallengePaid = true;
return true;
}
public static RewardDayLedger NewDay(DateTime nowUtc) => new() { Day = DayKey(nowUtc) };
}
}
+75
View File
@@ -0,0 +1,75 @@
using RadicadeServer.DB;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Enums.Store;
namespace RadicadeServer.Utils
{
public static class RewardTrace
{
public const string Prefix = "[QuestReward]";
public const string ErrorPrefix = "[ERROR][QuestReward]";
private const string FileName = "game-rewards.log";
public static void Log(long playerId, string line) => Write(Prefix, playerId, line);
public static void Error(long playerId, string line) => Write(ErrorPrefix, playerId, line);
public static void LogAndError(long playerId, string line)
{
Log(playerId, line);
Error(playerId, line);
}
private static void Write(string tag, long playerId, string line)
{
var text = $"{tag} player={playerId} {line}";
try
{
System.IO.File.AppendAllText(
System.IO.Path.Join(DBConfig.PersistentLogs, FileName),
$"[{DateTime.UtcNow:O}] {text}{Environment.NewLine}");
}
catch
{
}
}
public static string Context(GiftContext context) => $"{context} ({(int)context})";
public static string Box(Gift? box)
{
if (box == null) return "<none>";
var item = string.IsNullOrWhiteSpace(box.AvatarItemDesc) ? "-" : box.AvatarItemDesc;
if (item.Length > 120) item = item[..120] + "...";
var kind = !string.IsNullOrWhiteSpace(box.AvatarItemDesc) ? "AvatarItem"
: !string.IsNullOrWhiteSpace(box.ConsumableItemDesc) ? "Consumable"
: box.CurrencyType != CurrencyType.Invalid && box.Currency != 0 ? "Tokens"
: "Empty";
return $"{{id={box.GiftDropId} type={kind} name=\"{box.FriendlyName}\" item=\"{item}\" " +
$"consumable=\"{box.ConsumableItemDesc}\" currency={box.CurrencyType} {box.Currency} " +
$"context={Context(box.Context)}}}";
}
public static string Boxes(IEnumerable<Gift?> boxes)
{
var list = boxes.ToList();
return list.Count == 0 ? "[]" : "[" + string.Join(", ", list.Select(Box)) + "]";
}
public static string Items(IEnumerable<AvatarItem> items, int limit = 12)
{
var list = items.ToList();
if (list.Count == 0) return "[]";
var names = list.Take(limit).Select(x => $"\"{x.FriendlyName}\"");
var more = list.Count > limit ? $", ... +{list.Count - limit} more" : string.Empty;
return $"[{string.Join(", ", names)}{more}]";
}
}
}
@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc;
namespace RadicadeServer.API.Progression.SubscriptionSeasons.V1
{
[Route("api/subscriptionseasons/v1")]
[Route("econ/api/subscriptionseasons/v1")]
public class SubscriptionSeasonsAPI : Controller
{
[HttpGet("seasons/current")]
public IActionResult GetCurrentSeason() => NotFound();
}
}
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using RadicadeServer.RecNet.Avatar;
namespace RadicadeServer.RecNet.Progression.Challenges;
[PrimaryKey(nameof(PlayerId), nameof(ChallengeMapId))]
public class WeeklyChallengeState
{
public long PlayerId { get; set; }
public int ChallengeMapId { get; set; }
public string MapJson { get; set; } = "";
public long? RewardGiftDropId { get; set; }
}
public class WeeklyChallengeMap
{
public bool Enabled { get; set; } = true;
public string Revision { get; set; } = "";
public int FallbackTokens { get; set; } = 300;
public int ChallengeMapId { get; set; }
public string ChallengeThemeString { get; set; } = "Weekly Challenges";
public List<WeeklyChallenge> Challenges { get; set; } = [];
public Gift Gift { get; set; } = new();
public string FallbackGiftName { get; set; } = "300 Tokens";
public DateTime StartAt { get; set; }
public DateTime EndAt { get; set; }
public DateTime ServerTime { get; set; }
public bool CompletedRequired { get; set; }
}
public class WeeklyChallenge
{
public int ChallengeId { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public string Tooltip { get; set; } = "";
public string Config { get; set; } = "";
public bool Complete { get; set; }
}
@@ -0,0 +1,210 @@
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(); }
}
}