From d090aa23eea74246e09b0e2efc4ac76da2136780 Mon Sep 17 00:00:00 2001 From: Kwoth Date: Wed, 3 Nov 2021 14:22:51 +0000 Subject: [PATCH] Slots redesign nad images moved to images.yml --- src/NadekoBot/Bot.cs | 3 +- src/NadekoBot/Common/ImageUrls.cs | 2 + src/NadekoBot/Common/OldImageUrls.cs | 49 +++ .../CustomReactions/CustomReactions.cs | 2 +- .../Modules/Gambling/Common/GamblingError.cs | 8 + .../Modules/Gambling/Common/Slot/SlotGame.cs | 44 +++ .../Modules/Gambling/Common/SlotResponse.cs | 12 + .../Modules/Gambling/FlipCoinCommands.cs | 5 +- .../Gambling/Services/GamblingService.cs | 37 +++ .../Modules/Gambling/SlotCommands.cs | 195 ++++++----- .../Modules/Utility/QuoteCommands.cs | 4 +- src/NadekoBot/Services/Common/ImageLoader.cs | 141 -------- .../Services/Common/RedisImageArray.cs | 52 --- src/NadekoBot/Services/Impl/FontProvider.cs | 4 + src/NadekoBot/Services/Impl/ImagesService.cs | 181 ---------- .../Services/Impl/RedisImageExtensions.cs | 13 + .../Services/Impl/RedisImagesCache.cs | 312 ++++++++++++++++++ src/NadekoBot/data/fonts/dotty.ttf | Bin 0 -> 51848 bytes src/NadekoBot/data/images.json | 63 ---- src/NadekoBot/data/images.yml | 50 +++ src/NadekoBot/data/images_backup.json | 62 ---- 21 files changed, 649 insertions(+), 590 deletions(-) create mode 100644 src/NadekoBot/Common/OldImageUrls.cs create mode 100644 src/NadekoBot/Modules/Gambling/Common/GamblingError.cs create mode 100644 src/NadekoBot/Modules/Gambling/Common/Slot/SlotGame.cs create mode 100644 src/NadekoBot/Modules/Gambling/Common/SlotResponse.cs delete mode 100644 src/NadekoBot/Services/Common/ImageLoader.cs delete mode 100644 src/NadekoBot/Services/Common/RedisImageArray.cs delete mode 100644 src/NadekoBot/Services/Impl/ImagesService.cs create mode 100644 src/NadekoBot/Services/Impl/RedisImageExtensions.cs create mode 100644 src/NadekoBot/Services/Impl/RedisImagesCache.cs create mode 100644 src/NadekoBot/data/fonts/dotty.ttf delete mode 100644 src/NadekoBot/data/images.json create mode 100644 src/NadekoBot/data/images.yml delete mode 100644 src/NadekoBot/data/images_backup.json diff --git a/src/NadekoBot/Bot.cs b/src/NadekoBot/Bot.cs index cfb8518e1..7d92cc5ac 100644 --- a/src/NadekoBot/Bot.cs +++ b/src/NadekoBot/Bot.cs @@ -145,7 +145,8 @@ namespace NadekoBot svcs.Scan(scan => scan .FromAssemblyOf() - .AddClasses(classes => classes.AssignableToAny( + .AddClasses(classes => classes + .AssignableToAny( // services typeof(INService), diff --git a/src/NadekoBot/Common/ImageUrls.cs b/src/NadekoBot/Common/ImageUrls.cs index 72061cfde..f10f2acaf 100644 --- a/src/NadekoBot/Common/ImageUrls.cs +++ b/src/NadekoBot/Common/ImageUrls.cs @@ -1,9 +1,11 @@ using System; +using NadekoBot.Common.Yml; namespace NadekoBot.Common { public class ImageUrls { + [Comment("DO NOT CHANGE")] public int Version { get; set; } = 2; public CoinData Coins { get; set; } diff --git a/src/NadekoBot/Common/OldImageUrls.cs b/src/NadekoBot/Common/OldImageUrls.cs new file mode 100644 index 000000000..b98224df5 --- /dev/null +++ b/src/NadekoBot/Common/OldImageUrls.cs @@ -0,0 +1,49 @@ +using System; + +namespace NadekoBot.Common +{ + public class OldImageUrls + { + public int Version { get; set; } = 2; + + public CoinData Coins { get; set; } + public Uri[] Currency { get; set; } + public Uri[] Dice { get; set; } + public RategirlData Rategirl { get; set; } + public XpData Xp { get; set; } + + //new + public RipData Rip { get; set; } + public SlotData Slots { get; set; } + + public class RipData + { + public Uri Bg { get; set; } + public Uri Overlay { get; set; } + } + + public class SlotData + { + public Uri[] Emojis { get; set; } + public Uri[] Numbers { get; set; } + public Uri Bg { get; set; } + } + + public class CoinData + { + public Uri[] Heads { get; set; } + public Uri[] Tails { get; set; } + } + + public class RategirlData + { + public Uri Matrix { get; set; } + public Uri Dot { get; set; } + } + + public class XpData + { + public Uri Bg { get; set; } + } + } +} \ No newline at end of file diff --git a/src/NadekoBot/Modules/CustomReactions/CustomReactions.cs b/src/NadekoBot/Modules/CustomReactions/CustomReactions.cs index e9a23903d..dfda73b75 100644 --- a/src/NadekoBot/Modules/CustomReactions/CustomReactions.cs +++ b/src/NadekoBot/Modules/CustomReactions/CustomReactions.cs @@ -149,7 +149,7 @@ namespace NadekoBot.Modules.CustomReactions await ctx.Channel.EmbedAsync(_eb.Create().WithOkColor() .WithDescription($"#{id}") .AddField(GetText(strs.trigger), found.Trigger.TrimTo(1024)) - .AddField(GetText(strs.response), (found.Response + "\n```css\n" + found.Response).TrimTo(1020) + "```") + .AddField(GetText(strs.response), found.Response.TrimTo(1000).Replace("](", "]\\(")) ).ConfigureAwait(false); } } diff --git a/src/NadekoBot/Modules/Gambling/Common/GamblingError.cs b/src/NadekoBot/Modules/Gambling/Common/GamblingError.cs new file mode 100644 index 000000000..4732cb67e --- /dev/null +++ b/src/NadekoBot/Modules/Gambling/Common/GamblingError.cs @@ -0,0 +1,8 @@ +namespace NadekoBot.Modules.Gambling +{ + public enum GamblingError + { + None, + NotEnough + } +} \ No newline at end of file diff --git a/src/NadekoBot/Modules/Gambling/Common/Slot/SlotGame.cs b/src/NadekoBot/Modules/Gambling/Common/Slot/SlotGame.cs new file mode 100644 index 000000000..2140b640b --- /dev/null +++ b/src/NadekoBot/Modules/Gambling/Common/Slot/SlotGame.cs @@ -0,0 +1,44 @@ +using System; +using System.Linq; +using NadekoBot.Common; + +namespace NadekoBot.Modules.Gambling.Common.Slot +{ + public class SlotGame + { + public class Result + { + public float Multiplier { get; } + public int[] Rolls { get; } + + public Result(float multiplier, int[] rolls) + { + Multiplier = multiplier; + Rolls = rolls; + } + } + + private static readonly Random _rng = new NadekoRandom(); + + public SlotGame() + { + } + + public Result Spin() + { + var rolls = new int[] { _rng.Next(0, 6), _rng.Next(0, 6), _rng.Next(0, 6) }; + var multi = 0; + + if (rolls.All(x => x == 5)) + multi = 30; + else if (rolls.All(x => x == rolls[0])) + multi = 10; + else if (rolls.Count(x => x == 5) == 2) + multi = 4; + else if (rolls.Any(x => x == 5)) + multi = 1; + + return new Result(multi, rolls); + } + } +} \ No newline at end of file diff --git a/src/NadekoBot/Modules/Gambling/Common/SlotResponse.cs b/src/NadekoBot/Modules/Gambling/Common/SlotResponse.cs new file mode 100644 index 000000000..a9ecfaafe --- /dev/null +++ b/src/NadekoBot/Modules/Gambling/Common/SlotResponse.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace NadekoBot.Modules.Gambling +{ + public class SlotResponse + { + public float Multiplier { get; set; } + public long Won { get; set; } + public List Rolls { get; set; } = new List(); + public GamblingError Error { get; set; } + } +} \ No newline at end of file diff --git a/src/NadekoBot/Modules/Gambling/FlipCoinCommands.cs b/src/NadekoBot/Modules/Gambling/FlipCoinCommands.cs index 250722e64..28b39912d 100644 --- a/src/NadekoBot/Modules/Gambling/FlipCoinCommands.cs +++ b/src/NadekoBot/Modules/Gambling/FlipCoinCommands.cs @@ -22,15 +22,12 @@ namespace NadekoBot.Modules.Gambling { private readonly IImageCache _images; private readonly ICurrencyService _cs; - private readonly DbService _db; private static readonly NadekoRandom rng = new NadekoRandom(); - public FlipCoinCommands(IDataCache data, ICurrencyService cs, DbService db, - GamblingConfigService gss) : base(gss) + public FlipCoinCommands(IDataCache data, ICurrencyService cs, GamblingConfigService gss) : base(gss) { _images = data.LocalImages; _cs = cs; - _db = db; } [NadekoCommand, Aliases] diff --git a/src/NadekoBot/Modules/Gambling/Services/GamblingService.cs b/src/NadekoBot/Modules/Gambling/Services/GamblingService.cs index 7d62ea0b1..8286f3599 100644 --- a/src/NadekoBot/Modules/Gambling/Services/GamblingService.cs +++ b/src/NadekoBot/Modules/Gambling/Services/GamblingService.cs @@ -6,12 +6,14 @@ using NadekoBot.Modules.Gambling.Common.WheelOfFortune; using Newtonsoft.Json; using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using NadekoBot.Common; using NadekoBot.Db; +using NadekoBot.Modules.Gambling.Common.Slot; using NadekoBot.Modules.Gambling.Services; using Serilog; @@ -82,6 +84,41 @@ WHERE CurrencyAmount > {config.Decay.MinThreshold} AND UserId!={_client.CurrentU }, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); } } + + public async Task SlotAsync(ulong userId, long amount) + { + var takeRes = await _cs.RemoveAsync(userId, "Slot Machine", amount, true); + + if (!takeRes) + { + return new SlotResponse + { + Error = GamblingError.NotEnough + }; + } + + var game = new SlotGame(); + var result = game.Spin(); + long won = 0; + + if (result.Multiplier > 0) + { + won = (long)(result.Multiplier * amount); + + await _cs.AddAsync(userId, $"Slot Machine x{result.Multiplier}", won, true); + } + + var toReturn = new SlotResponse + { + Multiplier = result.Multiplier, + Won = won, + }; + + toReturn.Rolls.AddRange(result.Rolls); + + return toReturn; + } + public struct EconomyResult { diff --git a/src/NadekoBot/Modules/Gambling/SlotCommands.cs b/src/NadekoBot/Modules/Gambling/SlotCommands.cs index 4c0f0fde5..cf525f7c8 100644 --- a/src/NadekoBot/Modules/Gambling/SlotCommands.cs +++ b/src/NadekoBot/Modules/Gambling/SlotCommands.cs @@ -9,12 +9,16 @@ using System.Threading; using System.Threading.Tasks; using NadekoBot.Common; using NadekoBot.Common.Attributes; +using NadekoBot.Db.Models; using NadekoBot.Modules.Gambling.Services; using NadekoBot.Modules.Gambling.Common; using NadekoBot.Services; +using SixLabors.Fonts; using Image = SixLabors.ImageSharp.Image; using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; namespace NadekoBot.Modules.Gambling { @@ -33,12 +37,16 @@ namespace NadekoBot.Modules.Gambling //thanks to judge for helping me with this private readonly IImageCache _images; - private readonly ICurrencyService _cs; + private FontProvider _fonts; + private readonly DbService _db; - public SlotCommands(IDataCache data, ICurrencyService cs, GamblingConfigService gamb) : base(gamb) + public SlotCommands(IDataCache data, + FontProvider fonts, DbService db, + GamblingConfigService gamb) : base(gamb) { _images = data.LocalImages; - _cs = cs; + _fonts = fonts; + _db = db; } public sealed class SlotMachine @@ -140,92 +148,115 @@ namespace NadekoBot.Modules.Gambling [NadekoCommand, Aliases] public async Task Slot(ShmartNumber amount) { - if (!_runningUsers.Add(ctx.User.Id)) + if (!_runningUsers.Add(ctx.User.Id)) return; - try - { - if (!await CheckBetMandatory(amount).ConfigureAwait(false)) - return; - const int maxAmount = 9999; - if (amount > maxAmount) - { - await ReplyErrorLocalizedAsync(strs.max_bet_limit(maxAmount + CurrencySign)); - return; - } + + try + { + if (!await CheckBetMandatory(amount).ConfigureAwait(false)) + return; + + await ctx.Channel.TriggerTypingAsync().ConfigureAwait(false); - if (!await _cs.RemoveAsync(ctx.User, "Slot Machine", amount, false, gamble: true).ConfigureAwait(false)) - { - await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); - return; - } - Interlocked.Add(ref _totalBet, amount.Value); - using (var bgImage = Image.Load(_images.SlotBackground)) - { - var result = SlotMachine.Pull(); - int[] numbers = result.Numbers; + var result = await _service.SlotAsync(ctx.User.Id, amount); - for (int i = 0; i < 3; i++) - { - using (var randomImage = Image.Load(_images.SlotEmojis[numbers[i]])) - { - bgImage.Mutate(x => x.DrawImage(randomImage, new Point(95 + 142 * i, 330), new GraphicsOptions())); - } - } + if (result.Error != GamblingError.None) + { + if (result.Error == GamblingError.NotEnough) + { + await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); + } - var won = amount * result.Multiplier; - var printWon = won; - var n = 0; - do - { - var digit = (int)(printWon % 10); - using (var img = Image.Load(_images.SlotNumbers[digit])) - { - bgImage.Mutate(x => x.DrawImage(img, new Point(230 - n * 16, 462), new GraphicsOptions())); - } - n++; - } while ((printWon /= 10) != 0); + return; + } - var printAmount = amount; - n = 0; - do - { - var digit = (int)(printAmount % 10); - using (var img = Image.Load(_images.SlotNumbers[digit])) - { - bgImage.Mutate(x => x.DrawImage(img, new Point(395 - n * 16, 462), new GraphicsOptions())); - } - n++; - } while ((printAmount /= 10) != 0); + Interlocked.Add(ref _totalBet, amount); + Interlocked.Add(ref _totalPaidOut, result.Won); - var msg = GetText(strs.better_luck); - if (result.Multiplier != 0) - { - await _cs.AddAsync(ctx.User, $"Slot Machine x{result.Multiplier}", amount * result.Multiplier, false, gamble: true).ConfigureAwait(false); - Interlocked.Add(ref _totalPaidOut, amount * result.Multiplier); - if (result.Multiplier == 1) - msg = GetText(strs.slot_single(CurrencySign, 1)); - else if (result.Multiplier == 4) - msg = GetText(strs.slot_two(CurrencySign, 4)); - else if (result.Multiplier == 10) - msg = GetText(strs.slot_three(10)); - else if (result.Multiplier == 30) - msg = GetText(strs.slot_jackpot(30)); - } + long ownedAmount; + using (var uow = _db.GetDbContext()) + { + ownedAmount = uow.Set() + .FirstOrDefault(x => x.UserId == ctx.User.Id) + ?.CurrencyAmount ?? 0; + } - using (var imgStream = bgImage.ToStream()) - { - await ctx.Channel.SendFileAsync(imgStream, "result.png", ctx.User.Mention + " " + msg + $"\n`{GetText(strs.slot_bet)}:`{amount} `{GetText(strs.won)}:` {amount * result.Multiplier}{CurrencySign}").ConfigureAwait(false); - } - } - } - finally - { - var _ = Task.Run(async () => - { - await Task.Delay(1000).ConfigureAwait(false); - _runningUsers.Remove(ctx.User.Id); - }); - } + using (var bgImage = Image.Load(_images.SlotBackground, out var format)) + { + var numbers = new int[3]; + result.Rolls.CopyTo(numbers, 0); + + bgImage.Mutate(x => x.DrawText(new TextGraphicsOptions + { + TextOptions = new TextOptions() + { + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + WrapTextWidth = 140, + } + }, result.Won.ToString(), _fonts.DottyFont.CreateFont(65), SixLabors.ImageSharp.Color.Red, + new PointF(227, 92))); + + bgImage.Mutate(x => x.DrawText(new TextGraphicsOptions + { + TextOptions = new TextOptions() + { + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + WrapTextWidth = 135, + } + }, amount.ToString(), _fonts.DottyFont.CreateFont(50), SixLabors.ImageSharp.Color.Red, + new PointF(129, 472))); + + bgImage.Mutate(x => x.DrawText(new TextGraphicsOptions + { + TextOptions = new TextOptions() + { + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + WrapTextWidth = 135, + } + }, ownedAmount.ToString(), _fonts.DottyFont.CreateFont(50), SixLabors.ImageSharp.Color.Red, + new PointF(325, 472))); + //sw.PrintLap("drew red text"); + + for (var i = 0; i < 3; i++) + { + using (var img = Image.Load(_images.SlotEmojis[numbers[i]])) + { + bgImage.Mutate(x => x.DrawImage(img, new Point(148 + 105 * i, 217), 1f)); + } + } + + var msg = GetText(strs.better_luck); + if (result.Multiplier > 0) + { + if (result.Multiplier == 1f) + msg = GetText(strs.slot_single(CurrencySign, 1)); + else if (result.Multiplier == 4f) + msg = GetText(strs.slot_two(CurrencySign, 4)); + else if (result.Multiplier == 10f) + msg = GetText(strs.slot_three(10)); + else if (result.Multiplier == 30f) + msg = GetText(strs.slot_jackpot(30)); + } + + using (var imgStream = bgImage.ToStream()) + { + await ctx.Channel.SendFileAsync(imgStream, + filename: "result.png", + text: Format.Bold(ctx.User.ToString()) + " " + msg).ConfigureAwait(false); + } + } + } + finally + { + var _ = Task.Run(async () => + { + await Task.Delay(1000).ConfigureAwait(false); + _runningUsers.Remove(ctx.User.Id); + }); + } } } } diff --git a/src/NadekoBot/Modules/Utility/QuoteCommands.cs b/src/NadekoBot/Modules/Utility/QuoteCommands.cs index e2e2bcb79..2e256be2a 100644 --- a/src/NadekoBot/Modules/Utility/QuoteCommands.cs +++ b/src/NadekoBot/Modules/Utility/QuoteCommands.cs @@ -115,9 +115,7 @@ namespace NadekoBot.Modules.Utility .WithOkColor() .WithTitle(GetText(strs.quote_id($"#{data.Id}"))) .AddField(GetText(strs.trigger), data.Keyword) - .AddField(GetText(strs.response), data.Text.Length > 1000 - ? GetText(strs.redacted_too_long) - : Format.Sanitize(data.Text)) + .AddField(GetText(strs.response), Format.Sanitize(data.Text).Replace("](", "]\\(")) .WithFooter(GetText(strs.created_by($"{data.AuthorName} ({data.AuthorId})"))) ).ConfigureAwait(false); } diff --git a/src/NadekoBot/Services/Common/ImageLoader.cs b/src/NadekoBot/Services/Common/ImageLoader.cs deleted file mode 100644 index 44046b537..000000000 --- a/src/NadekoBot/Services/Common/ImageLoader.cs +++ /dev/null @@ -1,141 +0,0 @@ -using Newtonsoft.Json.Linq; -using StackExchange.Redis; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; -using Serilog; - -namespace NadekoBot.Services.Common -{ - public class ImageLoader - { - private readonly HttpClient _http; - private readonly ConnectionMultiplexer _con; - - public Func GetKey { get; } - - private IDatabase _db => _con.GetDatabase(); - - private readonly List>> uriTasks = new List>>(); - - public ImageLoader(HttpClient http, ConnectionMultiplexer con, Func getKey) - { - _http = http; - _con = con; - GetKey = getKey; - } - - private async Task GetImageData(Uri uri) - { - if (uri.IsFile) - { - try - { - var bytes = await File.ReadAllBytesAsync(uri.LocalPath); - return bytes; - } - catch (Exception ex) - { - Log.Warning(ex, "Failed reading image bytes"); - return null; - } - } - else - { - return await _http.GetByteArrayAsync(uri); - } - } - - async Task HandleJArray(JArray arr, string key) - { - var tasks = arr.Where(x => x.Type == JTokenType.String) - .Select(async x => - { - try - { - return await GetImageData((Uri)x).ConfigureAwait(false); - } - catch - { - Log.Error("Error retreiving image for key {Key}: {Data}", key, x); - return null; - } - }); - - byte[][] vals = Array.Empty(); - vals = await Task.WhenAll(tasks).ConfigureAwait(false); - if (vals.Any(x => x is null)) - vals = vals.Where(x => x != null).ToArray(); - - await _db.KeyDeleteAsync(GetKey(key)).ConfigureAwait(false); - await _db.ListRightPushAsync(GetKey(key), - vals.Where(x => x != null) - .Select(x => (RedisValue)x) - .ToArray()).ConfigureAwait(false); - - if (arr.Count != vals.Length) - { - Log.Information("{2}/{1} URIs for the key '{0}' have been loaded. Some of the supplied URIs are either unavailable or invalid.", key, arr.Count, vals.Count()); - } - } - - async Task> HandleUri(Uri uri, string key) - { - try - { - RedisValue data = await GetImageData(uri).ConfigureAwait(false); - return new KeyValuePair(GetKey(key), data); - } - catch - { - Log.Information("Setting '{0}' image failed. The URI you provided is either unavailable or invalid.", key.ToLowerInvariant()); - return new KeyValuePair("", ""); - } - } - - Task HandleJObject(JObject obj, string parent = "") - { - string GetParentString() - { - if (string.IsNullOrWhiteSpace(parent)) - return ""; - else - return parent + "_"; - } - List tasks = new List(); - Task t; - // go through all of the kvps in the object - foreach (var kvp in obj) - { - // if it's a JArray, resole it using jarray method which will - // return task aka an array of all images' bytes - if (kvp.Value.Type == JTokenType.Array) - { - t = HandleJArray((JArray)kvp.Value, GetParentString() + kvp.Key); - tasks.Add(t); - } - else if (kvp.Value.Type == JTokenType.String) - { - var uriTask = HandleUri((Uri)kvp.Value, GetParentString() + kvp.Key); - uriTasks.Add(uriTask); - } - else if (kvp.Value.Type == JTokenType.Object) - { - t = HandleJObject((JObject)kvp.Value, GetParentString() + kvp.Key); - tasks.Add(t); - } - } - return Task.WhenAll(tasks); - } - - public async Task LoadAsync(JObject obj) - { - await HandleJObject(obj).ConfigureAwait(false); - var results = await Task.WhenAll(uriTasks).ConfigureAwait(false); - await _db.StringSetAsync(results.Where(x => x.Key != "").ToArray()).ConfigureAwait(false); - } - } -} diff --git a/src/NadekoBot/Services/Common/RedisImageArray.cs b/src/NadekoBot/Services/Common/RedisImageArray.cs deleted file mode 100644 index 5accb5008..000000000 --- a/src/NadekoBot/Services/Common/RedisImageArray.cs +++ /dev/null @@ -1,52 +0,0 @@ -using StackExchange.Redis; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; - -namespace NadekoBot.Services.Common -{ - public sealed class RedisImageArray : IReadOnlyList - { - public byte[] this[int index] - { - get - { - if (index < 0) - throw new ArgumentOutOfRangeException(nameof(index)); - - return _con.GetDatabase().ListGetByIndex(_key, index); - } - } - - public int Count => _data.IsValueCreated - ? _data.Value.Length - : (int)_con.GetDatabase().ListLength(_key); - - private readonly ConnectionMultiplexer _con; - private readonly string _key; - - private readonly Lazy _data; - - public RedisImageArray(string key, ConnectionMultiplexer con) - { - _con = con; - _key = key; - _data = new Lazy(() => _con.GetDatabase().ListRange(_key).Select(x => (byte[])x).ToArray(), true); - } - - public IEnumerator GetEnumerator() - { - var actualData = _data.Value; - for (int i = 0; i < actualData.Length; i++) - { - yield return actualData[i]; - } - } - - IEnumerator IEnumerable.GetEnumerator() - { - return _data.Value.GetEnumerator(); - } - } -} diff --git a/src/NadekoBot/Services/Impl/FontProvider.cs b/src/NadekoBot/Services/Impl/FontProvider.cs index 1ef29a47a..f922e725b 100644 --- a/src/NadekoBot/Services/Impl/FontProvider.cs +++ b/src/NadekoBot/Services/Impl/FontProvider.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; namespace NadekoBot.Services { @@ -48,8 +49,11 @@ namespace NadekoBot.Services } RipFont = NotoSans.CreateFont(20, FontStyle.Bold); + DottyFont = FallBackFonts.First(x => x.Name == "dotty"); } + public FontFamily DottyFont { get; } + public FontFamily UniSans { get; } public FontFamily NotoSans { get; } //public FontFamily Emojis { get; } diff --git a/src/NadekoBot/Services/Impl/ImagesService.cs b/src/NadekoBot/Services/Impl/ImagesService.cs deleted file mode 100644 index b438bbcb9..000000000 --- a/src/NadekoBot/Services/Impl/ImagesService.cs +++ /dev/null @@ -1,181 +0,0 @@ -using NadekoBot.Common; -using NadekoBot.Services.Common; -using NadekoBot.Extensions; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using StackExchange.Redis; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; -using NadekoBot.Common.ModuleBehaviors; -using Serilog; - -namespace NadekoBot.Services -{ - public sealed class RedisImagesCache : IImageCache, IReadyExecutor - { - private readonly ConnectionMultiplexer _con; - private readonly IBotCredentials _creds; - private readonly HttpClient _http; - - private IDatabase _db => _con.GetDatabase(); - - private const string _basePath = "data/"; - private const string _cardsPath = "data/images/cards"; - - public ImageUrls ImageUrls { get; private set; } - - public IReadOnlyList Heads => GetByteArrayData(ImageKey.Coins_Heads); - - public IReadOnlyList Tails => GetByteArrayData(ImageKey.Coins_Tails); - - public IReadOnlyList Dice => GetByteArrayData(ImageKey.Dice); - - public IReadOnlyList SlotEmojis => GetByteArrayData(ImageKey.Slots_Emojis); - - public IReadOnlyList SlotNumbers => GetByteArrayData(ImageKey.Slots_Numbers); - - public IReadOnlyList Currency => GetByteArrayData(ImageKey.Currency); - - public byte[] SlotBackground => GetByteData(ImageKey.Slots_Bg); - - public byte[] RategirlMatrix => GetByteData(ImageKey.Rategirl_Matrix); - - public byte[] RategirlDot => GetByteData(ImageKey.Rategirl_Dot); - - public byte[] XpBackground => GetByteData(ImageKey.Xp_Bg); - - public byte[] Rip => GetByteData(ImageKey.Rip_Bg); - - public byte[] RipOverlay => GetByteData(ImageKey.Rip_Overlay); - - public byte[] GetCard(string key) - { - return _con.GetDatabase().StringGet(GetKey("card_" + key)); - } - - public enum ImageKey - { - Coins_Heads, - Coins_Tails, - Dice, - Slots_Bg, - Slots_Numbers, - Slots_Emojis, - Rategirl_Matrix, - Rategirl_Dot, - Xp_Bg, - Rip_Bg, - Rip_Overlay, - Currency, - } - - public async Task OnReadyAsync() - { - if (await AllKeysExist()) - return; - - await Reload(); - } - - public RedisImagesCache(ConnectionMultiplexer con, IBotCredentials creds) - { - _con = con; - _creds = creds; - _http = new HttpClient(); - - ImageUrls = JsonConvert.DeserializeObject( - File.ReadAllText(Path.Combine(_basePath, "images.json"))); - } - - public async Task AllKeysExist() - { - try - { - var results = await Task.WhenAll(Enum.GetNames(typeof(ImageKey)) - .Select(x => x.ToLowerInvariant()) - .Select(x => _db.KeyExistsAsync(GetKey(x)))) - .ConfigureAwait(false); - - var cardsExist = await Task.WhenAll(GetAllCardNames() - .Select(x => "card_" + x) - .Select(x => _db.KeyExistsAsync(GetKey(x)))) - .ConfigureAwait(false); - - var num = results.Where(x => !x).Count(); - - return results.All(x => x) && cardsExist.All(x => x); - } - catch (Exception ex) - { - Log.Warning(ex, "Error checking for Image keys"); - return false; - } - } - - public async Task Reload() - { - try - { - var sw = Stopwatch.StartNew(); - var obj = JObject.Parse( - File.ReadAllText(Path.Combine(_basePath, "images.json"))); - - ImageUrls = obj.ToObject(); - var t = new ImageLoader(_http, _con, GetKey) - .LoadAsync(obj); - - var loadCards = Task.Run(async () => - { - await _db.StringSetAsync(Directory.GetFiles(_cardsPath) - .ToDictionary( - x => GetKey("card_" + Path.GetFileNameWithoutExtension(x)), - x => (RedisValue)File.ReadAllBytes(x)) // loads them and creates pairs to store in redis - .ToArray()) - .ConfigureAwait(false); - }); - - await Task.WhenAll(t, loadCards).ConfigureAwait(false); - - sw.Stop(); - Log.Information($"Images reloaded in {sw.Elapsed.TotalSeconds:F2}s"); - } - catch (Exception ex) - { - Log.Error(ex, "Error reloading image service"); - throw; - } - } - - private IEnumerable GetAllCardNames(bool showExtension = false) - { - return Directory.GetFiles(_cardsPath) // gets all cards from the cards folder - .Select(x => showExtension - ? Path.GetFileName(x) - : Path.GetFileNameWithoutExtension(x)); // gets their names - } - - public RedisKey GetKey(string key) - { - return $"{_creds.RedisKey()}_localimg_{key.ToLowerInvariant()}"; - } - - public byte[] GetByteData(string key) - { - return _db.StringGet(GetKey(key)); - } - - public byte[] GetByteData(ImageKey key) => GetByteData(key.ToString()); - - public RedisImageArray GetByteArrayData(string key) - { - return new RedisImageArray(GetKey(key), _con); - } - - public RedisImageArray GetByteArrayData(ImageKey key) => GetByteArrayData(key.ToString()); - } -} \ No newline at end of file diff --git a/src/NadekoBot/Services/Impl/RedisImageExtensions.cs b/src/NadekoBot/Services/Impl/RedisImageExtensions.cs new file mode 100644 index 000000000..952b01158 --- /dev/null +++ b/src/NadekoBot/Services/Impl/RedisImageExtensions.cs @@ -0,0 +1,13 @@ +using System; + +namespace NadekoBot.Services +{ + public static class RedisImageExtensions + { + private const string OldCdnUrl = "nadeko-pictures.nyc3.digitaloceanspaces.com"; + private const string NewCdnUrl = "cdn.nadeko.bot"; + + public static Uri ToNewCdn(this Uri uri) + => new(uri.ToString().Replace(OldCdnUrl, NewCdnUrl)); + } +} \ No newline at end of file diff --git a/src/NadekoBot/Services/Impl/RedisImagesCache.cs b/src/NadekoBot/Services/Impl/RedisImagesCache.cs new file mode 100644 index 000000000..d53037bda --- /dev/null +++ b/src/NadekoBot/Services/Impl/RedisImagesCache.cs @@ -0,0 +1,312 @@ +using NadekoBot.Common; +using NadekoBot.Extensions; +using Newtonsoft.Json; +using StackExchange.Redis; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using NadekoBot.Common.ModuleBehaviors; +using NadekoBot.Common.Yml; +using Serilog; + +namespace NadekoBot.Services +{ + public sealed class RedisImagesCache : IImageCache, IReadyExecutor + { + + private readonly ConnectionMultiplexer _con; + private readonly IBotCredentials _creds; + private readonly HttpClient _http; + private readonly string _imagesPath; + + private IDatabase _db => _con.GetDatabase(); + + private const string _basePath = "data/"; + private const string _cardsPath = "data/images/cards"; + + public ImageUrls ImageUrls { get; private set; } + + public enum ImageKeys + { + CoinHeads, + CoinTails, + Dice, + SlotBg, + SlotEmojis, + SlotNumbers, + Currency, + RategirlMatrix, + RategirlDot, + RipOverlay, + RipBg, + XpBg + } + + public IReadOnlyList Heads + => GetByteArrayData(ImageKeys.CoinHeads); + + public IReadOnlyList Tails + => GetByteArrayData(ImageKeys.CoinTails); + + public IReadOnlyList Dice + => GetByteArrayData(ImageKeys.Dice); + + public IReadOnlyList SlotEmojis + => GetByteArrayData(ImageKeys.SlotEmojis); + + public IReadOnlyList SlotNumbers + => GetByteArrayData(ImageKeys.SlotNumbers); + + public IReadOnlyList Currency + => GetByteArrayData(ImageKeys.Currency); + + public byte[] SlotBackground + => GetByteData(ImageKeys.SlotBg); + + public byte[] RategirlMatrix + => GetByteData(ImageKeys.RategirlMatrix); + + public byte[] RategirlDot + => GetByteData(ImageKeys.RategirlDot); + + public byte[] XpBackground + => GetByteData(ImageKeys.XpBg); + + public byte[] Rip + => GetByteData(ImageKeys.RipBg); + + public byte[] RipOverlay + => GetByteData(ImageKeys.RipOverlay); + + public byte[] GetCard(string key) + { + // since cards are always local for now, don't cache them + return File.ReadAllBytes(Path.Join(_cardsPath, key + ".jpg")); + + } + + public async Task OnReadyAsync() + { + if (await AllKeysExist()) + return; + + await Reload(); + } + + public RedisImagesCache(ConnectionMultiplexer con, IBotCredentials creds) + { + _con = con; + _creds = creds; + _http = new HttpClient(); + _imagesPath = Path.Combine(_basePath, "images.yml"); + + Migrate(); + + ImageUrls = Yaml.Deserializer.Deserialize(File.ReadAllText(_imagesPath)); + } + + private void Migrate() + { + // migrate to yml + if (File.Exists(Path.Combine(_basePath, "images.json"))) + { + var oldFilePath = Path.Combine(_basePath, "images.json"); + var backupFilePath = Path.Combine(_basePath, "images.json.backup"); + + var oldData = JsonConvert.DeserializeObject( + File.ReadAllText(oldFilePath)); + + if (oldData is not null) + { + var newData = new ImageUrls() + { + Coins = new ImageUrls.CoinData() + { + Heads = oldData.Coins.Heads.Length == 1 && + oldData.Coins.Heads[0].ToString() == "https://nadeko-pictures.nyc3.digitaloceanspaces.com/other/coins/heads.png" + ? new[] { new Uri("https://cdn.nadeko.bot/coins/heads3.png") } + : oldData.Coins.Heads, + Tails = oldData.Coins.Tails.Length == 1 && + oldData.Coins.Tails[0].ToString() == "https://nadeko-pictures.nyc3.digitaloceanspaces.com/other/coins/tails.png" + ? new[] { new Uri("https://cdn.nadeko.bot/coins/tails3.png") } + : oldData.Coins.Tails, + }, + Dice = oldData.Dice.Map(x => x.ToNewCdn()), + Currency = oldData.Currency.Map(x => x.ToNewCdn()), + Rategirl = new ImageUrls.RategirlData() + { + Dot = oldData.Rategirl.Dot.ToNewCdn(), + Matrix = oldData.Rategirl.Matrix.ToNewCdn() + }, + Rip = new ImageUrls.RipData() + { + Bg = oldData.Rip.Bg.ToNewCdn(), + Overlay = oldData.Rip.Overlay.ToNewCdn(), + }, + Slots = new ImageUrls.SlotData() + { + Bg = new Uri("https://cdn.nadeko.bot/slots/slots_bg.png"), + Emojis = new[] + { + "https://cdn.nadeko.bot/slots/0.png", + "https://cdn.nadeko.bot/slots/1.png", + "https://cdn.nadeko.bot/slots/2.png", + "https://cdn.nadeko.bot/slots/3.png", + "https://cdn.nadeko.bot/slots/4.png", + "https://cdn.nadeko.bot/slots/5.png" + }.Map(x => new Uri(x)), + Numbers = new[] + { + "https://cdn.nadeko.bot/other/slots/numbers/0.png", + "https://cdn.nadeko.bot/other/slots/numbers/1.png", + "https://cdn.nadeko.bot/other/slots/numbers/2.png", + "https://cdn.nadeko.bot/other/slots/numbers/3.png", + "https://cdn.nadeko.bot/other/slots/numbers/4.png", + "https://cdn.nadeko.bot/other/slots/numbers/5.png", + "https://cdn.nadeko.bot/other/slots/numbers/6.png", + "https://cdn.nadeko.bot/other/slots/numbers/7.png", + "https://cdn.nadeko.bot/other/slots/numbers/8.png", + "https://cdn.nadeko.bot/other/slots/numbers/9.png" + }.Map(x => new Uri(x)), + }, + Xp = new ImageUrls.XpData() + { + Bg = oldData.Xp.Bg.ToNewCdn(), + }, + Version = 2, + }; + + File.Move(oldFilePath, backupFilePath, true); + File.WriteAllText(_imagesPath, Yaml.Serializer.Serialize(newData)); + } + } + } + + public async Task Reload() + { + ImageUrls = Yaml.Deserializer.Deserialize(await File.ReadAllTextAsync(_imagesPath)); + foreach (var key in GetAllKeys()) + { + switch (key) + { + case ImageKeys.CoinHeads: + await Load(key, ImageUrls.Coins.Heads); + break; + case ImageKeys.CoinTails: + await Load(key, ImageUrls.Coins.Tails); + break; + case ImageKeys.Dice: + await Load(key, ImageUrls.Dice); + break; + case ImageKeys.SlotBg: + await Load(key, ImageUrls.Slots.Bg); + break; + case ImageKeys.SlotEmojis: + await Load(key, ImageUrls.Slots.Emojis); + break; + case ImageKeys.SlotNumbers: + await Load(key, ImageUrls.Slots.Numbers); + break; + case ImageKeys.Currency: + await Load(key, ImageUrls.Currency); + break; + case ImageKeys.RategirlMatrix: + await Load(key, ImageUrls.Rategirl.Matrix); + break; + case ImageKeys.RategirlDot: + await Load(key, ImageUrls.Rategirl.Dot); + break; + case ImageKeys.RipOverlay: + await Load(key, ImageUrls.Rip.Overlay); + break; + case ImageKeys.RipBg: + await Load(key, ImageUrls.Rip.Bg); + break; + case ImageKeys.XpBg: + await Load(key, ImageUrls.Xp.Bg); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + } + + private async Task Load(ImageKeys key, Uri uri) + { + var data = await GetImageData(uri); + if (data is null) + return; + + await _db.StringSetAsync(GetRedisKey(key), data); + } + + private async Task Load(ImageKeys key, Uri[] uris) + { + await _db.KeyDeleteAsync(GetRedisKey(key)); + var imageData = await Task.WhenAll(uris.Select(GetImageData)); + var vals = imageData + .Where(x => x is not null) + .Select(x => (RedisValue)x) + .ToArray(); + + await _db.ListRightPushAsync(GetRedisKey(key), vals); + + if (uris.Length != vals.Length) + { + Log.Information("{Loaded}/{Max} URIs for the key '{ImageKey}' have been loaded.\n" + + "Some of the supplied URIs are either unavailable or invalid.", + vals.Length, uris.Length, key); + } + } + + private async Task GetImageData(Uri uri) + { + if (uri.IsFile) + { + try + { + var bytes = await File.ReadAllBytesAsync(uri.LocalPath); + return bytes; + } + catch (Exception ex) + { + Log.Warning(ex, "Failed reading image bytes from uri: {Uri}", uri.ToString()); + return null; + } + } + + try + { + return await _http.GetByteArrayAsync(uri); + } + catch (Exception ex) + { + Log.Warning(ex, "Image url you provided is not a valid image: {Uri}", uri.ToString()); + return null; + } + } + + private async Task AllKeysExist() + { + var tasks = await Task.WhenAll(GetAllKeys() + .Select(x => _db.KeyExistsAsync(GetRedisKey(x)))); + + return tasks.All(exist => exist); + } + + private IEnumerable GetAllKeys() => + Enum.GetValues(); + + private byte[][] GetByteArrayData(ImageKeys key) + => _db.ListRange(GetRedisKey(key)).Map(x => (byte[])x); + + private byte[] GetByteData(ImageKeys key) + => _db.StringGet(GetRedisKey(key)); + + private RedisKey GetRedisKey(ImageKeys key) + => _creds.RedisKey() + "_image_" + key; + } +} \ No newline at end of file diff --git a/src/NadekoBot/data/fonts/dotty.ttf b/src/NadekoBot/data/fonts/dotty.ttf new file mode 100644 index 0000000000000000000000000000000000000000..4ce2814cf7fbf593ad923ea52f162c408315dc42 GIT binary patch literal 51848 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 xOZuz!D?_)g4eVHaeR!~^r{_p@zJ