mirror of
https://gitlab.com/Kwoth/nadekobot.git
synced 2025-09-11 17:58:26 -04:00
Finished implementing xp shop. It allows users to buy frames and backgrounds if the user enables the feature in data/xp.yml. It can also be available only to patrons
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
#nullable disable
|
||||
#nullable disable warnings
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
using NadekoBot.Modules.Xp.Services;
|
||||
using NadekoBot.Services.Database.Models;
|
||||
using System.Diagnostics;
|
||||
using Nadeko.Common;
|
||||
using System.Globalization;
|
||||
using NadekoBot.Db.Models;
|
||||
using NadekoBot.Modules.Utility.Patronage;
|
||||
|
||||
namespace NadekoBot.Modules.Xp;
|
||||
|
||||
@@ -313,6 +314,154 @@ public partial class Xp : NadekoModule<XpService>
|
||||
await ReplyConfirmLocalizedAsync(strs.reset_server);
|
||||
}
|
||||
|
||||
public enum XpShopInputType
|
||||
{
|
||||
F = 0,
|
||||
Frs = 0,
|
||||
Fs = 0,
|
||||
Frames = 0,
|
||||
B = 1,
|
||||
Bg = 1,
|
||||
Bgs = 1,
|
||||
Backgrounds = 1
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task XpShop()
|
||||
{
|
||||
await SendConfirmAsync(GetText(strs.available_commands), $@"`{prefix}xpshop bgs`
|
||||
`{prefix}xpshop frames`");
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
|
||||
var culture = (CultureInfo)Culture.Clone();
|
||||
culture.NumberFormat.CurrencySymbol = _gss.Data.Currency.Sign;
|
||||
culture.NumberFormat.CurrencyNegativePattern = 5;
|
||||
|
||||
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), Gambling.Gambling.N(item.Price, culture), true)
|
||||
// .AddField(GetText(strs.buy), $"{prefix}xpbuy {key}", true)
|
||||
.WithImageUrl(item.Url.ToString());
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.Desc))
|
||||
eb.WithDescription(item.Desc);
|
||||
|
||||
var tier = _service.GetXpShopTierRequirement();
|
||||
if (tier != PatronTier.None)
|
||||
{
|
||||
eb.WithFooter(GetText(strs.feature_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),
|
||||
"XP_SHOP_USE",
|
||||
ButtonStyle.Primary,
|
||||
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),
|
||||
"XP_SHOP_BUY",
|
||||
ButtonStyle.Primary,
|
||||
emote: Emoji.Parse("💰"));
|
||||
|
||||
var inter = new SimpleInteraction<(string key, XpShopItemType type)?>(
|
||||
button,
|
||||
OnShopBuy,
|
||||
(key, itemType));
|
||||
|
||||
return inter;
|
||||
}
|
||||
},
|
||||
items.Count,
|
||||
1,
|
||||
addPaginatedFooter: false);
|
||||
}
|
||||
|
||||
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.Data.Currency.Sign));
|
||||
}
|
||||
}
|
||||
|
||||
private string GetNotifLocationString(XpNotificationLocation loc)
|
||||
{
|
||||
if (loc == XpNotificationLocation.Channel)
|
||||
@@ -323,4 +472,23 @@ public partial class Xp : NadekoModule<XpService>
|
||||
|
||||
return GetText(strs.xpn_notif_disabled);
|
||||
}
|
||||
}
|
||||
|
||||
public class SimpleInteraction<T>
|
||||
{
|
||||
public ButtonBuilder Button { get; }
|
||||
private readonly Func<SocketMessageComponent, T, Task> _onClick;
|
||||
private readonly T? _state;
|
||||
|
||||
public SimpleInteraction(ButtonBuilder button, Func<SocketMessageComponent, T?, Task> onClick, T state = default)
|
||||
{
|
||||
Button = button;
|
||||
_onClick = onClick;
|
||||
_state = state;
|
||||
}
|
||||
|
||||
public async Task TriggerAsync(SocketMessageComponent smc)
|
||||
{
|
||||
await _onClick(smc, _state);
|
||||
}
|
||||
}
|
@@ -1,6 +1,8 @@
|
||||
#nullable disable
|
||||
#nullable disable warnings
|
||||
using Cloneable;
|
||||
using NadekoBot.Common.Yml;
|
||||
using NadekoBot.Db.Models;
|
||||
using NadekoBot.Modules.Utility.Patronage;
|
||||
|
||||
namespace NadekoBot.Modules.Xp;
|
||||
|
||||
@@ -8,7 +10,7 @@ namespace NadekoBot.Modules.Xp;
|
||||
public sealed partial class XpConfig : ICloneable<XpConfig>
|
||||
{
|
||||
[Comment(@"DO NOT CHANGE")]
|
||||
public int Version { get; set; } = 2;
|
||||
public int Version { get; set; } = 3;
|
||||
|
||||
[Comment(@"How much XP will the users receive per message")]
|
||||
public int XpPerMessage { get; set; } = 3;
|
||||
@@ -24,4 +26,64 @@ public sealed partial class XpConfig : ICloneable<XpConfig>
|
||||
|
||||
[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(@"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 command
|
||||
Leave at 'None' if patron system is disabled or you don't want any restrictions")]
|
||||
public PatronTier TierRequirement { 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 compeltely means 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 compeltely means 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 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;
|
||||
}
|
@@ -46,5 +46,13 @@ public sealed class XpConfigService : ConfigServiceBase<XpConfig>
|
||||
c.XpFromImage = 0;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.Version < 3)
|
||||
{
|
||||
ModifyConfig(c =>
|
||||
{
|
||||
c.Version = 3;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@@ -13,7 +13,9 @@ using SixLabors.ImageSharp.Formats;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using System.Threading.Channels;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using Color = SixLabors.ImageSharp.Color;
|
||||
using Exception = System.Exception;
|
||||
using Image = SixLabors.ImageSharp.Image;
|
||||
|
||||
namespace NadekoBot.Modules.Xp.Services;
|
||||
@@ -1124,21 +1126,48 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
return output;
|
||||
});
|
||||
|
||||
private async Task<byte[]?> GetXpBackgroundAsync(ulong _)
|
||||
private async Task<byte[]?> GetXpBackgroundAsync(ulong userId)
|
||||
{
|
||||
var img = await _images.GetXpBackgroundImageAsync();
|
||||
return img;
|
||||
var item = await GetItemInUse(userId, XpShopItemType.Background);
|
||||
if (item is null)
|
||||
{
|
||||
return await _images.GetXpBackgroundImageAsync();
|
||||
}
|
||||
|
||||
var url = _xpConfig.Data.Shop.GetItemUrl(XpShopItemType.Background, item.ItemKey);
|
||||
if (!string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
var data = await _images.GetImageDataAsync(new Uri(url));
|
||||
return data;
|
||||
}
|
||||
|
||||
return await _images.GetXpBackgroundImageAsync();
|
||||
}
|
||||
|
||||
// #if GLOBAL_NADEKO
|
||||
private async Task DrawFrame(Image<Rgba32> img, ulong userId)
|
||||
{
|
||||
var patron = await _ps.GetPatronAsync(userId);
|
||||
|
||||
var item = await GetItemInUse(userId, XpShopItemType.Frame);
|
||||
|
||||
Image? frame = null;
|
||||
if (patron.Tier == PatronTier.V)
|
||||
frame = Image.Load<Rgba32>(File.OpenRead("data/images/frame_silver.png"));
|
||||
else if (patron.Tier >= PatronTier.X || _creds.IsOwner(userId))
|
||||
frame = Image.Load<Rgba32>(File.OpenRead("data/images/frame_gold.png"));
|
||||
if (item is null)
|
||||
{
|
||||
if (patron.Tier == PatronTier.V)
|
||||
frame = Image.Load<Rgba32>(File.OpenRead("data/images/frame_silver.png"));
|
||||
else if (patron.Tier >= PatronTier.X || _creds.IsOwner(userId))
|
||||
frame = Image.Load<Rgba32>(File.OpenRead("data/images/frame_gold.png"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var url = _xpConfig.Data.Shop.GetItemUrl(XpShopItemType.Frame, item.ItemKey);
|
||||
if (!string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
var data = await _images.GetImageDataAsync(new Uri(url));
|
||||
frame = Image.Load<Rgba32>(data);
|
||||
}
|
||||
}
|
||||
|
||||
if (frame is not null)
|
||||
img.Mutate(x => x.DrawImage(frame, new Point(0, 0), new GraphicsOptions()));
|
||||
@@ -1270,4 +1299,171 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
uow.RemoveRange(guildConfig.XpSettings.CurrencyRewards);
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public ValueTask<Dictionary<string, XpConfig.ShopItemInfo>?> GetShopBgs()
|
||||
{
|
||||
var data = _xpConfig.Data;
|
||||
if (!data.Shop.IsEnabled)
|
||||
return new(default(Dictionary<string, XpConfig.ShopItemInfo>));
|
||||
|
||||
return new(_xpConfig.Data.Shop.Bgs?.Where(x => x.Value.Price >= 0).ToDictionary(x => x.Key, x => x.Value));
|
||||
}
|
||||
|
||||
public ValueTask<Dictionary<string, XpConfig.ShopItemInfo>?> GetShopFrames()
|
||||
{
|
||||
var data = _xpConfig.Data;
|
||||
if (!data.Shop.IsEnabled)
|
||||
return new(default(Dictionary<string, XpConfig.ShopItemInfo>));
|
||||
|
||||
return new(_xpConfig.Data.Shop.Frames?.Where(x => x.Value.Price >= 0).ToDictionary(x => x.Key, x => x.Value));
|
||||
}
|
||||
|
||||
public async Task<BuyResult> BuyShopItemAsync(ulong userId, XpShopItemType type, string key)
|
||||
{
|
||||
var conf = _xpConfig.Data;
|
||||
|
||||
if (!conf.Shop.IsEnabled)
|
||||
return BuyResult.UnknownItem;
|
||||
|
||||
if (conf.Shop.TierRequirement != PatronTier.None)
|
||||
{
|
||||
var patron = await _ps.GetPatronAsync(userId);
|
||||
|
||||
if ((int)patron.Tier < (int)conf.Shop.TierRequirement)
|
||||
return BuyResult.InsufficientPatronTier;
|
||||
}
|
||||
|
||||
await using var ctx = _db.GetDbContext();
|
||||
// await using var tran = await ctx.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
if (await ctx.GetTable<XpShopOwnedItem>().AnyAsyncLinqToDB(x => x.ItemKey == key && x.ItemType == type))
|
||||
return BuyResult.AlreadyOwned;
|
||||
|
||||
var item = GetShopItem(type, key);
|
||||
|
||||
if (item is null || item.Price < 0)
|
||||
return BuyResult.UnknownItem;
|
||||
|
||||
if (item.Price > 0 && !await _cs.RemoveAsync(userId, item.Price, new("xpshop", "buy", $"Background {key}")))
|
||||
return BuyResult.InsufficientFunds;
|
||||
|
||||
|
||||
await ctx.GetTable<XpShopOwnedItem>()
|
||||
.InsertAsync(() => new XpShopOwnedItem()
|
||||
{
|
||||
UserId = userId,
|
||||
IsUsing = false,
|
||||
ItemKey = key,
|
||||
ItemType = type
|
||||
});
|
||||
|
||||
return BuyResult.Success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error buying shop item: {ErrorMessage}", ex.Message);
|
||||
return BuyResult.UnknownItem;
|
||||
}
|
||||
}
|
||||
|
||||
private XpConfig.ShopItemInfo? GetShopItem(XpShopItemType type, string key)
|
||||
{
|
||||
var data = _xpConfig.Data;
|
||||
if (type == XpShopItemType.Background)
|
||||
{
|
||||
if (data.Shop.Bgs is {} bgs && bgs.TryGetValue(key, out var item))
|
||||
return item;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type == XpShopItemType.Frame)
|
||||
{
|
||||
if (data.Shop.Frames is {} fs && fs.TryGetValue(key, out var item))
|
||||
return item;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new ArgumentOutOfRangeException(nameof(type));
|
||||
}
|
||||
|
||||
public async Task<bool> OwnsItemAsync(ulong userId,
|
||||
XpShopItemType itemType,
|
||||
string key)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
return await ctx.GetTable<XpShopOwnedItem>()
|
||||
.AnyAsyncLinqToDB(x => x.UserId == userId
|
||||
&& x.ItemType == itemType
|
||||
&& x.ItemKey == key);
|
||||
}
|
||||
|
||||
|
||||
public async Task<XpShopOwnedItem?> GetUserItemAsync(ulong userId,
|
||||
XpShopItemType itemType,
|
||||
string key)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
return await ctx.GetTable<XpShopOwnedItem>()
|
||||
.FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId
|
||||
&& x.ItemType == itemType
|
||||
&& x.ItemKey == key);
|
||||
}
|
||||
|
||||
public async Task<XpShopOwnedItem?> GetItemInUse(ulong userId,
|
||||
XpShopItemType itemType)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
return await ctx.GetTable<XpShopOwnedItem>()
|
||||
.FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId
|
||||
&& x.ItemType == itemType
|
||||
&& x.IsUsing);
|
||||
}
|
||||
|
||||
public async Task<bool> UseShopItemAsync(ulong userId, XpShopItemType itemType, string key)
|
||||
{
|
||||
var data = _xpConfig.Data;
|
||||
XpConfig.ShopItemInfo? item = null;
|
||||
if (itemType == XpShopItemType.Background)
|
||||
{
|
||||
data.Shop.Bgs?.TryGetValue(key, out item);
|
||||
}
|
||||
else
|
||||
{
|
||||
data.Shop.Frames?.TryGetValue(key, out item);
|
||||
}
|
||||
|
||||
if (item is null)
|
||||
return false;
|
||||
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
if (await OwnsItemAsync(userId, itemType, key))
|
||||
{
|
||||
await ctx.GetTable<XpShopOwnedItem>()
|
||||
.Where(x => x.UserId == userId && x.ItemType == itemType)
|
||||
.UpdateAsync(old => new()
|
||||
{
|
||||
IsUsing = key == old.ItemKey
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public PatronTier GetXpShopTierRequirement()
|
||||
=> _xpConfig.Data.Shop.TierRequirement;
|
||||
}
|
||||
|
||||
public enum BuyResult
|
||||
{
|
||||
Success,
|
||||
AlreadyOwned,
|
||||
InsufficientFunds,
|
||||
UnknownItem,
|
||||
InsufficientPatronTier,
|
||||
}
|
Reference in New Issue
Block a user