mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
Radicade Public Code
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user