Added Ellie.Bot.Modules.Xp

This commit is contained in:
Emotion 2023-08-12 00:56:11 +12:00
parent ac9bc86ca1
commit cdaf7c9ff2
No known key found for this signature in database
GPG key ID: D7D3E4C27A98C37B
32 changed files with 3679 additions and 7 deletions

View file

@ -41,8 +41,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Bot.Common", "src\Ell
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Bot.Modules.Gambling", "src\Ellie.Bot.Modules.Gambling\Ellie.Bot.Modules.Gambling.csproj", "{11910E7D-E373-482F-8207-678DE0A88112}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Bot.Generators.Cloneable", "src\Ellie.Bot.Generators.Cloneable\Ellie.Bot.Generators.Cloneable.csproj", "{AFA3DD12-0F98-4754-ADD7-9FF3C1A37C90}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Bot.Modules.Searches", "src\Ellie.Bot.Modules.Searches\Ellie.Bot.Modules.Searches.csproj", "{3168DB6A-574F-4ECA-9203-7F54AC3311DE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ellie.Bot.Modules.Music", "src\Ellie.Bot.Modules.Music\Ellie.Bot.Modules.Music.csproj", "{07579F42-E7DD-4FFC-B375-A4EC61EAF012}"
@ -57,6 +55,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ellie.Bot.Modules.Permisssi
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ellie.Bot.Modules.Xp", "src\Ellie.Bot.Modules.Xp\Ellie.Bot.Modules.Xp.csproj", "{A8F7CBAE-93EC-44D2-9CDF-EEC7CEDBDC73}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ellie.Bot.Generators.Cloneable", "src\Ellie.Bot.Generators.Cloneable\Ellie.Bot.Generators.Cloneable.csproj", "{D98BF713-F286-4B7A-895B-C9FE8842F3A9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -115,10 +115,6 @@ Global
{11910E7D-E373-482F-8207-678DE0A88112}.Debug|Any CPU.Build.0 = Debug|Any CPU
{11910E7D-E373-482F-8207-678DE0A88112}.Release|Any CPU.ActiveCfg = Release|Any CPU
{11910E7D-E373-482F-8207-678DE0A88112}.Release|Any CPU.Build.0 = Release|Any CPU
{AFA3DD12-0F98-4754-ADD7-9FF3C1A37C90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AFA3DD12-0F98-4754-ADD7-9FF3C1A37C90}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AFA3DD12-0F98-4754-ADD7-9FF3C1A37C90}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AFA3DD12-0F98-4754-ADD7-9FF3C1A37C90}.Release|Any CPU.Build.0 = Release|Any CPU
{3168DB6A-574F-4ECA-9203-7F54AC3311DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3168DB6A-574F-4ECA-9203-7F54AC3311DE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3168DB6A-574F-4ECA-9203-7F54AC3311DE}.Release|Any CPU.ActiveCfg = Release|Any CPU
@ -147,6 +143,10 @@ Global
{A8F7CBAE-93EC-44D2-9CDF-EEC7CEDBDC73}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A8F7CBAE-93EC-44D2-9CDF-EEC7CEDBDC73}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A8F7CBAE-93EC-44D2-9CDF-EEC7CEDBDC73}.Release|Any CPU.Build.0 = Release|Any CPU
{D98BF713-F286-4B7A-895B-C9FE8842F3A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D98BF713-F286-4B7A-895B-C9FE8842F3A9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D98BF713-F286-4B7A-895B-C9FE8842F3A9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D98BF713-F286-4B7A-895B-C9FE8842F3A9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -166,7 +166,6 @@ Global
{D3411F6C-320C-456D-BA86-24481EB000EA} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67}
{3EC0F005-560F-4E90-88CF-199520133BBA} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67}
{11910E7D-E373-482F-8207-678DE0A88112} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67}
{AFA3DD12-0F98-4754-ADD7-9FF3C1A37C90} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67}
{3168DB6A-574F-4ECA-9203-7F54AC3311DE} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67}
{07579F42-E7DD-4FFC-B375-A4EC61EAF012} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67}
{01168E4B-B34A-4C90-83E4-BEC6F03452D6} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67}
@ -174,6 +173,7 @@ Global
{6F8102FD-C78E-4AE5-8019-DC6BB4F6D1A8} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67}
{C4269D96-1699-46C8-8461-3263B5668BA5} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67}
{A8F7CBAE-93EC-44D2-9CDF-EEC7CEDBDC73} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67}
{D98BF713-F286-4B7A-895B-C9FE8842F3A9} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {878761F1-C7B5-4D38-A00D-3377D703EBBA}

View file

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,31 @@
// global using System.Collections.Concurrent;
global using NonBlocking;
// packages
global using Serilog;
global using Humanizer;
// ellie
global using Ellie;
global using Ellie.Services;
global using Ellise.Common; // new project
global using Ellie.Common; // old + ellie specific things
global using Ellie.Common.Attributes;
global using Ellie.Extensions;
global using Ellie.Marmalade;
// discord
global using Discord;
global using Discord.Commands;
global using Discord.Net;
global using Discord.WebSocket;
// aliases
global using GuildPerm = Discord.GuildPermission;
global using ChannelPerm = Discord.ChannelPermission;
global using BotPermAttribute = Discord.Commands.RequireBotPermissionAttribute;
global using LeftoverAttribute = Discord.Commands.RemainderAttribute;
global using TypeReaderResult = Ellie.Common.TypeReaders.TypeReaderResult;
// non-essential
global using JetBrains.Annotations;

View file

@ -0,0 +1,195 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Ellie.Modules.Gambling.Services;
using Ellie.Modules.Patronage;
using Ellie.Services.Currency;
using Ellie.Services.Database.Models;
namespace Ellie.Modules.Utility;
public sealed class CurrencyRewardService : IEService, IDisposable
{
private readonly ICurrencyService _cs;
private readonly IPatronageService _ps;
private readonly DbService _db;
private readonly IEmbedBuilderService _eb;
private readonly GamblingConfigService _config;
private readonly DiscordSocketClient _client;
public CurrencyRewardService(
ICurrencyService cs,
IPatronageService ps,
DbService db,
IEmbedBuilderService eb,
GamblingConfigService config,
DiscordSocketClient client)
{
_cs = cs;
_ps = ps;
_db = db;
_eb = eb;
_config = config;
_client = client;
_ps.OnNewPatronPayment += OnNewPayment;
_ps.OnPatronRefunded += OnPatronRefund;
_ps.OnPatronUpdated += OnPatronUpdate;
}
public void Dispose()
{
_ps.OnNewPatronPayment -= OnNewPayment;
_ps.OnPatronRefunded -= OnPatronRefund;
_ps.OnPatronUpdated -= OnPatronUpdate;
}
private async Task OnPatronUpdate(Patron oldPatron, Patron newPatron)
{
// if pledge was increased
if (oldPatron.Amount < newPatron.Amount)
{
var conf = _config.Data;
var newAmount = (long)(newPatron.Amount * conf.PatreonCurrencyPerCent);
RewardedUser old;
await using (var ctx = _db.GetDbContext())
{
old = await ctx.GetTable<RewardedUser>()
.Where(x => x.PlatformUserId == newPatron.UniquePlatformUserId)
.FirstOrDefaultAsync();
if (old is null)
{
await OnNewPayment(newPatron);
return;
}
// no action as the amount is the same or lower
if (old.AmountRewardedThisMonth >= newAmount)
return;
var count = await ctx.GetTable<RewardedUser>()
.Where(x => x.PlatformUserId == newPatron.UniquePlatformUserId)
.UpdateAsync(_ => new()
{
PlatformUserId = newPatron.UniquePlatformUserId,
UserId = newPatron.UserId,
// amount before bonuses
AmountRewardedThisMonth = newAmount,
LastReward = newPatron.PaidAt
});
// shouldn't ever happen
if (count == 0)
return;
}
var oldAmount = old.AmountRewardedThisMonth;
var realNewAmount = GetRealCurrencyReward(
(int)(newAmount / conf.PatreonCurrencyPerCent),
newAmount,
out var percentBonus);
var realOldAmount = GetRealCurrencyReward(
(int)(oldAmount / conf.PatreonCurrencyPerCent),
oldAmount,
out _);
var diff = realNewAmount - realOldAmount;
if (diff <= 0)
return; // no action if new is lower
// if the user pledges 5$ or more, they will get X % more flowers where X is amount in dollars,
// up to 100%
await _cs.AddAsync(newPatron.UserId, diff, new TxData("patron","update"));
_ = SendMessageToUser(newPatron.UserId,
$"You've received an additional **{diff}**{_config.Data.Currency.Sign} as a currency reward (+{percentBonus}%)!");
}
}
private long GetRealCurrencyReward(int pledgeCents, long modifiedAmount, out int percentBonus)
{
// needs at least 5$ to be eligible for a bonus
if (pledgeCents < 500)
{
percentBonus = 0;
return modifiedAmount;
}
var dollarValue = pledgeCents / 100;
percentBonus = dollarValue switch
{
>= 100 => 100,
>= 50 => 50,
>= 20 => 20,
>= 10 => 10,
>= 5 => 5,
_ => 0
};
return (long)(modifiedAmount * (1 + (percentBonus / 100.0f)));
}
// on a new payment, always give the full amount.
private async Task OnNewPayment(Patron patron)
{
var amount = (long)(patron.Amount * _config.Data.PatreonCurrencyPerCent);
await using var ctx = _db.GetDbContext();
await ctx.GetTable<RewardedUser>()
.InsertOrUpdateAsync(() => new()
{
PlatformUserId = patron.UniquePlatformUserId,
UserId = patron.UserId,
AmountRewardedThisMonth = amount,
LastReward = patron.PaidAt,
},
old => new()
{
AmountRewardedThisMonth = amount,
UserId = patron.UserId,
LastReward = patron.PaidAt
},
() => new()
{
PlatformUserId = patron.UniquePlatformUserId
});
var realAmount = GetRealCurrencyReward(patron.Amount, amount, out var percentBonus);
await _cs.AddAsync(patron.UserId, realAmount, new("patron", "new"));
_ = SendMessageToUser(patron.UserId,
$"You've received **{realAmount}**{_config.Data.Currency.Sign} as a currency reward (**+{percentBonus}%**)!");
}
private async Task SendMessageToUser(ulong userId, string message)
{
try
{
var user = (IUser)_client.GetUser(userId) ?? await _client.Rest.GetUserAsync(userId);
if (user is null)
return;
var eb = _eb.Create()
.WithOkColor()
.WithDescription(message);
await user.EmbedAsync(eb);
}
catch
{
Log.Warning("Unable to send a \"Currency Reward\" message to the patron {UserId}", userId);
}
}
private async Task OnPatronRefund(Patron patron)
{
await using var ctx = _db.GetDbContext();
_ = await ctx.GetTable<RewardedUser>()
.UpdateAsync(old => new()
{
AmountRewardedThisMonth = old.AmountRewardedThisMonth * 2
});
}
}

View file

@ -0,0 +1,11 @@
using Ellie.Db.Models;
namespace Ellie.Modules.Patronage;
public readonly struct InsufficientTier
{
public FeatureType FeatureType { get; init; }
public string Feature { get; init; }
public PatronTier RequiredTier { get; init; }
public PatronTier UserTier { get; init; }
}

View file

@ -0,0 +1,19 @@
namespace Ellie.Modules.Xp;
public sealed partial class Xp
{
public class CleanupCommands : CleanupModuleBase
{
private readonly IXpCleanupService _service;
public CleanupCommands(IXpCleanupService service)
{
_service = service;
}
[Cmd]
[OwnerOnly]
public Task DeleteXp()
=> ConfirmActionInternalAsync("Delete Xp", () => _service.DeleteXp());
}
}

View file

@ -0,0 +1,424 @@
#nullable disable
using Ellie.Db;
using Ellie.Db.Models;
using Ellie.Modules.Xp.Services;
namespace Ellie.Modules.Xp.Club;
public partial class Xp
{
[Group]
public partial class Club : EllieModule<IClubService>
{
private readonly XpService _xps;
public Club(XpService xps)
=> _xps = xps;
[Cmd]
public async Task ClubTransfer([Leftover] IUser newOwner)
{
var result = _service.TransferClub(ctx.User, newOwner);
if (!result.TryPickT0(out var club, out var error))
{
if (error == ClubTransferError.NotOwner)
await ReplyErrorLocalizedAsync(strs.club_owner_only);
else
await ReplyErrorLocalizedAsync(strs.club_target_not_member);
}
else
{
await ReplyConfirmLocalizedAsync(
strs.club_transfered(
Format.Bold(club.Name),
Format.Bold(newOwner.ToString())
)
);
}
}
[Cmd]
public async Task ClubAdmin([Leftover] IUser toAdmin)
{
var result = await _service.ToggleAdminAsync(ctx.User, toAdmin);
if (result == ToggleAdminResult.AddedAdmin)
await ReplyConfirmLocalizedAsync(strs.club_admin_add(Format.Bold(toAdmin.ToString())));
else if (result == ToggleAdminResult.RemovedAdmin)
await ReplyConfirmLocalizedAsync(strs.club_admin_remove(Format.Bold(toAdmin.ToString())));
else if (result == ToggleAdminResult.NotOwner)
await ReplyErrorLocalizedAsync(strs.club_owner_only);
else if (result == ToggleAdminResult.CantTargetThyself)
await ReplyErrorLocalizedAsync(strs.club_admin_invalid_target);
else if (result == ToggleAdminResult.TargetNotMember)
await ReplyErrorLocalizedAsync(strs.club_target_not_member);
}
[Cmd]
public async Task ClubCreate([Leftover] string clubName)
{
if (string.IsNullOrWhiteSpace(clubName) || clubName.Length > 20)
{
await ReplyErrorLocalizedAsync(strs.club_name_too_long);
return;
}
var result = await _service.CreateClubAsync(ctx.User, clubName);
if (result == ClubCreateResult.NameTaken)
{
await ReplyErrorLocalizedAsync(strs.club_create_error_name);
return;
}
if (result == ClubCreateResult.InsufficientLevel)
{
await ReplyErrorLocalizedAsync(strs.club_create_insuff_lvl);
return;
}
if (result == ClubCreateResult.AlreadyInAClub)
{
await ReplyErrorLocalizedAsync(strs.club_already_in);
return;
}
await ReplyConfirmLocalizedAsync(strs.club_created(Format.Bold(clubName)));
}
[Cmd]
public async Task ClubIcon([Leftover] string url = null)
{
if ((!Uri.IsWellFormedUriString(url, UriKind.Absolute) && url is not null))
{
await ReplyErrorLocalizedAsync(strs.club_icon_url_format);
return;
}
var result = await _service.SetClubIconAsync(ctx.User.Id, url);
if (result == SetClubIconResult.Success)
await ReplyConfirmLocalizedAsync(strs.club_icon_set);
else if (result == SetClubIconResult.NotOwner)
await ReplyErrorLocalizedAsync(strs.club_owner_only);
else if (result == SetClubIconResult.TooLarge)
await ReplyErrorLocalizedAsync(strs.club_icon_too_large);
else if (result == SetClubIconResult.InvalidFileType)
await ReplyErrorLocalizedAsync(strs.club_icon_invalid_filetype);
}
private async Task InternalClubInfoAsync(ClubInfo club)
{
var lvl = new LevelStats(club.Xp);
var users = club.Members.OrderByDescending(x =>
{
var l = new LevelStats(x.TotalXp).Level;
if (club.OwnerId == x.Id)
return int.MaxValue;
if (x.IsClubAdmin)
return (int.MaxValue / 2) + l;
return l;
});
await ctx.SendPaginatedConfirmAsync(0,
page =>
{
var embed = _eb.Create()
.WithOkColor()
.WithTitle($"{club}")
.WithDescription(GetText(strs.level_x(lvl.Level + $" ({club.Xp} xp)")))
.AddField(GetText(strs.desc),
string.IsNullOrWhiteSpace(club.Description) ? "-" : club.Description)
.AddField(GetText(strs.owner), club.Owner.ToString(), true)
// .AddField(GetText(strs.level_req), club.MinimumLevelReq.ToString(), true)
.AddField(GetText(strs.members),
string.Join("\n",
users.Skip(page * 10)
.Take(10)
.Select(x =>
{
var l = new LevelStats(x.TotalXp);
var lvlStr = Format.Bold($" ⟪{l.Level}⟫");
if (club.OwnerId == x.Id)
return x + "🌟" + lvlStr;
if (x.IsClubAdmin)
return x + "⭐" + lvlStr;
return x + lvlStr;
})));
if (Uri.IsWellFormedUriString(club.ImageUrl, UriKind.Absolute))
return embed.WithThumbnailUrl(club.ImageUrl);
return embed;
},
club.Members.Count,
10);
}
[Cmd]
[Priority(1)]
public async Task ClubInformation(IUser user = null)
{
user ??= ctx.User;
var club = _service.GetClubByMember(user);
if (club is null)
{
await ErrorLocalizedAsync(strs.club_user_not_in_club(Format.Bold(user.ToString())));
return;
}
await InternalClubInfoAsync(club);
}
[Cmd]
[Priority(0)]
public async Task ClubInformation([Leftover] string clubName = null)
{
if (string.IsNullOrWhiteSpace(clubName))
{
await ClubInformation(ctx.User);
return;
}
if (!_service.GetClubByName(clubName, out var club))
{
await ReplyErrorLocalizedAsync(strs.club_not_exists);
return;
}
await InternalClubInfoAsync(club);
}
[Cmd]
public Task ClubBans(int page = 1)
{
if (--page < 0)
return Task.CompletedTask;
var club = _service.GetClubWithBansAndApplications(ctx.User.Id);
if (club is null)
return ReplyErrorLocalizedAsync(strs.club_admin_perms);
var bans = club.Bans.Select(x => x.User).ToArray();
return ctx.SendPaginatedConfirmAsync(page,
_ =>
{
var toShow = string.Join("\n", bans
.Skip(page * 10).Take(10)
.Select(x => x.ToString()));
return _eb.Create()
.WithTitle(GetText(strs.club_bans_for(club.ToString())))
.WithDescription(toShow)
.WithOkColor();
},
bans.Length,
10);
}
[Cmd]
public Task ClubApps(int page = 1)
{
if (--page < 0)
return Task.CompletedTask;
var club = _service.GetClubWithBansAndApplications(ctx.User.Id);
if (club is null)
return ReplyErrorLocalizedAsync(strs.club_admin_perms);
var apps = club.Applicants.Select(x => x.User).ToArray();
return ctx.SendPaginatedConfirmAsync(page,
_ =>
{
var toShow = string.Join("\n", apps.Skip(page * 10).Take(10).Select(x => x.ToString()));
return _eb.Create()
.WithTitle(GetText(strs.club_apps_for(club.ToString())))
.WithDescription(toShow)
.WithOkColor();
},
apps.Length,
10);
}
[Cmd]
public async Task ClubApply([Leftover] string clubName)
{
if (string.IsNullOrWhiteSpace(clubName))
return;
if (!_service.GetClubByName(clubName, out var club))
{
await ReplyErrorLocalizedAsync(strs.club_not_exists);
return;
}
var result = _service.ApplyToClub(ctx.User, club);
if (result == ClubApplyResult.Success)
await ReplyConfirmLocalizedAsync(strs.club_applied(Format.Bold(club.ToString())));
else if (result == ClubApplyResult.Banned)
await ReplyErrorLocalizedAsync(strs.club_join_banned);
else if (result == ClubApplyResult.InsufficientLevel)
await ReplyErrorLocalizedAsync(strs.club_insuff_lvl);
else if (result == ClubApplyResult.AlreadyInAClub)
await ReplyErrorLocalizedAsync(strs.club_already_in);
}
[Cmd]
[Priority(1)]
public Task ClubAccept(IUser user)
=> ClubAccept(user.ToString());
[Cmd]
[Priority(0)]
public async Task ClubAccept([Leftover] string userName)
{
var result = _service.AcceptApplication(ctx.User.Id, userName, out var discordUser);
if (result == ClubAcceptResult.Accepted)
await ReplyConfirmLocalizedAsync(strs.club_accepted(Format.Bold(discordUser.ToString())));
else if (result == ClubAcceptResult.NoSuchApplicant)
await ReplyErrorLocalizedAsync(strs.club_accept_invalid_applicant);
else if (result == ClubAcceptResult.NotOwnerOrAdmin)
await ReplyErrorLocalizedAsync(strs.club_admin_perms);
}
[Cmd]
public async Task ClubLeave()
{
var res = _service.LeaveClub(ctx.User);
if (res == ClubLeaveResult.Success)
await ReplyConfirmLocalizedAsync(strs.club_left);
else if (res == ClubLeaveResult.NotInAClub)
await ReplyErrorLocalizedAsync(strs.club_not_in_a_club);
else
await ReplyErrorLocalizedAsync(strs.club_owner_cant_leave);
}
[Cmd]
[Priority(1)]
public Task ClubKick([Leftover] IUser user)
=> ClubKick(user.ToString());
[Cmd]
[Priority(0)]
public Task ClubKick([Leftover] string userName)
{
var result = _service.Kick(ctx.User.Id, userName, out var club);
if (result == ClubKickResult.Success)
{
return ReplyConfirmLocalizedAsync(strs.club_user_kick(Format.Bold(userName),
Format.Bold(club.ToString())));
}
if (result == ClubKickResult.Hierarchy)
return ReplyErrorLocalizedAsync(strs.club_kick_hierarchy);
if (result == ClubKickResult.NotOwnerOrAdmin)
return ReplyErrorLocalizedAsync(strs.club_admin_perms);
return ReplyErrorLocalizedAsync(strs.club_target_not_member);
}
[Cmd]
[Priority(1)]
public Task ClubBan([Leftover] IUser user)
=> ClubBan(user.ToString());
[Cmd]
[Priority(0)]
public Task ClubBan([Leftover] string userName)
{
var result = _service.Ban(ctx.User.Id, userName, out var club);
if (result == ClubBanResult.Success)
{
return ReplyConfirmLocalizedAsync(strs.club_user_banned(Format.Bold(userName),
Format.Bold(club.ToString())));
}
if (result == ClubBanResult.Unbannable)
return ReplyErrorLocalizedAsync(strs.club_ban_fail_unbannable);
if (result == ClubBanResult.WrongUser)
return ReplyErrorLocalizedAsync(strs.club_ban_fail_user_not_found);
return ReplyErrorLocalizedAsync(strs.club_admin_perms);
}
[Cmd]
[Priority(1)]
public Task ClubUnBan([Leftover] IUser user)
=> ClubUnBan(user.ToString());
[Cmd]
[Priority(0)]
public Task ClubUnBan([Leftover] string userName)
{
var result = _service.UnBan(ctx.User.Id, userName, out var club);
if (result == ClubUnbanResult.Success)
{
return ReplyConfirmLocalizedAsync(strs.club_user_unbanned(Format.Bold(userName),
Format.Bold(club.ToString())));
}
if (result == ClubUnbanResult.WrongUser)
{
return ReplyErrorLocalizedAsync(strs.club_unban_fail_user_not_found);
}
return ReplyErrorLocalizedAsync(strs.club_admin_perms);
}
[Cmd]
public async Task ClubDescription([Leftover] string desc = null)
{
if (_service.SetDescription(ctx.User.Id, desc))
{
desc = string.IsNullOrWhiteSpace(desc)
? "-"
: desc;
var eb = _eb.Create(ctx)
.WithAuthor(ctx.User)
.WithTitle(GetText(strs.club_desc_update))
.WithOkColor()
.WithDescription(desc);
await ctx.Channel.EmbedAsync(eb);
}
else
{
await ReplyErrorLocalizedAsync(strs.club_desc_update_failed);
}
}
[Cmd]
public async Task ClubDisband()
{
if (_service.Disband(ctx.User.Id, out var club))
await ReplyConfirmLocalizedAsync(strs.club_disbanded(Format.Bold(club.Name)));
else
await ReplyErrorLocalizedAsync(strs.club_disband_error);
}
[Cmd]
public Task ClubLeaderboard(int page = 1)
{
if (--page < 0)
return Task.CompletedTask;
var clubs = _service.GetClubLeaderboardPage(page);
var embed = _eb.Create().WithTitle(GetText(strs.club_leaderboard(page + 1))).WithOkColor();
var i = page * 9;
foreach (var club in clubs)
embed.AddField($"#{++i} " + club, club.Xp + " xp");
return ctx.Channel.EmbedAsync(embed);
}
}
}

View file

@ -0,0 +1,319 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Ellie.Db;
using Ellie.Db.Models;
using OneOf;
namespace Ellie.Modules.Xp.Services;
public class ClubService : IEService, IClubService
{
private readonly DbService _db;
private readonly IHttpClientFactory _httpFactory;
public ClubService(DbService db, IHttpClientFactory httpFactory)
{
_db = db;
_httpFactory = httpFactory;
}
public async Task<ClubCreateResult> CreateClubAsync(IUser user, string clubName)
{
//must be lvl 5 and must not be in a club already
await using var uow = _db.GetDbContext();
var du = uow.GetOrCreateUser(user);
var xp = new LevelStats(du.TotalXp);
if (xp.Level < 5)
return ClubCreateResult.InsufficientLevel;
if (du.ClubId is not null)
return ClubCreateResult.AlreadyInAClub;
if (await uow.Set<ClubInfo>().AnyAsyncEF(x => x.Name == clubName))
return ClubCreateResult.NameTaken;
du.IsClubAdmin = true;
du.Club = new()
{
Name = clubName,
Owner = du
};
uow.Set<ClubInfo>().Add(du.Club);
await uow.SaveChangesAsync();
await uow.GetTable<ClubApplicants>()
.DeleteAsync(x => x.UserId == du.Id);
return ClubCreateResult.Success;
}
public OneOf<ClubInfo, ClubTransferError> TransferClub(IUser from, IUser newOwner)
{
using var uow = _db.GetDbContext();
var club = uow.Set<ClubInfo>().GetByOwner(@from.Id);
var newOwnerUser = uow.GetOrCreateUser(newOwner);
if (club is null || club.Owner.UserId != from.Id)
return ClubTransferError.NotOwner;
if (!club.Members.Contains(newOwnerUser))
return ClubTransferError.TargetNotMember;
club.Owner.IsClubAdmin = true; // old owner will stay as admin
newOwnerUser.IsClubAdmin = true;
club.Owner = newOwnerUser;
uow.SaveChanges();
return club;
}
public async Task<ToggleAdminResult> ToggleAdminAsync(IUser owner, IUser toAdmin)
{
if (owner.Id == toAdmin.Id)
return ToggleAdminResult.CantTargetThyself;
await using var uow = _db.GetDbContext();
var club = uow.Set<ClubInfo>().GetByOwner(owner.Id);
var adminUser = uow.GetOrCreateUser(toAdmin);
if (club is null)
return ToggleAdminResult.NotOwner;
if(!club.Members.Contains(adminUser))
return ToggleAdminResult.TargetNotMember;
var newState = adminUser.IsClubAdmin = !adminUser.IsClubAdmin;
await uow.SaveChangesAsync();
return newState ? ToggleAdminResult.AddedAdmin : ToggleAdminResult.RemovedAdmin;
}
public ClubInfo GetClubByMember(IUser user)
{
using var uow = _db.GetDbContext();
var member = uow.Set<ClubInfo>().GetByMember(user.Id);
return member;
}
public async Task<SetClubIconResult> SetClubIconAsync(ulong ownerUserId, string url)
{
if (url is not null)
{
using var http = _httpFactory.CreateClient();
using var temp = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
if (!temp.IsImage())
return SetClubIconResult.InvalidFileType;
if (temp.GetContentLength() > 5.Megabytes().Bytes)
return SetClubIconResult.TooLarge;
}
await using var uow = _db.GetDbContext();
var club = uow.Set<ClubInfo>().GetByOwner(ownerUserId);
if (club is null)
return SetClubIconResult.NotOwner;
club.ImageUrl = url;
await uow.SaveChangesAsync();
return SetClubIconResult.Success;
}
public bool GetClubByName(string clubName, out ClubInfo club)
{
using var uow = _db.GetDbContext();
club = uow.Set<ClubInfo>().GetByName(clubName);
return club is not null;
}
public ClubApplyResult ApplyToClub(IUser user, ClubInfo club)
{
using var uow = _db.GetDbContext();
var du = uow.GetOrCreateUser(user);
uow.SaveChanges();
//user banned or a member of a club, or already applied,
// or doesn't min minumum level requirement, can't apply
if (du.Club is not null)
return ClubApplyResult.AlreadyInAClub;
if (club.Bans.Any(x => x.UserId == du.Id))
return ClubApplyResult.Banned;
if (club.Applicants.Any(x => x.UserId == du.Id))
return ClubApplyResult.InsufficientLevel;
var app = new ClubApplicants
{
ClubId = club.Id,
UserId = du.Id
};
uow.Set<ClubApplicants>().Add(app);
uow.SaveChanges();
return ClubApplyResult.Success;
}
public ClubAcceptResult AcceptApplication(ulong clubOwnerUserId, string userName, out DiscordUser discordUser)
{
discordUser = null;
using var uow = _db.GetDbContext();
var club = uow.Set<ClubInfo>().GetByOwnerOrAdmin(clubOwnerUserId);
if (club is null)
return ClubAcceptResult.NotOwnerOrAdmin;
var applicant =
club.Applicants.FirstOrDefault(x => x.User.ToString().ToUpperInvariant() == userName.ToUpperInvariant());
if (applicant is null)
return ClubAcceptResult.NoSuchApplicant;
applicant.User.Club = club;
applicant.User.IsClubAdmin = false;
club.Applicants.Remove(applicant);
//remove that user's all other applications
uow.Set<ClubApplicants>()
.RemoveRange(uow.Set<ClubApplicants>().AsQueryable().Where(x => x.UserId == applicant.User.Id));
discordUser = applicant.User;
uow.SaveChanges();
return ClubAcceptResult.Accepted;
}
public ClubInfo GetClubWithBansAndApplications(ulong ownerUserId)
{
using var uow = _db.GetDbContext();
return uow.Set<ClubInfo>().GetByOwnerOrAdmin(ownerUserId);
}
public ClubLeaveResult LeaveClub(IUser user)
{
using var uow = _db.GetDbContext();
var du = uow.GetOrCreateUser(user, x => x.Include(u => u.Club));
if (du.Club is null)
return ClubLeaveResult.NotInAClub;
if (du.Club.OwnerId == du.Id)
return ClubLeaveResult.OwnerCantLeave;
du.Club = null;
du.IsClubAdmin = false;
uow.SaveChanges();
return ClubLeaveResult.Success;
}
public bool SetDescription(ulong userId, string desc)
{
using var uow = _db.GetDbContext();
var club = uow.Set<ClubInfo>().GetByOwner(userId);
if (club is null)
return false;
club.Description = desc?.TrimTo(150, true);
uow.SaveChanges();
return true;
}
public bool Disband(ulong userId, out ClubInfo club)
{
using var uow = _db.GetDbContext();
club = uow.Set<ClubInfo>().GetByOwner(userId);
if (club is null)
return false;
uow.Set<ClubInfo>().Remove(club);
uow.SaveChanges();
return true;
}
public ClubBanResult Ban(ulong bannerId, string userName, out ClubInfo club)
{
using var uow = _db.GetDbContext();
club = uow.Set<ClubInfo>().GetByOwnerOrAdmin(bannerId);
if (club is null)
return ClubBanResult.NotOwnerOrAdmin;
var usr = club.Members.FirstOrDefault(x => x.ToString().ToUpperInvariant() == userName.ToUpperInvariant())
?? club.Applicants
.FirstOrDefault(x => x.User.ToString().ToUpperInvariant() == userName.ToUpperInvariant())
?.User;
if (usr is null)
return ClubBanResult.WrongUser;
if (club.OwnerId == usr.Id
|| (usr.IsClubAdmin && club.Owner.UserId != bannerId)) // can't ban the owner kek, whew
return ClubBanResult.Unbannable;
club.Bans.Add(new()
{
Club = club,
User = usr
});
club.Members.Remove(usr);
var app = club.Applicants.FirstOrDefault(x => x.UserId == usr.Id);
if (app is not null)
club.Applicants.Remove(app);
uow.SaveChanges();
return ClubBanResult.Success;
}
public ClubUnbanResult UnBan(ulong ownerUserId, string userName, out ClubInfo club)
{
using var uow = _db.GetDbContext();
club = uow.Set<ClubInfo>().GetByOwnerOrAdmin(ownerUserId);
if (club is null)
return ClubUnbanResult.NotOwnerOrAdmin;
var ban = club.Bans.FirstOrDefault(x => x.User.ToString().ToUpperInvariant() == userName.ToUpperInvariant());
if (ban is null)
return ClubUnbanResult.WrongUser;
club.Bans.Remove(ban);
uow.SaveChanges();
return ClubUnbanResult.Success;
}
public ClubKickResult Kick(ulong kickerId, string userName, out ClubInfo club)
{
using var uow = _db.GetDbContext();
club = uow.Set<ClubInfo>().GetByOwnerOrAdmin(kickerId);
if (club is null)
return ClubKickResult.NotOwnerOrAdmin;
var usr = club.Members.FirstOrDefault(x => x.ToString().ToUpperInvariant() == userName.ToUpperInvariant());
if (usr is null)
return ClubKickResult.TargetNotAMember;
if (club.OwnerId == usr.Id || (usr.IsClubAdmin && club.Owner.UserId != kickerId))
return ClubKickResult.Hierarchy;
club.Members.Remove(usr);
var app = club.Applicants.FirstOrDefault(x => x.UserId == usr.Id);
if (app is not null)
club.Applicants.Remove(app);
uow.SaveChanges();
return ClubKickResult.Success;
}
public List<ClubInfo> GetClubLeaderboardPage(int page)
{
if (page < 0)
throw new ArgumentOutOfRangeException(nameof(page));
using var uow = _db.GetDbContext();
return uow.Set<ClubInfo>().GetClubLeaderboardPage(page);
}
}

View file

@ -0,0 +1,33 @@
using Ellie.Db.Models;
using OneOf;
namespace Ellie.Modules.Xp.Services;
public interface IClubService
{
Task<ClubCreateResult> CreateClubAsync(IUser user, string clubName);
OneOf<ClubInfo,ClubTransferError> TransferClub(IUser from, IUser newOwner);
Task<ToggleAdminResult> ToggleAdminAsync(IUser owner, IUser toAdmin);
ClubInfo? GetClubByMember(IUser user);
Task<SetClubIconResult> SetClubIconAsync(ulong ownerUserId, string? url);
bool GetClubByName(string clubName, out ClubInfo club);
ClubApplyResult ApplyToClub(IUser user, ClubInfo club);
ClubAcceptResult AcceptApplication(ulong clubOwnerUserId, string userName, out DiscordUser discordUser);
ClubInfo? GetClubWithBansAndApplications(ulong ownerUserId);
ClubLeaveResult LeaveClub(IUser user);
bool SetDescription(ulong userId, string? desc);
bool Disband(ulong userId, out ClubInfo club);
ClubBanResult Ban(ulong bannerId, string userName, out ClubInfo club);
ClubUnbanResult UnBan(ulong ownerUserId, string userName, out ClubInfo club);
ClubKickResult Kick(ulong kickerId, string userName, out ClubInfo club);
List<ClubInfo> GetClubLeaderboardPage(int page);
}
public enum ClubApplyResult
{
Success,
AlreadyInAClub,
Banned,
InsufficientLevel
}

View file

@ -0,0 +1,8 @@
namespace Ellie.Modules.Xp.Services;
public enum ClubAcceptResult
{
Accepted,
NotOwnerOrAdmin,
NoSuchApplicant,
}

View file

@ -0,0 +1,9 @@
namespace Ellie.Modules.Xp.Services;
public enum ClubBanResult
{
Success,
NotOwnerOrAdmin,
WrongUser,
Unbannable
}

View file

@ -0,0 +1,9 @@
namespace Ellie.Modules.Xp.Services;
public enum ClubCreateResult
{
Success,
AlreadyInAClub,
NameTaken,
InsufficientLevel,
}

View file

@ -0,0 +1,9 @@
namespace Ellie.Modules.Xp.Services;
public enum ClubKickResult
{
Success,
NotOwnerOrAdmin,
TargetNotAMember,
Hierarchy
}

View file

@ -0,0 +1,8 @@
namespace Ellie.Modules.Xp.Services;
public enum ClubLeaveResult
{
Success,
OwnerCantLeave,
NotInAClub
}

View file

@ -0,0 +1,7 @@
namespace Ellie.Modules.Xp.Services;
public enum ClubTransferError
{
NotOwner,
TargetNotMember
}

View file

@ -0,0 +1,8 @@
namespace Ellie.Modules.Xp.Services;
public enum ClubUnbanResult
{
Success,
NotOwnerOrAdmin,
WrongUser
}

View file

@ -0,0 +1,9 @@
namespace Ellie.Modules.Xp.Services;
public enum SetClubIconResult
{
Success,
InvalidFileType,
TooLarge,
NotOwner,
}

View file

@ -0,0 +1,10 @@
namespace Ellie.Modules.Xp.Services;
public enum ToggleAdminResult
{
AddedAdmin,
RemovedAdmin,
NotOwner,
TargetNotMember,
CantTargetThyself,
}

View file

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="linq2db.EntityFrameworkCore" Version="7.3.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Ellie.Bot.Common\Ellie.Bot.Common.csproj"/>
<ProjectReference Include="..\Ellie.Bot.Db\Ellie.Bot.Db.csproj"/>
<ProjectReference Include="..\Ellie.Bot.Generators.Cloneable\Ellie.Bot.Generators.Cloneable.csproj" OutputItemType="Analyzer"/>
</ItemGroup>
</Project>

View file

@ -0,0 +1,32 @@
// // global using System.Collections.Concurrent;
global using NonBlocking;
//
// // packages
global using Serilog;
global using Humanizer;
global using Newtonsoft;
//
// // ellie
// global using Ellie;
global using Ellie.Services;
global using Ellise.Common; // new project
global using Ellie.Common; // old + ellie specific things
global using Ellie.Common.Attributes;
global using Ellie.Extensions;
// global using Nadeko.Snake;
// discord
global using Discord;
global using Discord.Commands;
global using Discord.Net;
global using Discord.WebSocket;
// aliases
global using GuildPerm = Discord.GuildPermission;
global using ChannelPerm = Discord.ChannelPermission;
global using BotPermAttribute = Discord.Commands.RequireBotPermissionAttribute;
global using LeftoverAttribute = Discord.Commands.RemainderAttribute;
global using TypeReaderResult = Ellie.Common.TypeReaders.TypeReaderResult;
// non-essential
// global using JetBrains.Annotations;

View file

@ -0,0 +1,559 @@
#nullable disable warnings
using Ellie.Modules.Xp.Services;
using Ellie.Services.Database.Models;
using Ellie.Bot.Common;
using Ellie.Db;
using Ellie.Db.Models;
using Ellie.Modules.Patronage;
namespace Ellie.Modules.Xp;
public partial class Xp : EllieModule<XpService>
{
public enum Channel
{
Channel
}
public enum NotifyPlace
{
Server = 0,
Guild = 0,
Global = 1
}
public enum Role
{
Role
}
public enum Server
{
Server
}
private readonly DownloadTracker _tracker;
private readonly ICurrencyProvider _gss;
public Xp(DownloadTracker tracker, ICurrencyProvider gss)
{
_tracker = tracker;
_gss = gss;
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Experience([Leftover] IUser user = null)
{
user ??= ctx.User;
await ctx.Channel.TriggerTypingAsync();
var (img, fmt) = await _service.GenerateXpImageAsync((IGuildUser)user);
await using (img)
{
await ctx.Channel.SendFileAsync(img, $"{ctx.Guild.Id}_{user.Id}_xp.{fmt.FileExtensions.FirstOrDefault()}");
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task XpNotify()
{
var globalSetting = _service.GetNotificationType(ctx.User);
var serverSetting = _service.GetNotificationType(ctx.User.Id, ctx.Guild.Id);
var embed = _eb.Create()
.WithOkColor()
.AddField(GetText(strs.xpn_setting_global), GetNotifLocationString(globalSetting))
.AddField(GetText(strs.xpn_setting_server), GetNotifLocationString(serverSetting));
await ctx.Channel.EmbedAsync(embed);
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task XpNotify(NotifyPlace place, XpNotificationLocation type)
{
if (place == NotifyPlace.Guild)
await _service.ChangeNotificationType(ctx.User.Id, ctx.Guild.Id, type);
else
await _service.ChangeNotificationType(ctx.User, type);
await ctx.OkAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task XpExclude(Server _)
{
var ex = _service.ToggleExcludeServer(ctx.Guild.Id);
if (ex)
await ReplyConfirmLocalizedAsync(strs.excluded(Format.Bold(ctx.Guild.ToString())));
else
await ReplyConfirmLocalizedAsync(strs.not_excluded(Format.Bold(ctx.Guild.ToString())));
}
[Cmd]
[UserPerm(GuildPerm.ManageRoles)]
[RequireContext(ContextType.Guild)]
public async Task XpExclude(Role _, [Leftover] IRole role)
{
var ex = _service.ToggleExcludeRole(ctx.Guild.Id, role.Id);
if (ex)
await ReplyConfirmLocalizedAsync(strs.excluded(Format.Bold(role.ToString())));
else
await ReplyConfirmLocalizedAsync(strs.not_excluded(Format.Bold(role.ToString())));
}
[Cmd]
[UserPerm(GuildPerm.ManageChannels)]
[RequireContext(ContextType.Guild)]
public async Task XpExclude(Channel _, [Leftover] IChannel channel = null)
{
if (channel is null)
channel = ctx.Channel;
var ex = _service.ToggleExcludeChannel(ctx.Guild.Id, channel.Id);
if (ex)
await ReplyConfirmLocalizedAsync(strs.excluded(Format.Bold(channel.ToString())));
else
await ReplyConfirmLocalizedAsync(strs.not_excluded(Format.Bold(channel.ToString())));
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task XpExclusionList()
{
var serverExcluded = _service.IsServerExcluded(ctx.Guild.Id);
var roles = _service.GetExcludedRoles(ctx.Guild.Id)
.Select(x => ctx.Guild.GetRole(x))
.Where(x => x is not null)
.Select(x => $"`role` {x.Mention}")
.ToList();
var chans = (await _service.GetExcludedChannels(ctx.Guild.Id)
.Select(x => ctx.Guild.GetChannelAsync(x))
.WhenAll()).Where(x => x is not null)
.Select(x => $"`channel` <#{x.Id}>")
.ToList();
var rolesStr = roles.Any() ? string.Join("\n", roles) + "\n" : string.Empty;
var chansStr = chans.Count > 0 ? string.Join("\n", chans) + "\n" : string.Empty;
var desc = Format.Code(serverExcluded
? GetText(strs.server_is_excluded)
: GetText(strs.server_is_not_excluded));
desc += "\n\n" + rolesStr + chansStr;
var lines = desc.Split('\n');
await ctx.SendPaginatedConfirmAsync(0,
curpage =>
{
var embed = _eb.Create()
.WithTitle(GetText(strs.exclusion_list))
.WithDescription(string.Join('\n', lines.Skip(15 * curpage).Take(15)))
.WithOkColor();
return embed;
},
lines.Length,
15);
}
[Cmd]
[EllieOptions<LbOpts>]
[Priority(0)]
[RequireContext(ContextType.Guild)]
public Task XpLeaderboard(params string[] args)
=> XpLeaderboard(1, args);
[Cmd]
[EllieOptions<LbOpts>]
[Priority(1)]
[RequireContext(ContextType.Guild)]
public async Task XpLeaderboard(int page = 1, params string[] args)
{
if (--page < 0 || page > 100)
return;
var (opts, _) = OptionsParser.ParseFrom(new LbOpts(), args);
await ctx.Channel.TriggerTypingAsync();
var socketGuild = (SocketGuild)ctx.Guild;
var allUsers = new List<UserXpStats>();
if (opts.Clean)
{
await ctx.Channel.TriggerTypingAsync();
await _tracker.EnsureUsersDownloadedAsync(ctx.Guild);
allUsers = _service.GetTopUserXps(ctx.Guild.Id, 1000)
.Where(user => socketGuild.GetUser(user.UserId) is not null)
.ToList();
}
await ctx.SendPaginatedConfirmAsync(page,
curPage =>
{
var embed = _eb.Create().WithTitle(GetText(strs.server_leaderboard)).WithOkColor();
List<UserXpStats> users;
if (opts.Clean)
users = allUsers.Skip(curPage * 9).Take(9).ToList();
else
users = _service.GetUserXps(ctx.Guild.Id, curPage);
if (!users.Any())
return embed.WithDescription("-");
for (var i = 0; i < users.Count; i++)
{
var levelStats = new LevelStats(users[i].Xp + users[i].AwardedXp);
var user = ((SocketGuild)ctx.Guild).GetUser(users[i].UserId);
var userXpData = users[i];
var awardStr = string.Empty;
if (userXpData.AwardedXp > 0)
awardStr = $"(+{userXpData.AwardedXp})";
else if (userXpData.AwardedXp < 0)
awardStr = $"({userXpData.AwardedXp})";
embed.AddField($"#{i + 1 + (curPage * 9)} {user?.ToString() ?? users[i].UserId.ToString()}",
$"{GetText(strs.level_x(levelStats.Level))} - {levelStats.TotalXp}xp {awardStr}");
}
return embed;
},
900,
9,
false);
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task XpGlobalLeaderboard(int page = 1)
{
if (--page < 0 || page > 99)
return;
var users = _service.GetUserXps(page);
var embed = _eb.Create().WithTitle(GetText(strs.global_leaderboard)).WithOkColor();
if (!users.Any())
embed.WithDescription("-");
else
{
for (var i = 0; i < users.Length; i++)
{
var user = users[i];
embed.AddField($"#{i + 1 + (page * 9)} {user.ToString()}",
$"{GetText(strs.level_x(new LevelStats(users[i].TotalXp).Level))} - {users[i].TotalXp}xp");
}
}
await ctx.Channel.EmbedAsync(embed);
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[Priority(2)]
public async Task XpAdd(long amount, [Remainder] SocketRole role)
{
if (amount == 0)
return;
if (role.IsManaged)
return;
var count = await _service.AddXpToUsersAsync(ctx.Guild.Id, amount, role.Members.Select(x => x.Id).ToArray());
await ReplyConfirmLocalizedAsync(
strs.xpadd_users(Format.Bold(amount.ToString()), Format.Bold(count.ToString())));
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[Priority(3)]
public async Task XpAdd(int amount, ulong userId)
{
if (amount == 0)
return;
_service.AddXp(userId, ctx.Guild.Id, amount);
var usr = ((SocketGuild)ctx.Guild).GetUser(userId)?.ToString() ?? userId.ToString();
await ReplyConfirmLocalizedAsync(strs.modified(Format.Bold(usr), Format.Bold(amount.ToString())));
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[Priority(4)]
public Task XpAdd(int amount, [Leftover] IGuildUser user)
=> XpAdd(amount, user.Id);
[Cmd]
[RequireContext(ContextType.Guild)]
[OwnerOnly]
public async Task XpTemplateReload()
{
_service.ReloadXpTemplate();
await Task.Delay(1000);
await ReplyConfirmLocalizedAsync(strs.template_reloaded);
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public Task XpReset(IGuildUser user)
=> XpReset(user.Id);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task XpReset(ulong userId)
{
var embed = _eb.Create().WithTitle(GetText(strs.reset)).WithDescription(GetText(strs.reset_user_confirm));
if (!await PromptUserConfirmAsync(embed))
return;
_service.XpReset(ctx.Guild.Id, userId);
await ReplyConfirmLocalizedAsync(strs.reset_user(userId));
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task XpReset()
{
var embed = _eb.Create().WithTitle(GetText(strs.reset)).WithDescription(GetText(strs.reset_server_confirm));
if (!await PromptUserConfirmAsync(embed))
return;
_service.XpReset(ctx.Guild.Id);
await ReplyConfirmLocalizedAsync(strs.reset_server);
}
public enum XpShopInputType
{
Backgrounds = 0,
B = 0,
Bg = 0,
Bgs = 0,
Frames = 1,
F = 1,
Fr = 1,
Frs = 1,
Fs = 1,
}
[Cmd]
public async Task XpShop()
{
if (!_service.IsShopEnabled())
{
await ReplyErrorLocalizedAsync(strs.xp_shop_disabled);
return;
}
await SendConfirmAsync(GetText(strs.available_commands),
$@"`{prefix}xpshop bgs`
`{prefix}xpshop frames`
*{GetText(strs.xpshop_website)}*");
}
[Cmd]
public async Task XpShop(XpShopInputType type, int page = 1)
{
--page;
if (page < 0)
return;
var items = type == XpShopInputType.Backgrounds
? await _service.GetShopBgs()
: await _service.GetShopFrames();
if (items is null)
{
await ReplyErrorLocalizedAsync(strs.xp_shop_disabled);
return;
}
if (items.Count == 0)
{
await ReplyErrorLocalizedAsync(strs.not_found);
return;
}
await ctx.SendPaginatedConfirmAsync<(string, XpShopItemType)?>(page,
current =>
{
var (key, item) = items.Skip(current).First();
var eb = _eb.Create(ctx)
.WithOkColor()
.WithTitle(item.Name)
.AddField(GetText(strs.price), CurrencyHelper.N(item.Price, Culture, _gss.GetCurrencySign()), true)
.WithImageUrl(string.IsNullOrWhiteSpace(item.Preview)
? item.Url
: item.Preview);
if (!string.IsNullOrWhiteSpace(item.Desc))
eb.AddField(GetText(strs.desc), item.Desc);
if (key == "default")
eb.WithDescription(GetText(strs.xpshop_website));
var tier = _service.GetXpShopTierRequirement(type);
if (tier != PatronTier.None)
{
eb.WithFooter(GetText(strs.xp_shop_buy_required_tier(tier.ToString())));
}
return Task.FromResult(eb);
},
async current =>
{
var (key, _) = items.Skip(current).First();
var itemType = type == XpShopInputType.Backgrounds
? XpShopItemType.Background
: XpShopItemType.Frame;
var ownedItem = await _service.GetUserItemAsync(ctx.User.Id, itemType, key);
if (ownedItem is not null)
{
var button = new ButtonBuilder(ownedItem.IsUsing
? GetText(strs.in_use)
: GetText(strs.use),
"xpshop:use",
emote: Emoji.Parse("👐"),
isDisabled: ownedItem.IsUsing);
var inter = new SimpleInteraction<(string key, XpShopItemType type)?>(
button,
OnShopUse,
(key, itemType));
return inter;
}
else
{
var button = new ButtonBuilder(GetText(strs.buy),
"xpshop:buy",
emote: Emoji.Parse("💰"));
var inter = new SimpleInteraction<(string key, XpShopItemType type)?>(
button,
OnShopBuy,
(key, itemType));
return inter;
}
},
items.Count,
1,
addPaginatedFooter: false);
}
[Cmd]
public async Task XpShopBuy(XpShopInputType type, string key)
{
var result = await _service.BuyShopItemAsync(ctx.User.Id, (XpShopItemType)type, key);
EllieInteraction GetUseInteraction()
{
return _inter.Create(ctx.User.Id,
new SimpleInteraction<object>(
new ButtonBuilder(label: "Use", customId: "xpshop:use_item", emote: Emoji.Parse("👐")),
async (smc, _) => await XpShopUse(type, key)
));
}
if (result != BuyResult.Success)
{
var _ = result switch
{
BuyResult.XpShopDisabled => await ReplyErrorLocalizedAsync(strs.xp_shop_disabled),
BuyResult.InsufficientFunds => await ReplyErrorLocalizedAsync(strs.not_enough(_gss.GetCurrencySign())),
BuyResult.AlreadyOwned =>
await ReplyErrorLocalizedAsync(strs.xpshop_already_owned, GetUseInteraction()),
BuyResult.UnknownItem => await ReplyErrorLocalizedAsync(strs.xpshop_item_not_found),
BuyResult.InsufficientPatronTier => await ReplyErrorLocalizedAsync(strs.patron_insuff_tier),
_ => throw new ArgumentOutOfRangeException()
};
return;
}
await ReplyConfirmLocalizedAsync(strs.xpshop_buy_success(type.ToString().ToLowerInvariant(),
key.ToLowerInvariant()),
GetUseInteraction());
}
[Cmd]
public async Task XpShopUse(XpShopInputType type, string key)
{
var result = await _service.UseShopItemAsync(ctx.User.Id, (XpShopItemType)type, key);
if (!result)
{
await ReplyConfirmLocalizedAsync(strs.xp_shop_item_cant_use);
return;
}
await ctx.OkAsync();
}
private async Task OnShopUse(SocketMessageComponent smc, (string? key, XpShopItemType type)? maybeState)
{
if (maybeState is not { } state)
return;
var (key, type) = state;
var result = await _service.UseShopItemAsync(ctx.User.Id, type, key);
if (!result)
{
await ReplyConfirmLocalizedAsync(strs.xp_shop_item_cant_use);
}
}
private async Task OnShopBuy(SocketMessageComponent smc, (string? key, XpShopItemType type)? maybeState)
{
if (maybeState is not { } state)
return;
var (key, type) = state;
var result = await _service.BuyShopItemAsync(ctx.User.Id, type, key);
if (result == BuyResult.InsufficientFunds)
{
await ReplyErrorLocalizedAsync(strs.not_enough(_gss.GetCurrencySign()));
}
}
private string GetNotifLocationString(XpNotificationLocation loc)
{
if (loc == XpNotificationLocation.Channel)
return GetText(strs.xpn_notif_channel);
if (loc == XpNotificationLocation.Dm)
return GetText(strs.xpn_notif_dm);
return GetText(strs.xpn_notif_disabled);
}
}

View file

@ -0,0 +1,109 @@
#nullable disable
using Cloneable;
using Ellie.Common.Yml;
using Ellie.Db.Models;
using Ellie.Modules.Patronage;
namespace Ellie.Modules.Xp;
[Cloneable]
public sealed partial class XpConfig : ICloneable<XpConfig>
{
[Comment("""DO NOT CHANGE""")]
public int Version { get; set; } = 5;
[Comment("""How much XP will the users receive per message""")]
public int XpPerMessage { get; set; } = 3;
[Comment("""How often can the users receive XP in minutes""")]
public int MessageXpCooldown { get; set; } = 5;
[Comment("""Amount of xp users gain from posting an image""")]
public int XpFromImage { get; set; } = 0;
[Comment("""Average amount of xp earned per minute in VC""")]
public double VoiceXpPerMinute { get; set; } = 0;
[Comment("""The maximum amount of minutes the bot will keep track of a user in a voice channel""")]
public int VoiceMaxMinutes { get; set; } = 720;
[Comment("""The amount of currency users will receive for each point of global xp that they earn""")]
public float CurrencyPerXp { get; set; } = 0;
[Comment("""Xp Shop config""")]
public ShopConfig Shop { get; set; } = new();
public sealed class ShopConfig
{
[Comment("""
Whether the xp shop is enabled
True -> Users can access the xp shop using .xpshop command
False -> Users can't access the xp shop
""")]
public bool IsEnabled { get; set; } = false;
[Comment("""
Which patron tier do users need in order to use the .xpshop bgs command
Leave at 'None' if patron system is disabled or you don't want any restrictions
""")]
public PatronTier BgsTierRequirement { get; set; } = PatronTier.None;
[Comment("""
Which patron tier do users need in order to use the .xpshop frames command
Leave at 'None' if patron system is disabled or you don't want any restrictions
""")]
public PatronTier FramesTierRequirement { get; set; } = PatronTier.None;
[Comment("""
Frames available for sale. Keys are unique IDs.
Do not change keys as they are not publicly visible. Only change properties (name, price, id)
Removing a key which previously existed means that all previous purchases will also be unusable.
To remove an item from the shop, but keep previous purchases, set the price to -1
""")]
public Dictionary<string, ShopItemInfo>? Frames { get; set; } = new()
{
{"default", new() {Name = "No frame", Price = 0, Url = string.Empty}}
};
[Comment("""
Backgrounds available for sale. Keys are unique IDs.
Do not change keys as they are not publicly visible. Only change properties (name, price, id)
Removing a key which previously existed means that all previous purchases will also be unusable.
To remove an item from the shop, but keep previous purchases, set the price to -1
""")]
public Dictionary<string, ShopItemInfo>? Bgs { get; set; } = new()
{
{"default", new() {Name = "Default Background", Price = 0, Url = string.Empty}}
};
}
public sealed class ShopItemInfo
{
[Comment("""Visible name of the item""")]
public string Name { get; set; }
[Comment("""Price of the item. Set to -1 if you no longer want to sell the item but want the users to be able to keep their old purchase""")]
public int Price { get; set; }
[Comment("""Direct url to the .png image which will be applied to the user's XP card""")]
public string Url { get; set; }
[Comment("""Optional preview url which will show instead of the real URL in the shop """)]
public string Preview { get; set; }
[Comment("""Optional description of the item""")]
public string Desc { get; set; }
}
}
public static class XpShopConfigExtensions
{
public static string? GetItemUrl(this XpConfig.ShopConfig sc, XpShopItemType type, string key)
=> (type switch
{
XpShopItemType.Background => sc.Bgs,
_ => sc.Frames
})?.TryGetValue(key, out var item) ?? false
? item.Url
: null;
}

View file

@ -0,0 +1,63 @@
#nullable disable
using Ellie.Common.Configs;
namespace Ellie.Modules.Xp.Services;
public sealed class XpConfigService : ConfigServiceBase<XpConfig>
{
private const string FILE_PATH = "data/xp.yml";
private static readonly TypedKey<XpConfig> _changeKey = new("config.xp.updated");
public override string Name
=> "xp";
public XpConfigService(IConfigSeria serializer, IPubSub pubSub)
: base(FILE_PATH, serializer, pubSub, _changeKey)
{
AddParsedProp("txt.cooldown",
conf => conf.MessageXpCooldown,
int.TryParse,
ConfigPrinters.ToString,
x => x > 0);
AddParsedProp("txt.per_msg", conf => conf.XpPerMessage, int.TryParse, ConfigPrinters.ToString, x => x >= 0);
AddParsedProp("txt.per_image", conf => conf.XpFromImage, int.TryParse, ConfigPrinters.ToString, x => x > 0);
AddParsedProp("voice.per_minute",
conf => conf.VoiceXpPerMinute,
double.TryParse,
ConfigPrinters.ToString,
x => x >= 0);
AddParsedProp("voice.max_minutes",
conf => conf.VoiceMaxMinutes,
int.TryParse,
ConfigPrinters.ToString,
x => x > 0);
AddParsedProp("shop.is_enabled",
conf => conf.Shop.IsEnabled,
bool.TryParse,
ConfigPrinters.ToString);
Migrate();
}
private void Migrate()
{
if (data.Version < 2)
{
ModifyConfig(c =>
{
c.Version = 2;
c.XpFromImage = 0;
});
}
if (data.Version < 6)
{
ModifyConfig(c =>
{
c.Version = 6;
});
}
}
}

View file

@ -0,0 +1,138 @@
using Ellie.Bot.Common;
using Ellie.Modules.Xp.Services;
namespace Ellie.Modules.Xp;
public partial class Xp
{
public partial class XpRewards : EllieModule<XpService>
{
private readonly ICurrencyProvider _cp;
public XpRewards(ICurrencyProvider cp)
=> _cp = cp;
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task XpRewsReset()
{
var promptEmbed = _eb.Create()
.WithPendingColor()
.WithDescription(GetText(strs.xprewsreset_confirm));
var reply = await PromptUserConfirmAsync(promptEmbed);
if (!reply)
return;
await _service.ResetXpRewards(ctx.Guild.Id);
await ctx.OkAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public Task XpLevelUpRewards(int page = 1)
{
page--;
if (page is < 0 or > 100)
return Task.CompletedTask;
var allRewards = _service.GetRoleRewards(ctx.Guild.Id)
.OrderBy(x => x.Level)
.Select(x =>
{
var sign = !x.Remove ? "✅ " : "❌ ";
var str = ctx.Guild.GetRole(x.RoleId)?.ToString();
if (str is null)
str = GetText(strs.role_not_found(Format.Code(x.RoleId.ToString())));
else
{
if (!x.Remove)
str = GetText(strs.xp_receive_role(Format.Bold(str)));
else
str = GetText(strs.xp_lose_role(Format.Bold(str)));
}
return (x.Level, Text: sign + str);
})
.Concat(_service.GetCurrencyRewards(ctx.Guild.Id)
.OrderBy(x => x.Level)
.Select(x => (x.Level,
Format.Bold(x.Amount + _cp.GetCurrencySign()))))
.GroupBy(x => x.Level)
.OrderBy(x => x.Key)
.ToList();
return Context.SendPaginatedConfirmAsync(page,
cur =>
{
var embed = _eb.Create().WithTitle(GetText(strs.level_up_rewards)).WithOkColor();
var localRewards = allRewards.Skip(cur * 9).Take(9).ToList();
if (!localRewards.Any())
return embed.WithDescription(GetText(strs.no_level_up_rewards));
foreach (var reward in localRewards)
embed.AddField(GetText(strs.level_x(reward.Key)),
string.Join("\n", reward.Select(y => y.Item2)));
return embed;
},
allRewards.Count,
9);
}
[Cmd]
[UserPerm(GuildPerm.Administrator)]
[BotPerm(GuildPerm.ManageRoles)]
[RequireContext(ContextType.Guild)]
[Priority(2)]
public async Task XpRoleReward(int level)
{
_service.ResetRoleReward(ctx.Guild.Id, level);
await ReplyConfirmLocalizedAsync(strs.xp_role_reward_cleared(level));
}
[Cmd]
[UserPerm(GuildPerm.Administrator)]
[BotPerm(GuildPerm.ManageRoles)]
[RequireContext(ContextType.Guild)]
[Priority(1)]
public async Task XpRoleReward(int level, AddRemove action, [Leftover] IRole role)
{
if (level < 1)
return;
_service.SetRoleReward(ctx.Guild.Id, level, role.Id, action == AddRemove.Remove);
if (action == AddRemove.Add)
await ReplyConfirmLocalizedAsync(strs.xp_role_reward_add_role(level, Format.Bold(role.ToString())));
else
{
await ReplyConfirmLocalizedAsync(strs.xp_role_reward_remove_role(Format.Bold(level.ToString()),
Format.Bold(role.ToString())));
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[OwnerOnly]
public async Task XpCurrencyReward(int level, int amount = 0)
{
if (level < 1 || amount < 0)
return;
// todo, shouldn't all amount + sign be N(amount)
_service.SetCurrencyReward(ctx.Guild.Id, level, amount);
if (amount == 0)
await ReplyConfirmLocalizedAsync(strs.cur_reward_cleared(level, _cp.GetCurrencySign()));
else
await ReplyConfirmLocalizedAsync(strs.cur_reward_added(level,
Format.Bold(amount + _cp.GetCurrencySign())));
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
#nullable disable
namespace Ellie.Modules.Xp.Extensions;
public static class Extensions
{
}

View file

@ -0,0 +1,32 @@
#nullable disable
using Ellie.Db;
using Ellie.Db.Models;
using Ellie.Services.Database.Models;
namespace Ellie.Modules.Xp;
public class FullUserStats
{
public DiscordUser User { get; }
public UserXpStats FullGuildStats { get; }
public LevelStats Global { get; }
public LevelStats Guild { get; }
public int GlobalRanking { get; }
public int GuildRanking { get; }
public FullUserStats(
DiscordUser user,
UserXpStats fullGuildStats,
LevelStats global,
LevelStats guild,
int globalRanking,
int guildRanking)
{
User = user;
Global = global;
Guild = guild;
GlobalRanking = globalRanking;
GuildRanking = guildRanking;
FullGuildStats = fullGuildStats;
}
}

View file

@ -0,0 +1,6 @@
namespace Ellie.Modules.Xp;
public interface IXpCleanupService
{
Task DeleteXp();
}

View file

@ -0,0 +1,13 @@
#nullable disable warnings
using Cloneable;
namespace Ellie.Modules.Xp.Services;
[Cloneable]
public sealed partial class UserCacheItem : ICloneable<UserXpGainData>
{
public IGuildUser User { get; init; }
public IGuild Guild { get; init; }
public IMessageChannel Channel { get; init; }
public int XpAmount { get; set; }
}

View file

@ -0,0 +1,32 @@
using LinqToDB;
using Ellie.Db.Models;
using Ellie.Services.Database.Models;
namespace Ellie.Modules.Xp;
public sealed class XpCleanupService : IXpCleanupService
{
private readonly DbService _db;
public XpCleanupService(DbService db)
{
_db = db;
}
public async Task DeleteXp()
{
await using var uow = _db.GetDbContext();
await uow.Set<DiscordUser>().UpdateAsync(_ => new DiscordUser()
{
ClubId = null,
// IsClubAdmin = false
TotalXp = 0
});
await uow.Set<UserXpStats>().DeleteAsync();
await uow.Set<ClubApplicants>().DeleteAsync();
await uow.Set<ClubBans>().DeleteAsync();
await uow.Set<ClubInfo>().DeleteAsync();
await uow.SaveChangesAsync();
}
}

View file

@ -0,0 +1,271 @@
#nullable disable
using Newtonsoft.Json;
using SixLabors.ImageSharp.PixelFormats;
using Color = SixLabors.ImageSharp.Color;
namespace Ellie.Modules.Xp;
public class XpTemplate
{
public int Version { get; set; } = 0;
[JsonProperty("output_size")]
public XpTemplatePos OutputSize { get; set; } = new()
{
X = 800,
Y = 392
};
public XpTemplateUser User { get; set; } = new()
{
Name = new()
{
FontSize = 50,
Show = true,
Pos = new()
{
X = 130,
Y = 17
}
},
Icon = new()
{
Show = true,
Pos = new()
{
X = 14,
Y = 14
},
Size = new()
{
X = 72,
Y = 71
}
},
GuildLevel = new()
{
Show = true,
FontSize = 45,
Pos = new()
{
X = 47,
Y = 308
}
},
GlobalLevel = new()
{
Show = true,
FontSize = 45,
Pos = new()
{
X = 47,
Y = 160
}
},
GuildRank = new()
{
Show = true,
FontSize = 30,
Pos = new()
{
X = 148,
Y = 326
}
},
GlobalRank = new()
{
Show = true,
FontSize = 30,
Pos = new()
{
X = 148,
Y = 179
}
},
Xp = new()
{
Bar = new()
{
Show = true,
Global = new()
{
Direction = XpTemplateDirection.Right,
Length = 450,
Color = new(0, 0, 0, 0.4f),
PointA = new()
{
X = 321,
Y = 104
},
PointB = new()
{
X = 286,
Y = 235
}
},
Guild = new()
{
Direction = XpTemplateDirection.Right,
Length = 450,
Color = new(0, 0, 0, 0.4f),
PointA = new()
{
X = 282,
Y = 248
},
PointB = new()
{
X = 247,
Y = 379
}
}
},
Global = new()
{
Show = true,
FontSize = 50,
Pos = new()
{
X = 528,
Y = 170
}
},
Guild = new()
{
Show = true,
FontSize = 50,
Pos = new()
{
X = 490,
Y = 313
}
},
Awarded = new()
{
Show = true,
FontSize = 25,
Pos = new()
{
X = 490,
Y = 345
}
}
}
};
public XpTemplateClub Club { get; set; } = new()
{
Icon = new()
{
Show = true,
Pos = new()
{
X = 722,
Y = 25
},
Size = new()
{
X = 45,
Y = 45
}
},
Name = new()
{
FontSize = 35,
Pos = new()
{
X = 650,
Y = 49
},
Show = true
}
};
}
public class XpTemplateIcon
{
public bool Show { get; set; }
public XpTemplatePos Pos { get; set; }
public XpTemplatePos Size { get; set; }
}
public class XpTemplatePos
{
public int X { get; set; }
public int Y { get; set; }
}
public class XpTemplateUser
{
public XpTemplateText Name { get; set; }
public XpTemplateIcon Icon { get; set; }
public XpTemplateText GlobalLevel { get; set; }
public XpTemplateText GuildLevel { get; set; }
public XpTemplateText GlobalRank { get; set; }
public XpTemplateText GuildRank { get; set; }
public XpTemplateXp Xp { get; set; }
}
public class XpTemplateClub
{
public XpTemplateIcon Icon { get; set; }
public XpTemplateText Name { get; set; }
}
public class XpTemplateText
{
[JsonConverter(typeof(XpRgba32Converter))]
public Rgba32 Color { get; set; } = SixLabors.ImageSharp.Color.White;
public bool Show { get; set; }
public int FontSize { get; set; }
public XpTemplatePos Pos { get; set; }
}
public class XpTemplateXp
{
public XpTemplateXpBar Bar { get; set; }
public XpTemplateText Global { get; set; }
public XpTemplateText Guild { get; set; }
public XpTemplateText Awarded { get; set; }
}
public class XpTemplateXpBar
{
public bool Show { get; set; }
public XpBar Global { get; set; }
public XpBar Guild { get; set; }
}
public class XpBar
{
[JsonConverter(typeof(XpRgba32Converter))]
public Rgba32 Color { get; set; }
public XpTemplatePos PointA { get; set; }
public XpTemplatePos PointB { get; set; }
public int Length { get; set; }
public XpTemplateDirection Direction { get; set; }
}
public enum XpTemplateDirection
{
Up,
Down,
Left,
Right
}
public class XpRgba32Converter : JsonConverter<Rgba32>
{
public override Rgba32 ReadJson(
JsonReader reader,
Type objectType,
Rgba32 existingValue,
bool hasExistingValue,
JsonSerializer serializer)
=> Color.ParseHex(reader.Value?.ToString());
public override void WriteJson(JsonWriter writer, Rgba32 value, JsonSerializer serializer)
=> writer.WriteValue(value.ToHex().ToLowerInvariant());
}

View file

@ -0,0 +1,18 @@
#nullable disable
using Ellie.Services.Database.Models;
namespace Ellie.Db.Models;
public class XpShopOwnedItem : DbEntity
{
public ulong UserId { get; set; }
public XpShopItemType ItemType { get; set; }
public bool IsUsing { get; set; }
public string ItemKey { get; set; }
}
public enum XpShopItemType
{
Background = 0,
Frame = 1,
}