wip reimplementation of xp gain loop

This commit is contained in:
Toastie 2025-02-06 12:34:22 +13:00
parent fd464731d5
commit 96c4ea0637
Signed by: toastie_t0ast
GPG key ID: 0861BE54AD481DC7
2 changed files with 154 additions and 257 deletions
src/EllieBot
Db/Models
Modules/Xp

View file

@ -1,17 +1,17 @@
#nullable disable
namespace EllieBot.Db.Models; namespace EllieBot.Db.Models;
// FUTURE remove LastLevelUp from here and UserXpStats // FUTURE remove LastLevelUp from here and UserXpStats
public class DiscordUser : DbEntity public class DiscordUser : DbEntity
{ {
public const string DEFAULT_USERNAME = "??Unknown";
public ulong UserId { get; set; } public ulong UserId { get; set; }
public string Username { get; set; } public string? Username { get; set; }
// public string Discriminator { get; set; } public string? AvatarId { get; set; }
public string AvatarId { get; set; }
public int? ClubId { get; set; } public int? ClubId { get; set; }
public ClubInfo Club { get; set; } public ClubInfo? Club { get; set; }
public bool IsClubAdmin { get; set; } public bool IsClubAdmin { get; set; }
public long TotalXp { get; set; } public long TotalXp { get; set; }

View file

@ -1,4 +1,5 @@
#nullable disable warnings #nullable disable warnings
using System.ComponentModel.DataAnnotations;
using LinqToDB; using LinqToDB;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors; using EllieBot.Common.ModuleBehaviors;
@ -11,10 +12,12 @@ using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing;
using System.Threading.Channels; using System.Threading.Channels;
using LinqToDB.Data;
using LinqToDB.EntityFrameworkCore; using LinqToDB.EntityFrameworkCore;
using LinqToDB.Tools; using LinqToDB.Tools;
using EllieBot.Modules.Administration; using EllieBot.Modules.Administration;
using EllieBot.Modules.Patronage; using EllieBot.Modules.Patronage;
using ArgumentOutOfRangeException = System.ArgumentOutOfRangeException;
using Color = SixLabors.ImageSharp.Color; using Color = SixLabors.ImageSharp.Color;
using Exception = System.Exception; using Exception = System.Exception;
using Image = SixLabors.ImageSharp.Image; using Image = SixLabors.ImageSharp.Image;
@ -36,7 +39,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
private readonly ConcurrentDictionary<ulong, ConcurrentHashSet<ulong>> _excludedChannels = new(); private readonly ConcurrentDictionary<ulong, ConcurrentHashSet<ulong>> _excludedChannels = new();
private readonly ConcurrentHashSet<ulong> _excludedServers; private readonly ConcurrentHashSet<ulong> _excludedServers;
private XpTemplate _template; private XpTemplate _template = new();
private readonly DiscordSocketClient _client; private readonly DiscordSocketClient _client;
private readonly TypedKey<bool> _xpTemplateReloadKey; private readonly TypedKey<bool> _xpTemplateReloadKey;
@ -44,9 +47,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
private readonly IBotCache _c; private readonly IBotCache _c;
private readonly QueueRunner _levelUpQueue = new(0, 50);
private readonly Channel<UserXpGainData> _xpGainQueue = Channel.CreateUnbounded<UserXpGainData>(); private readonly Channel<UserXpGainData> _xpGainQueue = Channel.CreateUnbounded<UserXpGainData>();
private readonly IMessageSenderService _sender;
private readonly INotifySubscriber _notifySub; private readonly INotifySubscriber _notifySub;
private readonly ShardData _shardData; private readonly ShardData _shardData;
@ -62,7 +63,6 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
XpConfigService xpConfig, XpConfigService xpConfig,
IPubSub pubSub, IPubSub pubSub,
IPatronageService ps, IPatronageService ps,
IMessageSenderService sender,
INotifySubscriber notifySub, INotifySubscriber notifySub,
ShardData shardData) ShardData shardData)
{ {
@ -74,7 +74,6 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
_httpFactory = http; _httpFactory = http;
_xpConfig = xpConfig; _xpConfig = xpConfig;
_pubSub = pubSub; _pubSub = pubSub;
_sender = sender;
_notifySub = notifySub; _notifySub = notifySub;
_shardData = shardData; _shardData = shardData;
_excludedServers = new(); _excludedServers = new();
@ -109,16 +108,16 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
public async Task OnReadyAsync() public async Task OnReadyAsync()
{ {
_ = Task.Run(() => _levelUpQueue.RunAsync());
// initialize ignored // initialize ignored
ArgumentOutOfRangeException.ThrowIfLessThan(_xpConfig.Data.MessageXpCooldown, 1,
nameof(_xpConfig.Data.MessageXpCooldown));
await using (var ctx = _db.GetDbContext()) await using (var ctx = _db.GetDbContext())
{ {
var xps = await ctx.GetTable<XpSettings>() var xps = await ctx.GetTable<XpSettings>()
.Where(x => Queries.GuildOnShard(x.GuildId, _shardData.TotalShards, _shardData.ShardId)) .Where(x => Queries.GuildOnShard(x.GuildId, _shardData.TotalShards, _shardData.ShardId))
.LoadWith(x => x.ExclusionList) .LoadWith(x => x.ExclusionList)
.ToListAsyncLinqToDB(); .ToListAsyncLinqToDB();
foreach (var xp in xps) foreach (var xp in xps)
{ {
@ -135,173 +134,69 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
} }
} }
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5)); await Task.WhenAll(UpdateTimer(), PeriodClearTimer());
while (await timer.WaitForNextTickAsync())
return;
async Task PeriodClearTimer()
{ {
await UpdateXp(); using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_xpConfig.Data.MessageXpCooldown));
while (true)
{
await timer.WaitForNextTickAsync();
_usersGainedInPeriod.Clear();
}
}
async Task UpdateTimer()
{
// todo a bigger loop that runs once every XpTimer
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3));
while (await timer.WaitForNextTickAsync())
{
await UpdateXp();
}
} }
} }
/// <summary>
/// The current batch of users that will gain xp
/// </summary>
private readonly ConcurrentHashSet<IGuildUser> _usersBatch = new();
private async Task UpdateXp() private async Task UpdateXp()
{ {
try var xpAmount = _xpConfig.Data.XpPerMessage;
var currentBatch = _usersBatch.ToArray();
_usersBatch.Clear();
await using var ctx = _db.GetDbContext();
await using var lctx = ctx.CreateLinqToDBConnection();
await using var batchTable = await lctx.CreateTempTableAsync<UserXpBatch>();
await batchTable.BulkCopyAsync(currentBatch.Select(x => new UserXpBatch()
{ {
var reader = _xpGainQueue.Reader; GuildId = x.GuildId,
UserId = x.Id,
UserName = x.Username,
AvatarId = x.DisplayAvatarId,
// sum up all gains into a single UserCacheItem }));
var globalToAdd = new Dictionary<ulong, UserXpGainData>();
var guildToAdd = new Dictionary<ulong, Dictionary<ulong, UserXpGainData>>();
while (reader.TryRead(out var item))
{
// add global xp to these users
if (!globalToAdd.TryGetValue(item.User.Id, out var ci))
globalToAdd[item.User.Id] = item.Clone();
else
ci.XpAmount += item.XpAmount;
await lctx.ExecuteAsync(
$"""
INSERT INTO ${nameof(DiscordUser)}
SELECT ${nameof(UserXpBatch.GuildId)}, ${nameof(UserXpBatch.UserId)}, ${nameof(UserXpBatch.UserName)}, ${nameof(UserXpBatch.AvatarId)}, ${xpAmount}
FROM ${nameof(UserXpBatch)}
ON CONFLICT(${nameof(DiscordUser.UserId)}, ${nameof(DiscordUser.UserId)}) DO UPDATE SET
${nameof(DiscordUser.Username)} = ${nameof(UserXpBatch)}.${nameof(UserXpBatch.UserName)}
${nameof(DiscordUser.AvatarId)} = ${nameof(UserXpBatch)}.${nameof(UserXpBatch.AvatarId)}
${nameof(DiscordUser.TotalXp)} = ${nameof(DiscordUser.TotalXp)} + ${xpAmount}
RETURNING *;
""");
// ad guild xp in these guilds to these users // todo send notifications
if (!guildToAdd.TryGetValue(item.Guild.Id, out var users))
users = guildToAdd[item.Guild.Id] = new();
if (!users.TryGetValue(item.User.Id, out ci))
users[item.User.Id] = item.Clone();
else
ci.XpAmount += item.XpAmount;
}
var dus = new List<DiscordUser>(globalToAdd.Count);
var gxps = new List<UserXpStats>(globalToAdd.Count);
var conf = _xpConfig.Data;
await using (var ctx = _db.GetDbContext())
{
if (conf.CurrencyPerXp > 0)
{
foreach (var user in globalToAdd)
{
var amount = (long)(user.Value.XpAmount * conf.CurrencyPerXp);
if (amount > 0)
await _cs.AddAsync(user.Key, amount, null);
}
}
// update global user xp in batches
// group by xp amount and update the same amounts at the same time
foreach (var group in globalToAdd.GroupBy(x => x.Value.XpAmount, x => x.Key))
{
var items = await ctx.Set<DiscordUser>()
.Where(x => group.Contains(x.UserId))
.UpdateWithOutputAsync(old => new()
{
TotalXp = old.TotalXp + group.Key
},
(_, n) => n);
await ctx.Set<ClubInfo>()
.Where(x => x.Members.Any(m => group.Contains(m.UserId)))
.UpdateAsync(old => new()
{
Xp = old.Xp + (group.Key * old.Members.Count(m => group.Contains(m.UserId)))
});
dus.AddRange(items);
}
// update guild user xp in batches
foreach (var (guildId, toAdd) in guildToAdd)
{
foreach (var group in toAdd.GroupBy(x => x.Value.XpAmount, x => x.Key))
{
var items = await ctx
.Set<UserXpStats>()
.Where(x => x.GuildId == guildId)
.Where(x => group.Contains(x.UserId))
.UpdateWithOutputAsync(old => new()
{
Xp = old.Xp + group.Key
},
(_, n) => n);
gxps.AddRange(items);
var missingUserIds = group.Where(userId => !items.Any(x => x.UserId == userId)).ToArray();
foreach (var userId in missingUserIds)
{
await ctx
.Set<UserXpStats>()
.ToLinqToDBTable()
.InsertOrUpdateAsync(() => new UserXpStats()
{
UserId = userId,
GuildId = guildId,
Xp = group.Key,
DateAdded = DateTime.UtcNow,
},
_ => new()
{
},
() => new()
{
UserId = userId
});
}
if (missingUserIds.Length > 0)
{
var missingItems = await ctx.Set<UserXpStats>()
.ToLinqToDBTable()
.Where(x => missingUserIds.Contains(x.UserId))
.ToArrayAsyncLinqToDB();
gxps.AddRange(missingItems);
}
}
}
}
foreach (var du in dus)
{
var oldLevel = new LevelStats(du.TotalXp - globalToAdd[du.UserId].XpAmount);
var newLevel = new LevelStats(du.TotalXp);
if (oldLevel.Level != newLevel.Level)
{
var item = globalToAdd[du.UserId];
await _levelUpQueue.EnqueueAsync(
NotifyUser(item.Guild.Id,
item.Channel.Id,
du.UserId,
false,
oldLevel.Level,
newLevel.Level));
}
}
foreach (var du in gxps)
{
if (guildToAdd.TryGetValue(du.GuildId, out var users)
&& users.TryGetValue(du.UserId, out var xpGainData))
{
var oldLevel = new LevelStats(du.Xp - xpGainData.XpAmount);
var newLevel = new LevelStats(du.Xp);
if (oldLevel.Level < newLevel.Level)
{
await _levelUpQueue.EnqueueAsync(
NotifyUser(xpGainData.Guild.Id,
xpGainData.Channel.Id,
du.UserId,
true,
oldLevel.Level,
newLevel.Level));
}
}
}
}
catch (Exception ex)
{
Log.Error(ex, "Error In the XP update loop");
}
} }
private Func<Task> NotifyUser( private Func<Task> NotifyUser(
@ -564,23 +459,23 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
{ {
await using var uow = _db.GetDbContext(); await using var uow = _db.GetDbContext();
return await uow return await uow
.UserXpStats .UserXpStats
.Where(x => x.GuildId == guildId) .Where(x => x.GuildId == guildId)
.OrderByDescending(x => x.Xp) .OrderByDescending(x => x.Xp)
.Skip(page * 10) .Skip(page * 10)
.Take(10) .Take(10)
.ToArrayAsyncLinqToDB(); .ToArrayAsyncLinqToDB();
} }
public async Task<IReadOnlyCollection<UserXpStats>> GetGuildUserXps(ulong guildId, List<ulong> users, int page) public async Task<IReadOnlyCollection<UserXpStats>> GetGuildUserXps(ulong guildId, List<ulong> users, int page)
{ {
await using var uow = _db.GetDbContext(); await using var uow = _db.GetDbContext();
return await uow.Set<UserXpStats>() return await uow.Set<UserXpStats>()
.Where(x => x.GuildId == guildId && x.UserId.In(users)) .Where(x => x.GuildId == guildId && x.UserId.In(users))
.OrderByDescending(x => x.Xp) .OrderByDescending(x => x.Xp)
.Skip(page * 10) .Skip(page * 10)
.Take(10) .Take(10)
.ToArrayAsyncLinqToDB(); .ToArrayAsyncLinqToDB();
} }
public async Task<IReadOnlyCollection<DiscordUser>> GetGlobalUserXps(int page) public async Task<IReadOnlyCollection<DiscordUser>> GetGlobalUserXps(int page)
@ -588,10 +483,10 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
await using var uow = _db.GetDbContext(); await using var uow = _db.GetDbContext();
return await uow.GetTable<DiscordUser>() return await uow.GetTable<DiscordUser>()
.OrderByDescending(x => x.TotalXp) .OrderByDescending(x => x.TotalXp)
.Skip(page * 10) .Skip(page * 10)
.Take(10) .Take(10)
.ToArrayAsyncLinqToDB(); .ToArrayAsyncLinqToDB();
} }
public async Task<IReadOnlyCollection<DiscordUser>> GetGlobalUserXps(int page, List<ulong> users) public async Task<IReadOnlyCollection<DiscordUser>> GetGlobalUserXps(int page, List<ulong> users)
@ -599,11 +494,11 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
await using var uow = _db.GetDbContext(); await using var uow = _db.GetDbContext();
return await uow.GetTable<DiscordUser>() return await uow.GetTable<DiscordUser>()
.Where(x => x.UserId.In(users)) .Where(x => x.UserId.In(users))
.OrderByDescending(x => x.TotalXp) .OrderByDescending(x => x.TotalXp)
.Skip(page * 10) .Skip(page * 10)
.Take(10) .Take(10)
.ToArrayAsyncLinqToDB(); .ToArrayAsyncLinqToDB();
} }
private Task Client_OnGuildAvailable(SocketGuild guild) private Task Client_OnGuildAvailable(SocketGuild guild)
@ -804,13 +699,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
if (!await SetUserRewardedAsync(user.Id)) if (!await SetUserRewardedAsync(user.Id))
return; return;
await _xpGainQueue.Writer.WriteAsync(new() _usersBatch.Add(user);
{
Guild = user.Guild,
Channel = arg.Channel,
User = user,
XpAmount = xp
});
}); });
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -833,11 +722,11 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
{ {
await using var ctx = _db.GetDbContext(); await using var ctx = _db.GetDbContext();
return await ctx.GetTable<UserXpStats>() return await ctx.GetTable<UserXpStats>()
.Where(x => x.GuildId == guildId && userIds.Contains(x.UserId)) .Where(x => x.GuildId == guildId && userIds.Contains(x.UserId))
.UpdateAsync(old => new() .UpdateAsync(old => new()
{ {
Xp = old.Xp + amount Xp = old.Xp + amount
}); });
} }
public async Task AddXpAsync(ulong userId, ulong guildId, int amount) public async Task AddXpAsync(ulong userId, ulong guildId, int amount)
@ -1198,10 +1087,10 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
using (var tempDraw = Image.Load<Rgba32>(avatarData)) using (var tempDraw = Image.Load<Rgba32>(avatarData))
{ {
tempDraw.Mutate(x => x tempDraw.Mutate(x => x
.Resize(_template.User.Icon.Size.X, _template.User.Icon.Size.Y) .Resize(_template.User.Icon.Size.X, _template.User.Icon.Size.Y)
.ApplyRoundedCorners(Math.Max(_template.User.Icon.Size.X, .ApplyRoundedCorners(Math.Max(_template.User.Icon.Size.X,
_template.User.Icon.Size.Y) _template.User.Icon.Size.Y)
/ 2.0f)); / 2.0f));
await using (var stream = await tempDraw.ToStreamAsync()) await using (var stream = await tempDraw.ToStreamAsync())
{ {
data = stream.ToArray(); data = stream.ToArray();
@ -1361,10 +1250,10 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
using (var tempDraw = Image.Load<Rgba32>(imgData)) using (var tempDraw = Image.Load<Rgba32>(imgData))
{ {
tempDraw.Mutate(x => x tempDraw.Mutate(x => x
.Resize(_template.Club.Icon.Size.X, _template.Club.Icon.Size.Y) .Resize(_template.Club.Icon.Size.X, _template.Club.Icon.Size.Y)
.ApplyRoundedCorners(Math.Max(_template.Club.Icon.Size.X, .ApplyRoundedCorners(Math.Max(_template.Club.Icon.Size.X,
_template.Club.Icon.Size.Y) _template.Club.Icon.Size.Y)
/ 2.0f)); / 2.0f));
await using (var tds = await tempDraw.ToStreamAsync()) await using (var tds = await tempDraw.ToStreamAsync())
{ {
data = tds.ToArray(); data = tds.ToArray();
@ -1395,7 +1284,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
{ {
await using var uow = _db.GetDbContext(); await using var uow = _db.GetDbContext();
await uow.GetTable<UserXpStats>() await uow.GetTable<UserXpStats>()
.DeleteAsync(x => x.UserId == userId && x.GuildId == guildId); .DeleteAsync(x => x.UserId == userId && x.GuildId == guildId);
} }
public void XpReset(ulong guildId) public void XpReset(ulong guildId)
@ -1409,8 +1298,8 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
{ {
await using var uow = _db.GetDbContext(); await using var uow = _db.GetDbContext();
await uow.GetTable<XpSettings>() await uow.GetTable<XpSettings>()
.Where(x => x.GuildId == guildId) .Where(x => x.GuildId == guildId)
.DeleteAsync(); .DeleteAsync();
} }
public ValueTask<Dictionary<string, XpConfig.ShopItemInfo>?> GetShopBgs() public ValueTask<Dictionary<string, XpConfig.ShopItemInfo>?> GetShopBgs()
@ -1454,7 +1343,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
try try
{ {
if (await ctx.GetTable<XpShopOwnedItem>() if (await ctx.GetTable<XpShopOwnedItem>()
.AnyAsyncLinqToDB(x => x.UserId == userId && x.ItemKey == key && x.ItemType == type)) .AnyAsyncLinqToDB(x => x.UserId == userId && x.ItemKey == key && x.ItemType == type))
return BuyResult.AlreadyOwned; return BuyResult.AlreadyOwned;
var item = GetShopItem(type, key); var item = GetShopItem(type, key);
@ -1467,14 +1356,14 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
await ctx.GetTable<XpShopOwnedItem>() await ctx.GetTable<XpShopOwnedItem>()
.InsertAsync(() => new XpShopOwnedItem() .InsertAsync(() => new XpShopOwnedItem()
{ {
UserId = userId, UserId = userId,
IsUsing = false, IsUsing = false,
ItemKey = key, ItemKey = key,
ItemType = type, ItemType = type,
DateAdded = DateTime.UtcNow, DateAdded = DateTime.UtcNow,
}); });
return BuyResult.Success; return BuyResult.Success;
} }
@ -1514,9 +1403,9 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
{ {
await using var ctx = _db.GetDbContext(); await using var ctx = _db.GetDbContext();
return await ctx.GetTable<XpShopOwnedItem>() return await ctx.GetTable<XpShopOwnedItem>()
.AnyAsyncLinqToDB(x => x.UserId == userId .AnyAsyncLinqToDB(x => x.UserId == userId
&& x.ItemType == itemType && x.ItemType == itemType
&& x.ItemKey == key); && x.ItemKey == key);
} }
@ -1527,9 +1416,9 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
{ {
await using var ctx = _db.GetDbContext(); await using var ctx = _db.GetDbContext();
return await ctx.GetTable<XpShopOwnedItem>() return await ctx.GetTable<XpShopOwnedItem>()
.FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId .FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId
&& x.ItemType == itemType && x.ItemType == itemType
&& x.ItemKey == key); && x.ItemKey == key);
} }
public async Task<XpShopOwnedItem?> GetItemInUse( public async Task<XpShopOwnedItem?> GetItemInUse(
@ -1538,9 +1427,9 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
{ {
await using var ctx = _db.GetDbContext(); await using var ctx = _db.GetDbContext();
return await ctx.GetTable<XpShopOwnedItem>() return await ctx.GetTable<XpShopOwnedItem>()
.FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId .FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId
&& x.ItemType == itemType && x.ItemType == itemType
&& x.IsUsing); && x.IsUsing);
} }
public async Task<bool> UseShopItemAsync(ulong userId, XpShopItemType itemType, string key) public async Task<bool> UseShopItemAsync(ulong userId, XpShopItemType itemType, string key)
@ -1564,11 +1453,11 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
if (await OwnsItemAsync(userId, itemType, key)) if (await OwnsItemAsync(userId, itemType, key))
{ {
await ctx.GetTable<XpShopOwnedItem>() await ctx.GetTable<XpShopOwnedItem>()
.Where(x => x.UserId == userId && x.ItemType == itemType) .Where(x => x.UserId == userId && x.ItemType == itemType)
.UpdateAsync(old => new() .UpdateAsync(old => new()
{ {
IsUsing = key == old.ItemKey IsUsing = key == old.ItemKey
}); });
return true; return true;
} }
@ -1590,9 +1479,9 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
{ {
await using var ctx = _db.GetDbContext(); await using var ctx = _db.GetDbContext();
return await ctx.GetTable<UserXpStats>() return await ctx.GetTable<UserXpStats>()
.Where(x => x.GuildId == requestGuildId .Where(x => x.GuildId == requestGuildId
&& (guildUsers == null || guildUsers.Contains(x.UserId))) && (guildUsers == null || guildUsers.Contains(x.UserId)))
.CountAsyncLinqToDB(); .CountAsyncLinqToDB();
} }
public async Task SetLevelAsync(ulong guildId, ulong userId, int level) public async Task SetLevelAsync(ulong guildId, ulong userId, int level)
@ -1600,21 +1489,29 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
var lvlStats = LevelStats.CreateForLevel(level); var lvlStats = LevelStats.CreateForLevel(level);
await using var ctx = _db.GetDbContext(); await using var ctx = _db.GetDbContext();
await ctx.GetTable<UserXpStats>() await ctx.GetTable<UserXpStats>()
.InsertOrUpdateAsync(() => new() .InsertOrUpdateAsync(() => new()
{ {
GuildId = guildId, GuildId = guildId,
UserId = userId, UserId = userId,
Xp = lvlStats.TotalXp, Xp = lvlStats.TotalXp,
DateAdded = DateTime.UtcNow DateAdded = DateTime.UtcNow
}, },
(old) => new() (old) => new()
{ {
Xp = lvlStats.TotalXp Xp = lvlStats.TotalXp
}, },
() => new() () => new()
{ {
GuildId = guildId, GuildId = guildId,
UserId = userId UserId = userId
}); });
} }
}
public sealed class UserXpBatch
{
[Key] public ulong UserId { get; set; }
public ulong GuildId { get; set; }
public string UserName { get; set; } = string.Empty;
public string AvatarId { get; set; } = string.Empty;
} }