added a .questlog and a quest system

This commit is contained in:
Toastie 2025-03-28 21:13:53 +13:00
parent 97fe14cf5a
commit 4c2b42ab7f
Signed by: toastie_t0ast
GPG key ID: 0861BE54AD481DC7
46 changed files with 1391 additions and 392 deletions

View file

@ -0,0 +1,6 @@
START TRANSACTION;
INSERT INTO "__EFMigrationsHistory" (migrationid, productversion)
VALUES ('20250328075848_quests', '9.0.1');
COMMIT;

View file

@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace EllieBot.Migrations.PostgreSql namespace EllieBot.Migrations.PostgreSql
{ {
[DbContext(typeof(PostgreSqlContext))] [DbContext(typeof(PostgreSqlContext))]
[Migration("20250323022235_init")] [Migration("20250328080459_init")]
partial class init partial class init
{ {
/// <inheritdoc /> /// <inheritdoc />

View file

@ -0,0 +1,6 @@
BEGIN TRANSACTION;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20250328075818_quests', '9.0.1');
COMMIT;

View file

@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace EllieBot.Migrations.Sqlite namespace EllieBot.Migrations.Sqlite
{ {
[DbContext(typeof(SqliteContext))] [DbContext(typeof(SqliteContext))]
[Migration("20250323022218_init")] [Migration("20250328080413_init")]
partial class init partial class init
{ {
/// <inheritdoc /> /// <inheritdoc />

View file

@ -1,6 +1,7 @@
#nullable disable #nullable disable
using EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions; using EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
using EllieBot.Modules.Games.Common; using EllieBot.Modules.Games.Common;
using EllieBot.Modules.Games.Quests;
namespace EllieBot.Modules.Gambling.Common.AnimalRacing; namespace EllieBot.Modules.Gambling.Common.AnimalRacing;
@ -35,12 +36,18 @@ public sealed class AnimalRace : IDisposable
private readonly ICurrencyService _currency; private readonly ICurrencyService _currency;
private readonly RaceOptions _options; private readonly RaceOptions _options;
private readonly Queue<RaceAnimal> _animalsQueue; private readonly Queue<RaceAnimal> _animalsQueue;
private readonly QuestService _quests;
public AnimalRace(RaceOptions options, ICurrencyService currency, IEnumerable<RaceAnimal> availableAnimals) public AnimalRace(
RaceOptions options,
ICurrencyService currency,
IEnumerable<RaceAnimal> availableAnimals,
QuestService quests)
{ {
_currency = currency; _currency = currency;
_options = options; _options = options;
_animalsQueue = new(availableAnimals); _animalsQueue = new(availableAnimals);
_quests = quests;
MaxUsers = _animalsQueue.Count; MaxUsers = _animalsQueue.Count;
if (_animalsQueue.Count == 0) if (_animalsQueue.Count == 0)
@ -60,7 +67,10 @@ public sealed class AnimalRace : IDisposable
await Start(); await Start();
} }
finally { _locker.Release(); } finally
{
_locker.Release();
}
}); });
public async Task<AnimalRacingUser> JoinRace(ulong userId, string userName, long bet = 0) public async Task<AnimalRacingUser> JoinRace(ulong userId, string userName, long bet = 0)
@ -93,7 +103,10 @@ public sealed class AnimalRace : IDisposable
return user; return user;
} }
finally { _locker.Release(); } finally
{
_locker.Release();
}
} }
private async Task Start() private async Task Start()
@ -104,7 +117,9 @@ public sealed class AnimalRace : IDisposable
foreach (var user in _users) foreach (var user in _users)
{ {
if (user.Bet > 0) if (user.Bet > 0)
await _currency.AddAsync(user.UserId, (long)(user.Bet + BASE_MULTIPLIER), new("animalrace", "refund")); await _currency.AddAsync(user.UserId,
(long)(user.Bet * BASE_MULTIPLIER),
new("animalrace", "refund"));
} }
_ = OnStartingFailed?.Invoke(this); _ = OnStartingFailed?.Invoke(this);
@ -112,6 +127,11 @@ public sealed class AnimalRace : IDisposable
return; return;
} }
foreach (var user in _users)
{
await _quests.ReportActionAsync(user.UserId, QuestEventType.RaceJoined);
}
_ = OnStarted?.Invoke(this); _ = OnStarted?.Invoke(this);
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {

View file

@ -4,6 +4,7 @@ using EllieBot.Modules.Gambling.Common;
using EllieBot.Modules.Gambling.Common.AnimalRacing; using EllieBot.Modules.Gambling.Common.AnimalRacing;
using EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions; using EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
using EllieBot.Modules.Gambling.Services; using EllieBot.Modules.Gambling.Services;
using EllieBot.Modules.Games.Quests;
using EllieBot.Modules.Games.Services; using EllieBot.Modules.Games.Services;
namespace EllieBot.Modules.Gambling; namespace EllieBot.Modules.Gambling;
@ -17,6 +18,7 @@ public partial class Gambling
private readonly ICurrencyService _cs; private readonly ICurrencyService _cs;
private readonly DiscordSocketClient _client; private readonly DiscordSocketClient _client;
private readonly GamesConfigService _gamesConf; private readonly GamesConfigService _gamesConf;
private readonly QuestService _quests;
private IUserMessage raceMessage; private IUserMessage raceMessage;
@ -24,12 +26,14 @@ public partial class Gambling
ICurrencyService cs, ICurrencyService cs,
DiscordSocketClient client, DiscordSocketClient client,
GamblingConfigService gamblingConf, GamblingConfigService gamblingConf,
GamesConfigService gamesConf) GamesConfigService gamesConf,
QuestService quests)
: base(gamblingConf) : base(gamblingConf)
{ {
_cs = cs; _cs = cs;
_client = client; _client = client;
_gamesConf = gamesConf; _gamesConf = gamesConf;
_quests = quests;
} }
[Cmd] [Cmd]
@ -39,11 +43,11 @@ public partial class Gambling
{ {
var (options, _) = OptionsParser.ParseFrom(new RaceOptions(), args); var (options, _) = OptionsParser.ParseFrom(new RaceOptions(), args);
var ar = new AnimalRace(options, _cs, _gamesConf.Data.RaceAnimals.Shuffle()); var ar = new AnimalRace(options, _cs, _gamesConf.Data.RaceAnimals.Shuffle(), _quests);
if (!_service.AnimalRaces.TryAdd(ctx.Guild.Id, ar)) if (!_service.AnimalRaces.TryAdd(ctx.Guild.Id, ar))
return Response() return Response()
.Error(GetText(strs.animal_race), GetText(strs.animal_race_already_started)) .Error(GetText(strs.animal_race), GetText(strs.animal_race_already_started))
.SendAsync(); .SendAsync();
ar.Initialize(); ar.Initialize();
@ -61,7 +65,9 @@ public partial class Gambling
raceMessage = null; raceMessage = null;
} }
} }
catch { } catch
{
}
}); });
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -74,22 +80,22 @@ public partial class Gambling
if (race.FinishedUsers[0].Bet > 0) if (race.FinishedUsers[0].Bet > 0)
{ {
return Response() return Response()
.Embed(CreateEmbed() .Embed(CreateEmbed()
.WithOkColor() .WithOkColor()
.WithTitle(GetText(strs.animal_race)) .WithTitle(GetText(strs.animal_race))
.WithDescription(GetText(strs.animal_race_won_money( .WithDescription(GetText(strs.animal_race_won_money(
Format.Bold(winner.Username), Format.Bold(winner.Username),
winner.Animal.Icon, winner.Animal.Icon,
N(race.FinishedUsers[0].Bet * race.Multi)))) N(race.FinishedUsers[0].Bet * race.Multi))))
.WithFooter($"x{race.Multi:F2}")) .WithFooter($"x{race.Multi:F2}"))
.SendAsync(); .SendAsync();
} }
ar.Dispose(); ar.Dispose();
return Response() return Response()
.Confirm(GetText(strs.animal_race), .Confirm(GetText(strs.animal_race),
GetText(strs.animal_race_won(Format.Bold(winner.Username), winner.Animal.Icon))) GetText(strs.animal_race_won(Format.Bold(winner.Username), winner.Animal.Icon)))
.SendAsync(); .SendAsync();
} }
ar.OnStartingFailed += Ar_OnStartingFailed; ar.OnStartingFailed += Ar_OnStartingFailed;
@ -99,10 +105,10 @@ public partial class Gambling
_client.MessageReceived += ClientMessageReceived; _client.MessageReceived += ClientMessageReceived;
return Response() return Response()
.Confirm(GetText(strs.animal_race), .Confirm(GetText(strs.animal_race),
GetText(strs.animal_race_starting(options.StartTime)), GetText(strs.animal_race_starting(options.StartTime)),
footer: GetText(strs.animal_race_join_instr(prefix))) footer: GetText(strs.animal_race_join_instr(prefix)))
.SendAsync(); .SendAsync();
} }
private Task Ar_OnStarted(AnimalRace race) private Task Ar_OnStarted(AnimalRace race)
@ -110,9 +116,9 @@ public partial class Gambling
if (race.Users.Count == race.MaxUsers) if (race.Users.Count == race.MaxUsers)
return Response().Confirm(GetText(strs.animal_race), GetText(strs.animal_race_full)).SendAsync(); return Response().Confirm(GetText(strs.animal_race), GetText(strs.animal_race_full)).SendAsync();
return Response() return Response()
.Confirm(GetText(strs.animal_race), .Confirm(GetText(strs.animal_race),
GetText(strs.animal_race_starting_with_x(race.Users.Count))) GetText(strs.animal_race_starting_with_x(race.Users.Count)))
.SendAsync(); .SendAsync();
} }
private async Task Ar_OnStateUpdate(AnimalRace race) private async Task Ar_OnStateUpdate(AnimalRace race)
@ -133,10 +139,10 @@ public partial class Gambling
else else
{ {
await msg.ModifyAsync(x => x.Embed = CreateEmbed() await msg.ModifyAsync(x => x.Embed = CreateEmbed()
.WithTitle(GetText(strs.animal_race)) .WithTitle(GetText(strs.animal_race))
.WithDescription(text) .WithDescription(text)
.WithOkColor() .WithOkColor()
.Build()); .Build());
} }
} }
@ -166,15 +172,15 @@ public partial class Gambling
if (amount > 0) if (amount > 0)
{ {
await Response() await Response()
.Confirm(GetText(strs.animal_race_join_bet(ctx.User.Mention, .Confirm(GetText(strs.animal_race_join_bet(ctx.User.Mention,
user.Animal.Icon, user.Animal.Icon,
amount + CurrencySign))) amount + CurrencySign)))
.SendAsync(); .SendAsync();
} }
else else
await Response() await Response()
.Confirm(strs.animal_race_join(ctx.User.Mention, user.Animal.Icon)) .Confirm(strs.animal_race_join(ctx.User.Mention, user.Animal.Icon))
.SendAsync(); .SendAsync();
} }
catch (ArgumentOutOfRangeException) catch (ArgumentOutOfRangeException)
{ {

View file

@ -1,20 +1,15 @@
using LinqToDB; using LinqToDB;
using LinqToDB.EntityFrameworkCore; using LinqToDB.EntityFrameworkCore;
using EllieBot.Db.Models; using EllieBot.Db.Models;
using EllieBot.Modules.Games.Quests;
namespace EllieBot.Modules.Gambling.Bank; namespace EllieBot.Modules.Gambling.Bank;
public sealed class BankService : IBankService, IEService public sealed class BankService(
ICurrencyService _cur,
DbService _db,
QuestService quests) : IBankService, IEService
{ {
private readonly ICurrencyService _cur;
private readonly DbService _db;
public BankService(ICurrencyService cur, DbService db)
{
_cur = cur;
_db = db;
}
public async Task<bool> AwardAsync(ulong userId, long amount) public async Task<bool> AwardAsync(ulong userId, long amount)
{ {
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
@ -37,7 +32,7 @@ public sealed class BankService : IBankService, IEService
return true; return true;
} }
public async Task<bool> TakeAsync(ulong userId, long amount) public async Task<bool> TakeAsync(ulong userId, long amount)
{ {
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
@ -50,7 +45,7 @@ public sealed class BankService : IBankService, IEService
{ {
Balance = old.Balance - amount Balance = old.Balance - amount
}); });
return rows > 0; return rows > 0;
} }
@ -63,20 +58,28 @@ public sealed class BankService : IBankService, IEService
await using var ctx = _db.GetDbContext(); await using var ctx = _db.GetDbContext();
await ctx.Set<BankUser>() await ctx.Set<BankUser>()
.ToLinqToDBTable() .ToLinqToDBTable()
.InsertOrUpdateAsync(() => new() .InsertOrUpdateAsync(() => new()
{ {
UserId = userId, UserId = userId,
Balance = amount Balance = amount
}, },
(old) => new() (old) => new()
{ {
Balance = old.Balance + amount Balance = old.Balance + amount
}, },
() => new() () => new()
{ {
UserId = userId UserId = userId
}); });
await quests.ReportActionAsync(userId,
QuestEventType.BankAction,
new()
{
{ "type", "deposit" },
{ "amount", amount.ToString() }
});
return true; return true;
} }
@ -87,12 +90,12 @@ public sealed class BankService : IBankService, IEService
await using var ctx = _db.GetDbContext(); await using var ctx = _db.GetDbContext();
var rows = await ctx.Set<BankUser>() var rows = await ctx.Set<BankUser>()
.ToLinqToDBTable() .ToLinqToDBTable()
.Where(x => x.UserId == userId && x.Balance >= amount) .Where(x => x.UserId == userId && x.Balance >= amount)
.UpdateAsync((old) => new() .UpdateAsync((old) => new()
{ {
Balance = old.Balance - amount Balance = old.Balance - amount
}); });
if (rows > 0) if (rows > 0)
{ {
@ -106,10 +109,11 @@ public sealed class BankService : IBankService, IEService
public async Task<long> GetBalanceAsync(ulong userId) public async Task<long> GetBalanceAsync(ulong userId)
{ {
await using var ctx = _db.GetDbContext(); await using var ctx = _db.GetDbContext();
return (await ctx.Set<BankUser>() var res = (await ctx.Set<BankUser>()
.ToLinqToDBTable() .ToLinqToDBTable()
.FirstOrDefaultAsync(x => x.UserId == userId)) .FirstOrDefaultAsync(x => x.UserId == userId))
?.Balance ?.Balance
?? 0; ?? 0;
return res;
} }
} }

View file

@ -14,6 +14,7 @@ using System.Text;
using EllieBot.Modules.Gambling.Rps; using EllieBot.Modules.Gambling.Rps;
using EllieBot.Common.TypeReaders; using EllieBot.Common.TypeReaders;
using EllieBot.Modules.Games; using EllieBot.Modules.Games;
using EllieBot.Modules.Games.Quests;
using EllieBot.Modules.Patronage; using EllieBot.Modules.Patronage;
namespace EllieBot.Modules.Gambling; namespace EllieBot.Modules.Gambling;
@ -36,6 +37,7 @@ public partial class Gambling : GamblingModule<GamblingService>
private readonly IBotCache _cache; private readonly IBotCache _cache;
private readonly CaptchaService _captchaService; private readonly CaptchaService _captchaService;
private readonly VoteRewardService _vrs; private readonly VoteRewardService _vrs;
private readonly QuestService _quests;
public Gambling( public Gambling(
IGamblingService gs, IGamblingService gs,
@ -52,7 +54,8 @@ public partial class Gambling : GamblingModule<GamblingService>
RakebackService rb, RakebackService rb,
IBotCache cache, IBotCache cache,
CaptchaService captchaService, CaptchaService captchaService,
VoteRewardService vrs) VoteRewardService vrs,
QuestService quests)
: base(configService) : base(configService)
{ {
_gs = gs; _gs = gs;
@ -68,6 +71,7 @@ public partial class Gambling : GamblingModule<GamblingService>
_ps = patronage; _ps = patronage;
_rng = new EllieRandom(); _rng = new EllieRandom();
_vrs = vrs; _vrs = vrs;
_quests = quests;
_enUsCulture = new CultureInfo("en-US", false).NumberFormat; _enUsCulture = new CultureInfo("en-US", false).NumberFormat;
_enUsCulture.NumberDecimalDigits = 0; _enUsCulture.NumberDecimalDigits = 0;

View file

@ -6,6 +6,7 @@ using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models; using EllieBot.Db.Models;
using EllieBot.Modules.Gambling.Common; using EllieBot.Modules.Gambling.Common;
using EllieBot.Modules.Gambling.Common.Connect4; using EllieBot.Modules.Gambling.Common.Connect4;
using EllieBot.Modules.Games.Quests;
using EllieBot.Modules.Patronage; using EllieBot.Modules.Patronage;
namespace EllieBot.Modules.Gambling.Services; namespace EllieBot.Modules.Gambling.Services;
@ -19,6 +20,7 @@ public class GamblingService : IEService, IReadyExecutor
private readonly IBotCache _cache; private readonly IBotCache _cache;
private readonly GamblingConfigService _gcs; private readonly GamblingConfigService _gcs;
private readonly IPatronageService _ps; private readonly IPatronageService _ps;
private readonly QuestService _quests;
private readonly EllieRandom _rng; private readonly EllieRandom _rng;
private static readonly TypedKey<long> _curDecayKey = new("currency:last_decay"); private static readonly TypedKey<long> _curDecayKey = new("currency:last_decay");
@ -28,13 +30,15 @@ public class GamblingService : IEService, IReadyExecutor
DiscordSocketClient client, DiscordSocketClient client,
IBotCache cache, IBotCache cache,
GamblingConfigService gcs, GamblingConfigService gcs,
IPatronageService ps) IPatronageService ps,
QuestService quests)
{ {
_db = db; _db = db;
_client = client; _client = client;
_cache = cache; _cache = cache;
_gcs = gcs; _gcs = gcs;
_ps = ps; _ps = ps;
_quests = quests;
_rng = new EllieRandom(); _rng = new EllieRandom();
} }
@ -230,10 +234,15 @@ public class GamblingService : IEService, IReadyExecutor
if (booster) if (booster)
originalAmount += gcsData.BoostBonus.BaseTimelyBonus; originalAmount += gcsData.BoostBonus.BaseTimelyBonus;
var hasCompletedDailies = await _quests.UserCompletedDailies(userId);
if (hasCompletedDailies)
originalAmount = (long)(1.5 * originalAmount);
var patron = await _ps.GetPatronAsync(userId); var patron = await _ps.GetPatronAsync(userId);
var percentBonus = (_ps.PercentBonus(patron) / 100f); var percentBonus = (_ps.PercentBonus(patron) / 100f);
originalAmount += (int)(originalAmount * percentBonus); originalAmount += (long)(originalAmount * percentBonus);
var msg = $"**{N(originalAmount)}** base reward\n\n"; var msg = $"**{N(originalAmount)}** base reward\n\n";
if (boostGuilds.Count > 0) if (boostGuilds.Count > 0)
@ -252,6 +261,15 @@ public class GamblingService : IEService, IReadyExecutor
else else
msg += $"\\❌ *+0 bonus for the [Patreon](https://patreon.com/elliebot) pledge*\n"; msg += $"\\❌ *+0 bonus for the [Patreon](https://patreon.com/elliebot) pledge*\n";
} }
if (hasCompletedDailies)
{
msg += $"\\✅ *+50% bonus for completing daily quests*\n";
}
else
{
msg += $"\\❌ *+0 bonus for completing daily quests*\n";
}
return (originalAmount, msg); return (originalAmount, msg);
} }

View file

@ -4,6 +4,7 @@ using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors; using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models; using EllieBot.Db.Models;
using EllieBot.Modules.Games.Quests;
using SixLabors.Fonts; using SixLabors.Fonts;
using SixLabors.Fonts.Unicode; using SixLabors.Fonts.Unicode;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
@ -15,67 +16,47 @@ using Image = SixLabors.ImageSharp.Image;
namespace EllieBot.Modules.Gambling.Services; namespace EllieBot.Modules.Gambling.Services;
public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor public class PlantPickService(
DbService db,
IBotStrings strings,
IImageCache images,
FontProvider fonts,
ICurrencyService cs,
CommandHandler cmdHandler,
DiscordSocketClient client,
GamblingConfigService gss,
GamblingService gs,
QuestService quests) : IEService, IExecNoCommand, IReadyExecutor
{ {
//channelId/last generation //channelId/last generation
public ConcurrentDictionary<ulong, long> LastGenerations { get; } = new(); public ConcurrentDictionary<ulong, long> LastGenerations { get; } = new();
private readonly DbService _db;
private readonly IBotStrings _strings;
private readonly IImageCache _images;
private readonly FontProvider _fonts;
private readonly ICurrencyService _cs;
private readonly CommandHandler _cmdHandler;
private readonly DiscordSocketClient _client;
private readonly GamblingConfigService _gss;
private readonly GamblingService _gs;
private ConcurrentHashSet<ulong> _generationChannels = []; private ConcurrentHashSet<ulong> _generationChannels = [];
public PlantPickService(
DbService db,
IBotStrings strings,
IImageCache images,
FontProvider fonts,
ICurrencyService cs,
CommandHandler cmdHandler,
DiscordSocketClient client,
GamblingConfigService gss,
GamblingService gs)
{
_db = db;
_strings = strings;
_images = images;
_fonts = fonts;
_cs = cs;
_cmdHandler = cmdHandler;
_client = client;
_gss = gss;
_gs = gs;
}
public Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) public Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg)
=> PotentialFlowerGeneration(msg); => PotentialFlowerGeneration(msg);
private string GetText(ulong gid, LocStr str) private string GetText(ulong gid, LocStr str)
=> _strings.GetText(str, gid); => strings.GetText(str, gid);
public async Task<bool> ToggleCurrencyGeneration(ulong gid, ulong cid) public async Task<bool> ToggleCurrencyGeneration(ulong gid, ulong cid)
{ {
bool enabled; bool enabled;
await using var uow = _db.GetDbContext(); await using var uow = db.GetDbContext();
if (_generationChannels.Add(cid)) if (_generationChannels.Add(cid))
{ {
await uow.GetTable<GCChannelId>() await uow.GetTable<GCChannelId>()
.InsertOrUpdateAsync(() => new() .InsertOrUpdateAsync(() => new()
{ {
ChannelId = cid, ChannelId = cid,
GuildId = gid GuildId = gid
}, (x) => new() },
(x) => new()
{ {
ChannelId = cid, ChannelId = cid,
GuildId = gid GuildId = gid
}, () => new() },
() => new()
{ {
ChannelId = cid, ChannelId = cid,
GuildId = gid GuildId = gid
@ -87,8 +68,8 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
else else
{ {
await uow.GetTable<GCChannelId>() await uow.GetTable<GCChannelId>()
.Where(x => x.ChannelId == cid && x.GuildId == gid) .Where(x => x.ChannelId == cid && x.GuildId == gid)
.DeleteAsync(); .DeleteAsync();
_generationChannels.TryRemove(cid); _generationChannels.TryRemove(cid);
enabled = false; enabled = false;
@ -99,9 +80,9 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
public async Task<IReadOnlyCollection<GCChannelId>> GetAllGeneratingChannels() public async Task<IReadOnlyCollection<GCChannelId>> GetAllGeneratingChannels()
{ {
await using var uow = _db.GetDbContext(); await using var uow = db.GetDbContext();
return await uow.GetTable<GCChannelId>() return await uow.GetTable<GCChannelId>()
.ToListAsyncLinqToDB(); .ToListAsyncLinqToDB();
} }
/// <summary> /// <summary>
@ -111,7 +92,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
/// <returns>Stream of the currency image</returns> /// <returns>Stream of the currency image</returns>
public async Task<(Stream, string)> GetRandomCurrencyImageAsync(string pass) public async Task<(Stream, string)> GetRandomCurrencyImageAsync(string pass)
{ {
var curImg = await _images.GetCurrencyImageAsync(); var curImg = await images.GetCurrencyImageAsync();
if (curImg is null) if (curImg is null)
return (new MemoryStream(), null); return (new MemoryStream(), null);
@ -142,7 +123,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
pass = pass.TrimTo(10, true).ToLowerInvariant(); pass = pass.TrimTo(10, true).ToLowerInvariant();
using var img = Image.Load<Rgba32>(curImg); using var img = Image.Load<Rgba32>(curImg);
// choose font size based on the image height, so that it's visible // choose font size based on the image height, so that it's visible
var font = _fonts.NotoSans.CreateFont(img.Height / 11.0f, FontStyle.Bold); var font = fonts.NotoSans.CreateFont(img.Height / 11.0f, FontStyle.Bold);
img.Mutate(x => img.Mutate(x =>
{ {
// measure the size of the text to be drawing // measure the size of the text to be drawing
@ -170,13 +151,13 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
// draw the password over the background // draw the password over the background
x.DrawText(new RichTextOptions(font) x.DrawText(new RichTextOptions(font)
{ {
Origin = new(0, 0), Origin = new(0, 0),
TextRuns = TextRuns =
[ [
strikeoutRun strikeoutRun
] ]
}, },
pass, pass,
new SolidBrush(Color.White)); new SolidBrush(Color.White));
}); });
@ -200,7 +181,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
{ {
try try
{ {
var config = _gss.Data; var config = gss.Data;
var lastGeneration = LastGenerations.GetOrAdd(channel.Id, DateTime.MinValue.ToBinary()); var lastGeneration = LastGenerations.GetOrAdd(channel.Id, DateTime.MinValue.ToBinary());
var rng = new EllieRandom(); var rng = new EllieRandom();
@ -219,7 +200,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
if (dropAmount > 0) if (dropAmount > 0)
{ {
var prefix = _cmdHandler.GetPrefix(channel.Guild.Id); var prefix = cmdHandler.GetPrefix(channel.Guild.Id);
var toSend = dropAmount == 1 var toSend = dropAmount == 1
? GetText(channel.GuildId, strs.curgen_sn(config.Currency.Sign)) ? GetText(channel.GuildId, strs.curgen_sn(config.Currency.Sign))
+ " " + " "
@ -228,7 +209,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
+ " " + " "
+ GetText(channel.GuildId, strs.pick_pl(prefix)); + GetText(channel.GuildId, strs.pick_pl(prefix));
var pw = config.Generation.HasPassword ? _gs.GeneratePassword().ToUpperInvariant() : null; var pw = config.Generation.HasPassword ? gs.GeneratePassword().ToUpperInvariant() : null;
IUserMessage sent; IUserMessage sent;
var (stream, ext) = await GetRandomCurrencyImageAsync(pw); var (stream, ext) = await GetRandomCurrencyImageAsync(pw);
@ -238,7 +219,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
var res = await AddPlantToDatabase(channel.GuildId, var res = await AddPlantToDatabase(channel.GuildId,
channel.Id, channel.Id,
_client.CurrentUser.Id, client.CurrentUser.Id,
sent.Id, sent.Id,
dropAmount, dropAmount,
pw, pw,
@ -261,12 +242,12 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
public async Task<long> PickAsync( public async Task<long> PickAsync(
ulong gid, ulong gid,
ITextChannel ch, ITextChannel ch,
ulong uid, ulong userId,
string pass) string pass)
{ {
long amount; long amount;
ulong[] ids; ulong[] ids;
await using (var uow = _db.GetDbContext()) await using (var uow = db.GetDbContext())
{ {
// this method will sum all plants with that password, // this method will sum all plants with that password,
// remove them, and get messageids of the removed plants // remove them, and get messageids of the removed plants
@ -274,8 +255,8 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
pass = pass?.Trim().TrimTo(10, true)?.ToUpperInvariant(); pass = pass?.Trim().TrimTo(10, true)?.ToUpperInvariant();
// gets all plants in this channel with the same password // gets all plants in this channel with the same password
var entries = await uow.GetTable<PlantedCurrency>() var entries = await uow.GetTable<PlantedCurrency>()
.Where(x => x.ChannelId == ch.Id && pass == x.Password) .Where(x => x.ChannelId == ch.Id && pass == x.Password)
.DeleteWithOutputAsync(); .DeleteWithOutputAsync();
if (!entries.Any()) if (!entries.Any())
return 0; return 0;
@ -285,14 +266,24 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
} }
if (amount > 0) if (amount > 0)
await _cs.AddAsync(uid, amount, new("currency", "collect")); {
await cs.AddAsync(userId, amount, new("currency", "collect"));
await quests.ReportActionAsync(userId,
QuestEventType.PlantOrPick,
new()
{
{ "type", "pick" },
});
}
try try
{ {
_ = ch.DeleteMessagesAsync(ids); _ = ch.DeleteMessagesAsync(ids);
} }
catch { } catch
{
}
// return the amount of currency the user picked // return the amount of currency the user picked
return amount; return amount;
@ -308,8 +299,8 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
try try
{ {
// get the text // get the text
var prefix = _cmdHandler.GetPrefix(gid); var prefix = cmdHandler.GetPrefix(gid);
var msgToSend = GetText(gid, strs.planted(Format.Bold(user), amount + _gss.Data.Currency.Sign)); var msgToSend = GetText(gid, strs.planted(Format.Bold(user), amount + gss.Data.Currency.Sign));
if (amount > 1) if (amount > 1)
msgToSend += " " + GetText(gid, strs.pick_pl(prefix)); msgToSend += " " + GetText(gid, strs.pick_pl(prefix));
@ -337,7 +328,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
public async Task<bool> PlantAsync( public async Task<bool> PlantAsync(
ulong gid, ulong gid,
ITextChannel ch, ITextChannel ch,
ulong uid, ulong userId,
string user, string user,
long amount, long amount,
string pass) string pass)
@ -349,19 +340,20 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
return false; return false;
// remove currency from the user who's planting // remove currency from the user who's planting
if (await _cs.RemoveAsync(uid, amount, new("put/collect", "put"))) if (await cs.RemoveAsync(userId, amount, new("put/collect", "put")))
{ {
// try to send the message with the currency image // try to send the message with the currency image
var msgId = await SendPlantMessageAsync(gid, ch, user, amount, pass); var msgId = await SendPlantMessageAsync(gid, ch, user, amount, pass);
if (msgId is null) if (msgId is null)
{ {
// if it fails it will return null, if it returns null, refund // if it fails it will return null, if it returns null, refund
await _cs.AddAsync(uid, amount, new("put/collect", "refund")); await cs.AddAsync(userId, amount, new("put/collect", "refund"));
return false; return false;
} }
// if it doesn't fail, put the plant in the database for other people to pick // if it doesn't fail, put the plant in the database for other people to pick
await AddPlantToDatabase(gid, ch.Id, uid, msgId.Value, amount, pass); await AddPlantToDatabase(gid, ch.Id, userId, msgId.Value, amount, pass);
await quests.ReportActionAsync(userId, QuestEventType.PlantOrPick, new() { { "type", "plant" } });
return true; return true;
} }
@ -379,43 +371,42 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
string pass, string pass,
bool auto = false) bool auto = false)
{ {
await using var uow = _db.GetDbContext(); await using var uow = db.GetDbContext();
PlantedCurrency[] deleted = []; PlantedCurrency[] deleted = [];
if (!string.IsNullOrWhiteSpace(pass) && auto) if (!string.IsNullOrWhiteSpace(pass) && auto)
{ {
deleted = await uow.GetTable<PlantedCurrency>() deleted = await uow.GetTable<PlantedCurrency>()
.Where(x => x.GuildId == gid .Where(x => x.GuildId == gid
&& x.ChannelId == cid && x.ChannelId == cid
&& x.Password != null && x.Password != null
&& x.Password.Length == pass.Length) && x.Password.Length == pass.Length)
.DeleteWithOutputAsync(); .DeleteWithOutputAsync();
} }
var totalDeletedAmount = deleted.Length == 0 ? 0 : deleted.Sum(x => x.Amount); var totalDeletedAmount = deleted.Length == 0 ? 0 : deleted.Sum(x => x.Amount);
await uow.GetTable<PlantedCurrency>() await uow.GetTable<PlantedCurrency>()
.InsertAsync(() => new() .InsertAsync(() => new()
{ {
Amount = totalDeletedAmount + amount, Amount = totalDeletedAmount + amount,
GuildId = gid, GuildId = gid,
ChannelId = cid, ChannelId = cid,
Password = pass, Password = pass,
UserId = uid, UserId = uid,
MessageId = mid, MessageId = mid,
}); });
return (totalDeletedAmount + amount, deleted.Select(x => x.MessageId).ToArray()); return (totalDeletedAmount + amount, deleted.Select(x => x.MessageId).ToArray());
} }
public async Task OnReadyAsync() public async Task OnReadyAsync()
{ {
await using var uow = _db.GetDbContext(); await using var uow = db.GetDbContext();
_generationChannels = (await uow.GetTable<GCChannelId>() _generationChannels = (await uow.GetTable<GCChannelId>()
.Select(x => x.ChannelId) .Select(x => x.ChannelId)
.ToListAsyncLinqToDB()) .ToListAsyncLinqToDB())
.ToHashSet() .ToHashSet()
.ToConcurrentSet(); .ToConcurrentSet();
} }
} }

View file

@ -6,6 +6,7 @@ using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models; using EllieBot.Db.Models;
using EllieBot.Modules.Gambling.Common; using EllieBot.Modules.Gambling.Common;
using EllieBot.Modules.Gambling.Common.Waifu; using EllieBot.Modules.Gambling.Common.Waifu;
using EllieBot.Modules.Games.Quests;
namespace EllieBot.Modules.Gambling.Services; namespace EllieBot.Modules.Gambling.Services;
@ -15,23 +16,23 @@ public class WaifuService : IEService, IReadyExecutor
private readonly ICurrencyService _cs; private readonly ICurrencyService _cs;
private readonly IBotCache _cache; private readonly IBotCache _cache;
private readonly GamblingConfigService _gss; private readonly GamblingConfigService _gss;
private readonly IBotCreds _creds;
private readonly DiscordSocketClient _client; private readonly DiscordSocketClient _client;
private readonly QuestService _quests;
public WaifuService( public WaifuService(
DbService db, DbService db,
ICurrencyService cs, ICurrencyService cs,
IBotCache cache, IBotCache cache,
GamblingConfigService gss, GamblingConfigService gss,
IBotCreds creds, DiscordSocketClient client,
DiscordSocketClient client) QuestService quests)
{ {
_db = db; _db = db;
_cs = cs; _cs = cs;
_cache = cache; _cache = cache;
_gss = gss; _gss = gss;
_creds = creds;
_client = client; _client = client;
_quests = quests;
} }
public async Task<bool> WaifuTransfer(IUser owner, ulong waifuId, IUser newOwner) public async Task<bool> WaifuTransfer(IUser owner, ulong waifuId, IUser newOwner)
@ -411,6 +412,8 @@ public class WaifuService : IEService, IReadyExecutor
w.Price += (long)(totalValue * _gss.Data.Waifu.Multipliers.GiftEffect); w.Price += (long)(totalValue * _gss.Data.Waifu.Multipliers.GiftEffect);
else else
w.Price += totalValue / 2; w.Price += totalValue / 2;
await _quests.ReportActionAsync(from.Id, QuestEventType.WaifuGiftSent);
} }
else else
{ {

View file

@ -4,6 +4,7 @@ using LinqToDB;
using LinqToDB.EntityFrameworkCore; using LinqToDB.EntityFrameworkCore;
using EllieBot.Modules.Administration; using EllieBot.Modules.Administration;
using EllieBot.Modules.Administration.Services; using EllieBot.Modules.Administration.Services;
using EllieBot.Modules.Games.Quests;
namespace EllieBot.Modules.Games.Fish; namespace EllieBot.Modules.Games.Fish;
@ -11,7 +12,8 @@ public sealed class FishService(
FishConfigService fcs, FishConfigService fcs,
IBotCache cache, IBotCache cache,
DbService db, DbService db,
INotifySubscriber notify INotifySubscriber notify,
QuestService quests
) )
: IEService : IEService
{ {
@ -91,6 +93,15 @@ public sealed class FishService(
} }
} }
await quests.ReportActionAsync(userId,
QuestEventType.FishCaught,
new()
{
{ "fish", result.Fish.Name },
{ "type", typeRoll < nothingChance + fishChance ? "fish" : "trash" },
{ "stars", result.Stars.ToString() }
});
return result; return result;
} }

View file

@ -2,6 +2,7 @@
using EllieBot.Common.ModuleBehaviors; using EllieBot.Common.ModuleBehaviors;
using EllieBot.Modules.Games.Services; using EllieBot.Modules.Games.Services;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using EllieBot.Modules.Games.Quests;
namespace EllieBot.Modules.Games.Hangman; namespace EllieBot.Modules.Games.Hangman;
@ -13,6 +14,7 @@ public sealed class HangmanService : IHangmanService, IExecNoCommand
private readonly GamesConfigService _gcs; private readonly GamesConfigService _gcs;
private readonly ICurrencyService _cs; private readonly ICurrencyService _cs;
private readonly IMemoryCache _cdCache; private readonly IMemoryCache _cdCache;
private readonly QuestService _quests;
private readonly object _locker = new(); private readonly object _locker = new();
public HangmanService( public HangmanService(
@ -20,13 +22,15 @@ public sealed class HangmanService : IHangmanService, IExecNoCommand
IMessageSenderService sender, IMessageSenderService sender,
GamesConfigService gcs, GamesConfigService gcs,
ICurrencyService cs, ICurrencyService cs,
IMemoryCache cdCache) IMemoryCache cdCache,
QuestService quests)
{ {
_source = source; _source = source;
_sender = sender; _sender = sender;
_gcs = gcs; _gcs = gcs;
_cs = cs; _cs = cs;
_cdCache = cdCache; _cdCache = cdCache;
_quests = quests;
} }
public bool StartHangman(ulong channelId, string? category, [NotNullWhen(true)] out HangmanGame.State? state) public bool StartHangman(ulong channelId, string? category, [NotNullWhen(true)] out HangmanGame.State? state)
@ -104,6 +108,9 @@ public sealed class HangmanService : IHangmanService, IExecNoCommand
if (rew > 0) if (rew > 0)
await _cs.AddAsync(msg.Author, rew, new("hangman", "win")); await _cs.AddAsync(msg.Author, rew, new("hangman", "win"));
if (state.GuessResult == HangmanGame.GuessResult.Win)
await _quests.ReportActionAsync(msg.Author.Id, QuestEventType.GameWon, new() { { "game", "hangman" } });
await SendState((ITextChannel)msg.Channel, msg.Author, msg.Content, state); await SendState((ITextChannel)msg.Channel, msg.Author, msg.Content, state);
} }

View file

@ -3,6 +3,7 @@ using LinqToDB.Data;
using LinqToDB.EntityFrameworkCore; using LinqToDB.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors; using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models; using EllieBot.Db.Models;
using EllieBot.Modules.Games.Quests;
using SixLabors.ImageSharp.ColorSpaces; using SixLabors.ImageSharp.ColorSpaces;
using SixLabors.ImageSharp.ColorSpaces.Conversion; using SixLabors.ImageSharp.ColorSpaces.Conversion;
using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.PixelFormats;
@ -17,6 +18,7 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
private readonly IBotCache _cache; private readonly IBotCache _cache;
private readonly DiscordSocketClient _client; private readonly DiscordSocketClient _client;
private readonly ICurrencyService _cs; private readonly ICurrencyService _cs;
private readonly QuestService _quests;
public const int CANVAS_WIDTH = 500; public const int CANVAS_WIDTH = 500;
public const int CANVAS_HEIGHT = 350; public const int CANVAS_HEIGHT = 350;
@ -26,12 +28,14 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
DbService db, DbService db,
IBotCache cache, IBotCache cache,
DiscordSocketClient client, DiscordSocketClient client,
ICurrencyService cs) ICurrencyService cs,
QuestService quests)
{ {
_db = db; _db = db;
_cache = cache; _cache = cache;
_client = client; _client = client;
_cs = cs; _cs = cs;
_quests = quests;
} }
public async Task OnReadyAsync() public async Task OnReadyAsync()
@ -59,23 +63,23 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
} }
await uow.GetTable<NCPixel>() await uow.GetTable<NCPixel>()
.BulkCopyAsync(toAdd.Select(x => .BulkCopyAsync(toAdd.Select(x =>
{ {
var clr = ColorSpaceConverter.ToRgb(new Hsv(((float)Random.Shared.NextDouble() * 360), var clr = ColorSpaceConverter.ToRgb(new Hsv(((float)Random.Shared.NextDouble() * 360),
(float)(0.5 + (Random.Shared.NextDouble() * 0.49)), (float)(0.5 + (Random.Shared.NextDouble() * 0.49)),
(float)(0.4 + (Random.Shared.NextDouble() / 5 + (x % 100 * 0.2))))) (float)(0.4 + (Random.Shared.NextDouble() / 5 + (x % 100 * 0.2)))))
.ToVector3(); .ToVector3();
var packed = new Rgba32(clr).PackedValue; var packed = new Rgba32(clr).PackedValue;
return new NCPixel() return new NCPixel()
{ {
Color = packed, Color = packed,
Price = 1, Price = 1,
Position = x, Position = x,
Text = "", Text = "",
OwnerId = 0 OwnerId = 0
}; };
})); }));
} }
@ -83,9 +87,9 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
{ {
await using var uow = _db.GetDbContext(); await using var uow = _db.GetDbContext();
var colors = await uow.GetTable<NCPixel>() var colors = await uow.GetTable<NCPixel>()
.OrderBy(x => x.Position) .OrderBy(x => x.Position)
.Select(x => x.Color) .Select(x => x.Color)
.ToArrayAsyncLinqToDB(); .ToArrayAsyncLinqToDB();
return colors; return colors;
} }
@ -121,15 +125,15 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
{ {
await using var uow = _db.GetDbContext(); await using var uow = _db.GetDbContext();
var updates = await uow.GetTable<NCPixel>() var updates = await uow.GetTable<NCPixel>()
.Where(x => x.Position == position && x.Price <= price) .Where(x => x.Position == position && x.Price <= price)
.UpdateAsync(old => new NCPixel() .UpdateAsync(old => new NCPixel()
{ {
Position = position, Position = position,
Color = color, Color = color,
Text = text, Text = text,
OwnerId = userId, OwnerId = userId,
Price = price + 1 Price = price + 1
}); });
success = updates > 0; success = updates > 0;
} }
catch catch
@ -140,6 +144,10 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
{ {
await wallet.Add(price, new("canvas", "pixel-refund", $"Refund pixel {new kwum(position)} purchase")); await wallet.Add(price, new("canvas", "pixel-refund", $"Refund pixel {new kwum(position)} purchase"));
} }
else
{
await _quests.ReportActionAsync(userId, QuestEventType.PixelSet);
}
return success ? SetPixelResult.Success : SetPixelResult.InsufficientPayment; return success ? SetPixelResult.Success : SetPixelResult.InsufficientPayment;
} }
@ -152,14 +160,14 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
await using var uow = _db.GetDbContext(); await using var uow = _db.GetDbContext();
await uow.GetTable<NCPixel>().DeleteAsync(); await uow.GetTable<NCPixel>().DeleteAsync();
await uow.GetTable<NCPixel>() await uow.GetTable<NCPixel>()
.BulkCopyAsync(colors.Select((x, i) => new NCPixel() .BulkCopyAsync(colors.Select((x, i) => new NCPixel()
{ {
Color = x, Color = x,
Price = INITIAL_PRICE, Price = INITIAL_PRICE,
Position = i, Position = i,
Text = "", Text = "",
OwnerId = 0 OwnerId = 0
})); }));
return true; return true;
} }
@ -190,12 +198,12 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
await using var uow = _db.GetDbContext(); await using var uow = _db.GetDbContext();
return await uow.GetTable<NCPixel>() return await uow.GetTable<NCPixel>()
.Where(x => x.Position % CANVAS_WIDTH >= (position % CANVAS_WIDTH) - 2 .Where(x => x.Position % CANVAS_WIDTH >= (position % CANVAS_WIDTH) - 2
&& x.Position % CANVAS_WIDTH <= (position % CANVAS_WIDTH) + 2 && x.Position % CANVAS_WIDTH <= (position % CANVAS_WIDTH) + 2
&& x.Position / CANVAS_WIDTH >= (position / CANVAS_WIDTH) - 2 && x.Position / CANVAS_WIDTH >= (position / CANVAS_WIDTH) - 2
&& x.Position / CANVAS_WIDTH <= (position / CANVAS_WIDTH) + 2) && x.Position / CANVAS_WIDTH <= (position / CANVAS_WIDTH) + 2)
.OrderBy(x => x.Position) .OrderBy(x => x.Position)
.ToArrayAsyncLinqToDB(); .ToArrayAsyncLinqToDB();
} }
public int GetHeight() public int GetHeight()

View file

@ -0,0 +1,9 @@
namespace EllieBot.Modules.Games.Quests;
public record class Quest(
QuestIds Id,
string Name,
string Description,
QuestEventType TriggerEvent,
int RequiredAmount
);

View file

@ -0,0 +1,38 @@
namespace EllieBot.Modules.Games.Quests;
public class QuestCommands : EllieModule<QuestService>
{
[Cmd]
public async Task QuestLog()
{
var now = DateTime.UtcNow;
var quests = await _service.GetUserQuestsAsync(ctx.User.Id, now);
var embed = CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.quest_log));
var allDone = quests.All(x => x.UserQuest.IsCompleted);
var tmrw = now.AddDays(1).Date;
var desc = GetText(strs.dailies_reset(TimestampTag.FromDateTime(tmrw, TimestampTagStyles.Relative)));
if (allDone)
desc = GetText(strs.dailies_done) + "\n" + desc;
embed.WithDescription(desc);
foreach (var res in quests)
{
if (res.Quest is null)
continue;
embed.AddField(
(res.UserQuest.IsCompleted ? IQuest.COMPLETED : IQuest.INCOMPLETE) + " " + res.Quest.Name,
$"{res.Quest.Desc}\n\n" +
res.Quest.ToString(res.UserQuest.Progress),
true);
}
await Response().Embed(embed).SendAsync();
}
}

View file

@ -0,0 +1,15 @@
namespace EllieBot.Modules.Games.Quests;
public class QuestEvent
{
public QuestEventType EventType { get; }
public ulong UserId { get; }
public Dictionary<string, string> Metadata { get; }
public QuestEvent(QuestEventType eventType, ulong userId, Dictionary<string, string>? metadata = null)
{
EventType = eventType;
UserId = userId;
Metadata = metadata ?? new Dictionary<string, string>();
}
}

View file

@ -0,0 +1,15 @@
namespace EllieBot.Modules.Games.Quests;
public enum QuestEventType
{
CommandUsed,
GameWon,
BetPlaced,
FishCaught,
PixelSet,
RaceJoined,
BankAction,
PlantOrPick,
Give,
WaifuGiftSent
}

View file

@ -0,0 +1,16 @@
namespace EllieBot.Modules.Games.Quests;
public enum QuestIds
{
HangmanWin,
Bet,
WaifuGift,
CatchFish,
SetPixels,
JoinAnimalRace,
BankDeposit,
CheckBetting,
PlantPick,
GiveFlowers,
WellInformed
}

View file

@ -0,0 +1,65 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class BankerQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.BankDeposit;
public string Name
=> "Banker";
public string Desc
=> "Perform bank actions";
public string ProgDesc
=> "";
public QuestEventType EventType
=> QuestEventType.BankAction;
public long RequiredAmount
=> 0b111;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (!metadata.TryGetValue("type", out var type))
return oldProgress;
var progress = oldProgress;
if (type == "balance")
progress |= 0b001;
else if (type == "deposit")
progress |= 0b010;
else if (type == "withdraw")
progress |= 0b100;
return progress;
}
public string ToString(long progress)
{
var msg = "";
var emoji = IQuest.INCOMPLETE;
if ((progress & 0b001) == 0b001)
emoji = IQuest.COMPLETED;
msg += emoji + " checked bank balance";
emoji = IQuest.INCOMPLETE;
if ((progress & 0b010) == 0b010)
emoji = IQuest.COMPLETED;
msg += "\n" + emoji + " made a deposit";
emoji = IQuest.INCOMPLETE;
if ((progress & 0b100) == 0b100)
emoji = IQuest.COMPLETED;
msg += "\n" + emoji + " made a withdrawal";
return msg;
}
}

View file

@ -0,0 +1,31 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class BetFlowersQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.Bet;
public string Name
=> "Flower Gambler";
public string Desc
=> "Bet 300 flowers";
public string ProgDesc
=> "flowers bet";
public QuestEventType EventType
=> QuestEventType.BetPlaced;
public long RequiredAmount
=> 300;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (!metadata.TryGetValue("amount", out var amountStr)
|| !long.TryParse(amountStr, out var amount))
return oldProgress;
return oldProgress + amount;
}
}

View file

@ -0,0 +1,27 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class BetQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.Bet;
public string Name
=> "High Roller";
public string Desc
=> "Place 10 bets";
public string ProgDesc
=> "bets placed";
public QuestEventType EventType
=> QuestEventType.BetPlaced;
public long RequiredAmount
=> 10;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
return oldProgress + 1;
}
}

View file

@ -0,0 +1,30 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class CatchFishQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.CatchFish;
public string Name
=> "Fisherman";
public string Desc
=> "Catch 5 fish";
public string ProgDesc
=> "fish caught";
public QuestEventType EventType
=> QuestEventType.FishCaught;
public long RequiredAmount
=> 5;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (metadata.TryGetValue("type", out var type) && type == "fish")
return oldProgress + 1;
return oldProgress;
}
}

View file

@ -0,0 +1,32 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class CatchQualityQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.CatchFish;
public string Name
=> "Master Angler";
public string Desc
=> "Catch a fish or an item rated 3 stars or above.";
public string ProgDesc
=> "3+ star fish caught";
public QuestEventType EventType
=> QuestEventType.FishCaught;
public long RequiredAmount
=> 1;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (metadata.TryGetValue("stars", out var quality)
&& int.TryParse(quality, out var q)
&& q >= 3)
return oldProgress + 1;
return oldProgress;
}
}

View file

@ -0,0 +1,30 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class CatchTrashQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.CatchFish;
public string Name
=> "Environmentalist";
public string Desc
=> "Catch 5 trash items while fishing";
public string ProgDesc
=> "items caught";
public QuestEventType EventType
=> QuestEventType.FishCaught;
public long RequiredAmount
=> 5;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (metadata.TryGetValue("type", out var type) && type == "trash")
return oldProgress + 1;
return oldProgress;
}
}

View file

@ -0,0 +1,64 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class CheckLeaderboardsQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.CheckBetting;
public string Name
=> "Leaderboard Enthusiast";
public string Desc
=> "Check lb, xplb and waifulb";
public string ProgDesc
=> "";
public QuestEventType EventType
=> QuestEventType.CommandUsed;
public long RequiredAmount
=> 0b111;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (!metadata.TryGetValue("name", out var name))
return oldProgress;
var progress = oldProgress;
if (name == "leaderboard")
progress |= 0b001;
else if (name == "xpleaderboard")
progress |= 0b010;
else if (name == "waifulb")
progress |= 0b100;
return progress;
}
public string ToString(long progress)
{
var msg = "";
var emoji = IQuest.INCOMPLETE;
if ((progress & 0b001) == 0b001)
emoji = IQuest.COMPLETED;
msg += emoji + " flower lb seen\n";
emoji = IQuest.INCOMPLETE;
if ((progress & 0b010) == 0b010)
emoji = IQuest.COMPLETED;
msg += emoji + " xp lb seen\n";
emoji = IQuest.INCOMPLETE;
if ((progress & 0b100) == 0b100)
emoji = IQuest.COMPLETED;
msg += emoji + " waifu lb seen";
return msg;
}
}

View file

@ -0,0 +1,27 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class GiftWaifuQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.WaifuGift;
public string Name
=> "Generous Gifter";
public string Desc
=> "Gift a waifu";
public string ProgDesc
=> "waifus gifted";
public QuestEventType EventType
=> QuestEventType.WaifuGiftSent;
public long RequiredAmount
=> 1;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
return oldProgress + 1;
}
}

View file

@ -0,0 +1,31 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class GiveFlowersQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.GiveFlowers;
public string Name
=> "Sharing is Caring";
public string Desc
=> "Give 10 flowers to someone";
public string ProgDesc
=> "flowers given";
public QuestEventType EventType
=> QuestEventType.Give;
public long RequiredAmount
=> 10;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (!metadata.TryGetValue("amount", out var amountStr)
|| !long.TryParse(amountStr, out var amount))
return oldProgress;
return oldProgress + amount;
}
}

View file

@ -0,0 +1,30 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class HangmanWinQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.HangmanWin;
public string Name
=> "Hangman Champion";
public string Desc
=> "Win a game of Hangman";
public string ProgDesc
=> "hangman games won";
public QuestEventType EventType
=> QuestEventType.GameWon;
public long RequiredAmount
=> 1;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (!metadata.TryGetValue("game", out var value))
return oldProgress;
return value == "hangman" ? oldProgress + 1 : oldProgress;
}
}

View file

@ -0,0 +1,33 @@
using EllieBot.Db.Models;
namespace EllieBot.Modules.Games.Quests;
public interface IQuest
{
QuestIds QuestId { get; }
string Name { get; }
string Desc { get; }
string ProgDesc { get; }
QuestEventType EventType { get; }
long RequiredAmount { get; }
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress);
public virtual string ToString(long progress)
=> GetEmoji(progress, RequiredAmount) + $" [{progress}/{RequiredAmount}] " + ProgDesc;
public static string GetEmoji(long progress, long requiredAmount)
=> progress >= requiredAmount
? COMPLETED
: INCOMPLETE;
/// <summary>
/// Completed Emoji
/// </summary>
public const string COMPLETED = "\\✅";
/// <summary>
/// Incomplete Emoji
/// </summary>
public const string INCOMPLETE = "\\❌";
}

View file

@ -0,0 +1,27 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class JoinAnimalRaceQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.JoinAnimalRace;
public string Name
=> "Race Participant";
public string Desc
=> "Join an animal race";
public string ProgDesc
=> "races joined";
public QuestEventType EventType
=> QuestEventType.RaceJoined;
public long RequiredAmount
=> 1;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
return oldProgress + 1;
}
}

View file

@ -0,0 +1,61 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class PlantPickQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.PlantPick;
public string Name
=> "Gardener";
public string Desc
=> "pick and plant";
public string ProgDesc
=> "";
public QuestEventType EventType
=> QuestEventType.PlantOrPick;
public long RequiredAmount
=> 0b11;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (!metadata.TryGetValue("type", out var val))
return oldProgress;
if (val == "plant")
{
oldProgress |= 0b10;
return oldProgress;
}
if (val == "pick")
{
oldProgress |= 0b01;
return oldProgress;
}
return oldProgress;
}
public string ToString(long progress)
{
var msg = "";
var emoji = IQuest.INCOMPLETE;
if ((progress & 0b01) == 0b01)
emoji = IQuest.COMPLETED;
msg += emoji + " picked flowers\n";
emoji = IQuest.INCOMPLETE;
if ((progress & 0b10) == 0b10)
emoji = IQuest.COMPLETED;
msg += emoji + " planted flowers";
return msg;
}
}

View file

@ -0,0 +1,27 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class SetPixelsQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.SetPixels;
public string Name
=> "Pixel Artist";
public string Desc
=> "Set 3 pixels";
public string ProgDesc
=> "pixels set";
public QuestEventType EventType
=> QuestEventType.PixelSet;
public long RequiredAmount
=> 3;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
return oldProgress + 1;
}
}

View file

@ -0,0 +1,64 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class WellInformedQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.WellInformed;
public string Name
=> "Well Informed";
public string Desc
=> "Check your flower stats";
public string ProgDesc
=> "";
public QuestEventType EventType
=> QuestEventType.CommandUsed;
public long RequiredAmount
=> 0b111;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (!metadata.TryGetValue("name", out var type))
return oldProgress;
var progress = oldProgress;
if (type == "cash")
progress |= 0b001;
else if (type == "rakeback")
progress |= 0b010;
else if (type == "betstats")
progress |= 0b100;
return progress;
}
public string ToString(long progress)
{
var msg = "";
var emoji = IQuest.INCOMPLETE;
if ((progress & 0b001) == 0b001)
emoji = IQuest.COMPLETED;
msg += emoji + " checked cash\n";
emoji = IQuest.INCOMPLETE;
if ((progress & 0b010) == 0b010)
emoji = IQuest.COMPLETED;
msg += emoji + " checked rakeback\n";
emoji = IQuest.INCOMPLETE;
if ((progress & 0b100) == 0b100)
emoji = IQuest.COMPLETED;
msg += emoji + " checked bet stats";
return msg;
}
}

View file

@ -0,0 +1,206 @@
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.CodeAnalysis.Operations;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
namespace EllieBot.Modules.Games.Quests;
public sealed class QuestService(
DbService db,
IBotCache botCache,
IMessageSenderService sender,
DiscordSocketClient client
) : IEService, IExecPreCommand
{
private readonly EllieRandom rng = new();
private readonly IQuest[] _availableQuests =
[
new HangmanWinQuest(),
new PlantPickQuest(),
new BetQuest(),
new BetFlowersQuest(),
new GiftWaifuQuest(),
new CatchFishQuest(),
new SetPixelsQuest(),
new JoinAnimalRaceQuest(),
new BankerQuest(),
new CheckLeaderboardsQuest(),
new WellInformedQuest(),
];
private const int MAX_QUESTS_PER_DAY = 3;
private TypedKey<bool> UserHasQuestsKey(ulong userId)
=> new($"daily:generated:{userId}");
private TypedKey<bool> UserCompletedDailiesKey(ulong userId)
=> new($"daily:completed:{userId}");
public Task ReportActionAsync(
ulong userId,
QuestEventType eventType,
Dictionary<string, string>? metadata = null)
{
// don't block any caller
_ = Task.Run(async () =>
{
Log.Information("Action reported by {UserId}: {EventType} {Metadata}",
userId,
eventType,
metadata.ToJson());
metadata ??= new();
var now = DateTime.UtcNow;
var alreadyDone = await botCache.GetAsync(UserCompletedDailiesKey(userId));
if (alreadyDone.IsT0)
return;
var userQuests = await GetUserQuestsAsync(userId, now);
foreach (var (q, uq) in userQuests)
{
// deleted quest
if (q is null)
continue;
// user already completed or incorrect event
if (uq.IsCompleted || q.EventType != eventType)
continue;
var newProgress = q.TryUpdateProgress(metadata, uq.Progress);
// user already did that part of the quest
if (newProgress == uq.Progress)
continue;
var isCompleted = newProgress >= q.RequiredAmount;
await using var uow = db.GetDbContext();
await uow.GetTable<UserQuest>()
.Where(x => x.UserId == userId && x.QuestId == q.QuestId && x.QuestNumber == uq.QuestNumber)
.Set(x => x.Progress, newProgress)
.Set(x => x.IsCompleted, isCompleted)
.UpdateAsync();
uq.IsCompleted = isCompleted;
if (userQuests.All(x => x.UserQuest.IsCompleted))
{
var timeUntilTomorrow = now.Date.AddDays(1) - DateTime.UtcNow;
if (!await botCache.AddAsync(
UserCompletedDailiesKey(userId),
true,
expiry: timeUntilTomorrow))
return;
try
{
var user = await client.GetUserAsync(userId);
await sender
.Response(user)
.Confirm(strs.dailies_done)
.SendAsync();
}
catch
{
// we don't really care if the user receives it
}
break;
}
}
});
return Task.CompletedTask;
}
public async Task<IReadOnlyList<(IQuest? Quest, UserQuest UserQuest)>> GetUserQuestsAsync(
ulong userId,
DateTime now)
{
var today = now.Date;
await EnsureUserDailiesAsync(userId, today);
await using var uow = db.GetDbContext();
var quests = await uow.GetTable<UserQuest>()
.Where(x => x.UserId == userId && x.DateAssigned == today)
.ToListAsync();
return quests
.Select(x => (_availableQuests.FirstOrDefault(q => q.QuestId == x.QuestId), x))
.Select(x => x!)
.ToList();
}
private async Task EnsureUserDailiesAsync(ulong userId, DateTime date)
{
var today = date.Date;
var timeUntilTomorrow = today.AddDays(1) - DateTime.UtcNow;
if (!await botCache.AddAsync(UserHasQuestsKey(userId), true, expiry: timeUntilTomorrow))
return;
await using var uow = db.GetDbContext();
var newQuests = GenerateDailyQuestsAsync(userId);
for (var i = 0; i < MAX_QUESTS_PER_DAY; i++)
{
await uow.GetTable<UserQuest>()
.InsertOrUpdateAsync(() => new()
{
UserId = userId,
QuestNumber = i,
DateAssigned = today,
IsCompleted = false,
QuestId = newQuests[i].QuestId,
Progress = 0,
},
old => new()
{
},
() => new()
{
UserId = userId,
QuestNumber = i,
DateAssigned = today
});
}
}
private IReadOnlyList<IQuest> GenerateDailyQuestsAsync(ulong userId)
{
return _availableQuests
.ToList()
.Shuffle()
.Take(MAX_QUESTS_PER_DAY)
.ToList();
}
public int Priority
=> int.MinValue;
public async Task<bool> ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command)
{
var cmdName = command.Name.ToLowerInvariant();
await ReportActionAsync(
context.User.Id,
QuestEventType.CommandUsed,
new()
{
{ "name", cmdName }
});
return false;
}
public async Task<bool> UserCompletedDailies(ulong userId)
{
var result = await botCache.GetAsync(UserCompletedDailiesKey(userId));
return result.IsT0;
}
}

View file

@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using EllieBot.Modules.Games.Quests;
namespace EllieBot.Db.Models;
public class UserQuest
{
[Key]
public int Id { get; set; }
public int QuestNumber { get; set; }
public ulong UserId { get; set; }
public QuestIds QuestId { get; set; }
public int Progress { get; set; }
public bool IsCompleted { get; set; }
public DateTime DateAssigned { get; set; }
}

View file

@ -12,7 +12,7 @@ public class Owner(VoteRewardService vrs) : EllieModule
await ctx.OkAsync(); await ctx.OkAsync();
} }
private static CancellationTokenSource _cts = null; private static CancellationTokenSource? _cts = null;
[Cmd] [Cmd]
public async Task MassPing() public async Task MassPing()
@ -22,6 +22,8 @@ public class Owner(VoteRewardService vrs) : EllieModule
await t.CancelAsync(); await t.CancelAsync();
} }
_cts = new();
try try
{ {
var users = await ctx.Guild.GetUsersAsync().Fmap(u => u.Where(x => !x.IsBot).ToArray()); var users = await ctx.Guild.GetUsersAsync().Fmap(u => u.Where(x => !x.IsBot).ToArray());

View file

@ -38,7 +38,7 @@ public interface ICurrencyService
IUser user, IUser user,
long amount, long amount,
TxData? txData); TxData? txData);
Task<IReadOnlyList<DiscordUser>> GetTopRichest(ulong ignoreId, int page = 0, int perPage = 9); Task<IReadOnlyList<DiscordUser>> GetTopRichest(ulong ignoreId, int page = 0, int perPage = 9);
Task<IReadOnlyList<CurrencyTransaction>> GetTransactionsAsync( Task<IReadOnlyList<CurrencyTransaction>> GetTransactionsAsync(
@ -47,4 +47,14 @@ public interface ICurrencyService
int perPage = 15); int perPage = 15);
Task<int> GetTransactionsCountAsync(ulong userId); Task<int> GetTransactionsCountAsync(ulong userId);
Task<bool> TransferAsync(
IMessageSenderService sender,
IUser from,
IUser to,
long amount,
string? note,
string formattedAmount);
Task<long> GetBalanceAsync(ulong userId);
} }

View file

@ -6,21 +6,12 @@ using EllieBot.Services.Currency;
namespace EllieBot.Services; namespace EllieBot.Services;
public sealed class CurrencyService : ICurrencyService, IEService public sealed class CurrencyService(DbService db, ITxTracker txTracker) : ICurrencyService, IEService
{ {
private readonly DbService _db;
private readonly ITxTracker _txTracker;
public CurrencyService(DbService db, ITxTracker txTracker)
{
_db = db;
_txTracker = txTracker;
}
public Task<IWallet> GetWalletAsync(ulong userId, CurrencyType type = CurrencyType.Default) public Task<IWallet> GetWalletAsync(ulong userId, CurrencyType type = CurrencyType.Default)
{ {
if (type == CurrencyType.Default) if (type == CurrencyType.Default)
return Task.FromResult<IWallet>(new DefaultWallet(userId, _db)); return Task.FromResult<IWallet>(new DefaultWallet(userId, db));
throw new ArgumentOutOfRangeException(nameof(type)); throw new ArgumentOutOfRangeException(nameof(type));
} }
@ -53,16 +44,16 @@ public sealed class CurrencyService : ICurrencyService, IEService
{ {
if (type == CurrencyType.Default) if (type == CurrencyType.Default)
{ {
await using var ctx = _db.GetDbContext(); await using var ctx = db.GetDbContext();
await ctx await ctx
.GetTable<DiscordUser>() .GetTable<DiscordUser>()
.Where(x => userIds.Contains(x.UserId)) .Where(x => userIds.Contains(x.UserId))
.UpdateAsync(du => new() .UpdateAsync(du => new()
{ {
CurrencyAmount = du.CurrencyAmount >= amount CurrencyAmount = du.CurrencyAmount >= amount
? du.CurrencyAmount - amount ? du.CurrencyAmount - amount
: 0 : 0
}); });
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
return; return;
} }
@ -77,7 +68,7 @@ public sealed class CurrencyService : ICurrencyService, IEService
{ {
var wallet = await GetWalletAsync(userId); var wallet = await GetWalletAsync(userId);
await wallet.Add(amount, txData); await wallet.Add(amount, txData);
await _txTracker.TrackAdd(userId, amount, txData); await txTracker.TrackAdd(userId, amount, txData);
} }
public async Task AddAsync( public async Task AddAsync(
@ -97,7 +88,7 @@ public sealed class CurrencyService : ICurrencyService, IEService
var wallet = await GetWalletAsync(userId); var wallet = await GetWalletAsync(userId);
var result = await wallet.Take(amount, txData); var result = await wallet.Take(amount, txData);
if (result) if (result)
await _txTracker.TrackRemove(userId, amount, txData); await txTracker.TrackRemove(userId, amount, txData);
return result; return result;
} }
@ -109,7 +100,7 @@ public sealed class CurrencyService : ICurrencyService, IEService
public async Task<IReadOnlyList<DiscordUser>> GetTopRichest(ulong ignoreId, int page = 0, int perPage = 9) public async Task<IReadOnlyList<DiscordUser>> GetTopRichest(ulong ignoreId, int page = 0, int perPage = 9)
{ {
await using var uow = _db.GetDbContext(); await using var uow = db.GetDbContext();
return await uow.Set<DiscordUser>().GetTopRichest(ignoreId, page, perPage); return await uow.Set<DiscordUser>().GetTopRichest(ignoreId, page, perPage);
} }
@ -118,23 +109,63 @@ public sealed class CurrencyService : ICurrencyService, IEService
int page, int page,
int perPage = 15) int perPage = 15)
{ {
await using var uow = _db.GetDbContext(); await using var uow = db.GetDbContext();
var trs = await uow.GetTable<CurrencyTransaction>() var trs = await uow.GetTable<CurrencyTransaction>()
.Where(x => x.UserId == userId) .Where(x => x.UserId == userId)
.OrderByDescending(x => x.DateAdded) .OrderByDescending(x => x.DateAdded)
.Skip(perPage * page) .Skip(perPage * page)
.Take(perPage) .Take(perPage)
.ToListAsyncLinqToDB(); .ToListAsyncLinqToDB();
return trs; return trs;
} }
public async Task<int> GetTransactionsCountAsync(ulong userId) public async Task<int> GetTransactionsCountAsync(ulong userId)
{ {
await using var uow = _db.GetDbContext(); await using var uow = db.GetDbContext();
return await uow.GetTable<CurrencyTransaction>() return await uow.GetTable<CurrencyTransaction>()
.Where(x => x.UserId == userId) .Where(x => x.UserId == userId)
.CountAsyncLinqToDB(); .CountAsyncLinqToDB();
}
public async Task<bool> TransferAsync(
IMessageSenderService sender,
IUser from,
IUser to,
long amount,
string note,
string formattedAmount)
{
var fromWallet = await GetWalletAsync(from.Id);
var toWallet = await GetWalletAsync(to.Id);
var extra = new TxData("gift", from.ToString()!, note, from.Id);
if (await fromWallet.Transfer(amount, toWallet, extra))
{
try
{
await sender.Response(to)
.Confirm(string.IsNullOrWhiteSpace(note)
? $"Received {formattedAmount} from {from} "
: $"Received {formattedAmount} from {from}: {note}")
.SendAsync();
}
catch
{
//ignored
}
return true;
}
return false;
}
public async Task<long> GetBalanceAsync(ulong userId)
{
var wallet = await GetWalletAsync(userId);
return await wallet.GetBalance();
} }
} }

View file

@ -4,45 +4,8 @@ namespace EllieBot.Services;
public static class CurrencyServiceExtensions public static class CurrencyServiceExtensions
{ {
public static async Task<long> GetBalanceAsync(this ICurrencyService cs, ulong userId)
{
var wallet = await cs.GetWalletAsync(userId);
return await wallet.GetBalance();
}
// FUTURE should be a transaction // FUTURE should be a transaction
public static async Task<bool> TransferAsync(
this ICurrencyService cs,
IMessageSenderService sender,
IUser from,
IUser to,
long amount,
string? note,
string formattedAmount)
{
var fromWallet = await cs.GetWalletAsync(from.Id);
var toWallet = await cs.GetWalletAsync(to.Id);
var extra = new TxData("gift", from.ToString()!, note, from.Id);
if (await fromWallet.Transfer(amount, toWallet, extra))
{
try
{
await sender.Response(to)
.Confirm(string.IsNullOrWhiteSpace(note)
? $"Received {formattedAmount} from {from} "
: $"Received {formattedAmount} from {from}: {note}")
.SendAsync();
}
catch
{
//ignored
}
return true;
}
return false;
}
} }

View file

@ -8,10 +8,15 @@ using EllieBot.Modules.Gambling;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using EllieBot.Modules.Administration; using EllieBot.Modules.Administration;
using EllieBot.Modules.Gambling.Services; using EllieBot.Modules.Gambling.Services;
using EllieBot.Modules.Games.Quests;
namespace EllieBot.Services; namespace EllieBot.Services;
public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor public sealed class GamblingTxTracker(
DbService db,
QuestService quests
)
: ITxTracker, IEService, IReadyExecutor
{ {
private static readonly IReadOnlySet<string> _gamblingTypes = new HashSet<string>(new[] private static readonly IReadOnlySet<string> _gamblingTypes = new HashSet<string>(new[]
{ {
@ -21,17 +26,6 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
private NonBlocking.ConcurrentDictionary<string, (decimal Bet, decimal PaidOut)> globalStats = new(); private NonBlocking.ConcurrentDictionary<string, (decimal Bet, decimal PaidOut)> globalStats = new();
private ConcurrentBag<UserBetStats> userStats = new(); private ConcurrentBag<UserBetStats> userStats = new();
private readonly DbService _db;
private readonly GamblingConfigService _gcs;
private readonly INotifySubscriber _notify;
public GamblingTxTracker(DbService db, GamblingConfigService gcs, INotifySubscriber notify)
{
_db = db;
_gcs = gcs;
_notify = notify;
}
public async Task OnReadyAsync() public async Task OnReadyAsync()
=> await Task.WhenAll(RunUserStatsCollector(), RunBetStatsCollector()); => await Task.WhenAll(RunUserStatsCollector(), RunBetStatsCollector());
@ -40,7 +34,7 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
while (await timer.WaitForNextTickAsync()) while (await timer.WaitForNextTickAsync())
{ {
await using var ctx = _db.GetDbContext(); await using var ctx = db.GetDbContext();
try try
{ {
@ -51,22 +45,22 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
if (globalStats.TryRemove(key, out var stat)) if (globalStats.TryRemove(key, out var stat))
{ {
await ctx.GetTable<GamblingStats>() await ctx.GetTable<GamblingStats>()
.InsertOrUpdateAsync(() => new() .InsertOrUpdateAsync(() => new()
{ {
Feature = key, Feature = key,
Bet = stat.Bet, Bet = stat.Bet,
PaidOut = stat.PaidOut, PaidOut = stat.PaidOut,
DateAdded = DateTime.UtcNow DateAdded = DateTime.UtcNow
}, },
old => new() old => new()
{ {
Bet = old.Bet + stat.Bet, Bet = old.Bet + stat.Bet,
PaidOut = old.PaidOut + stat.PaidOut, PaidOut = old.PaidOut + stat.PaidOut,
}, },
() => new() () => new()
{ {
Feature = key Feature = key
}); });
} }
} }
} }
@ -100,68 +94,68 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
// update userstats // update userstats
foreach (var (k, x) in users.GroupBy(x => (x.UserId, x.Game)) foreach (var (k, x) in users.GroupBy(x => (x.UserId, x.Game))
.ToDictionary(x => x.Key, .ToDictionary(x => x.Key,
x => x.Aggregate((a, b) => new() x => x.Aggregate((a, b) => new()
{ {
WinCount = a.WinCount + b.WinCount, WinCount = a.WinCount + b.WinCount,
LoseCount = a.LoseCount + b.LoseCount, LoseCount = a.LoseCount + b.LoseCount,
TotalBet = a.TotalBet + b.TotalBet, TotalBet = a.TotalBet + b.TotalBet,
PaidOut = a.PaidOut + b.PaidOut, PaidOut = a.PaidOut + b.PaidOut,
MaxBet = Math.Max(a.MaxBet, b.MaxBet), MaxBet = Math.Max(a.MaxBet, b.MaxBet),
MaxWin = Math.Max(a.MaxWin, b.MaxWin), MaxWin = Math.Max(a.MaxWin, b.MaxWin),
}))) })))
{ {
rakebacks.TryAdd(k.UserId, 0m); rakebacks.TryAdd(k.UserId, 0m);
rakebacks[k.UserId] += x.TotalBet * GetHouseEdge(k.Game) * BASE_RAKEBACK; rakebacks[k.UserId] += x.TotalBet * GetHouseEdge(k.Game) * BASE_RAKEBACK;
// bulk upsert in the future // bulk upsert in the future
await using var uow = _db.GetDbContext(); await using var uow = db.GetDbContext();
await uow.GetTable<UserBetStats>() await uow.GetTable<UserBetStats>()
.InsertOrUpdateAsync(() => new() .InsertOrUpdateAsync(() => new()
{ {
UserId = k.UserId, UserId = k.UserId,
Game = k.Game, Game = k.Game,
WinCount = x.WinCount, WinCount = x.WinCount,
LoseCount = Math.Max(0, x.LoseCount), LoseCount = Math.Max(0, x.LoseCount),
TotalBet = x.TotalBet, TotalBet = x.TotalBet,
PaidOut = x.PaidOut, PaidOut = x.PaidOut,
MaxBet = x.MaxBet, MaxBet = x.MaxBet,
MaxWin = x.MaxWin MaxWin = x.MaxWin
}, },
o => new() o => new()
{ {
WinCount = o.WinCount + x.WinCount, WinCount = o.WinCount + x.WinCount,
LoseCount = Math.Max(0, o.LoseCount + x.LoseCount), LoseCount = Math.Max(0, o.LoseCount + x.LoseCount),
TotalBet = o.TotalBet + x.TotalBet, TotalBet = o.TotalBet + x.TotalBet,
PaidOut = o.PaidOut + x.PaidOut, PaidOut = o.PaidOut + x.PaidOut,
MaxBet = Math.Max(o.MaxBet, x.MaxBet), MaxBet = Math.Max(o.MaxBet, x.MaxBet),
MaxWin = Math.Max(o.MaxWin, x.MaxWin), MaxWin = Math.Max(o.MaxWin, x.MaxWin),
}, },
() => new() () => new()
{ {
UserId = k.UserId, UserId = k.UserId,
Game = k.Game Game = k.Game
}); });
} }
foreach (var (k, v) in rakebacks) foreach (var (k, v) in rakebacks)
{ {
await _db.GetDbContext() await db.GetDbContext()
.GetTable<Rakeback>() .GetTable<Rakeback>()
.InsertOrUpdateAsync(() => new() .InsertOrUpdateAsync(() => new()
{ {
UserId = k, UserId = k,
Amount = v Amount = v
}, },
(old) => new() (old) => new()
{ {
Amount = old.Amount + v Amount = old.Amount + v
}, },
() => new() () => new()
{ {
UserId = k UserId = k
}); });
} }
} }
catch (Exception ex) catch (Exception ex)
@ -173,10 +167,10 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
private const decimal BASE_RAKEBACK = 0.05m; private const decimal BASE_RAKEBACK = 0.05m;
public Task TrackAdd(ulong userId, long amount, TxData? txData) public async Task TrackAdd(ulong userId, long amount, TxData? txData)
{ {
if (txData is null) if (txData is null)
return Task.CompletedTask; return;
if (_gamblingTypes.Contains(txData.Type)) if (_gamblingTypes.Contains(txData.Type))
{ {
@ -188,7 +182,7 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
var mType = GetGameType(txData.Type); var mType = GetGameType(txData.Type);
if (mType is not { } type) if (mType is not { } type)
return Task.CompletedTask; return;
// var bigWin = _gcs.Data.BigWin; // var bigWin = _gcs.Data.BigWin;
// if (bigWin > 0 && amount >= bigWin) // if (bigWin > 0 && amount >= bigWin)
@ -211,7 +205,7 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
MaxBet = 0, MaxBet = 0,
MaxWin = amount, MaxWin = amount,
}); });
return Task.CompletedTask; return;
} }
} }
else if (txData.Type == "animalrace") else if (txData.Type == "animalrace")
@ -230,7 +224,7 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
MaxWin = 0, MaxWin = 0,
}); });
return Task.CompletedTask; return;
} }
} }
@ -245,14 +239,12 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
MaxBet = 0, MaxBet = 0,
MaxWin = amount, MaxWin = amount,
}); });
return Task.CompletedTask;
} }
public Task TrackRemove(ulong userId, long amount, TxData? txData) public async Task TrackRemove(ulong userId, long amount, TxData? txData)
{ {
if (txData is null) if (txData is null)
return Task.CompletedTask; return;
if (_gamblingTypes.Contains(txData.Type)) if (_gamblingTypes.Contains(txData.Type))
{ {
@ -264,7 +256,7 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
var mType = GetGameType(txData.Type); var mType = GetGameType(txData.Type);
if (mType is not { } type) if (mType is not { } type)
return Task.CompletedTask; return;
userStats.Add(new UserBetStats() userStats.Add(new UserBetStats()
{ {
@ -278,7 +270,14 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
MaxWin = 0 MaxWin = 0
}); });
return Task.CompletedTask; await quests.ReportActionAsync(userId,
QuestEventType.BetPlaced,
new()
{
{ "type", txData.Type },
{ "amount", amount.ToString() }
}
);
} }
private static GamblingGame? GetGameType(string game) private static GamblingGame? GetGameType(string game)
@ -296,26 +295,26 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
public async Task<IReadOnlyCollection<GamblingStats>> GetAllAsync() public async Task<IReadOnlyCollection<GamblingStats>> GetAllAsync()
{ {
await using var ctx = _db.GetDbContext(); await using var ctx = db.GetDbContext();
return await ctx.Set<GamblingStats>() return await ctx.Set<GamblingStats>()
.ToListAsyncEF(); .ToListAsyncEF();
} }
public async Task<List<UserBetStats>> GetUserStatsAsync(ulong userId, GamblingGame? game = null) public async Task<List<UserBetStats>> GetUserStatsAsync(ulong userId, GamblingGame? game = null)
{ {
await using var ctx = _db.GetDbContext(); await using var ctx = db.GetDbContext();
if (game is null) if (game is null)
return await ctx return await ctx
.GetTable<UserBetStats>() .GetTable<UserBetStats>()
.Where(x => x.UserId == userId) .Where(x => x.UserId == userId)
.ToListAsync(); .ToListAsync();
return await ctx return await ctx
.GetTable<UserBetStats>() .GetTable<UserBetStats>()
.Where(x => x.UserId == userId && x.Game == game) .Where(x => x.UserId == userId && x.Game == game)
.ToListAsync(); .ToListAsync();
} }
public decimal GetHouseEdge(GamblingGame game) public decimal GetHouseEdge(GamblingGame game)
@ -330,8 +329,6 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
GamblingGame.Race => 0.06m, GamblingGame.Race => 0.06m,
_ => 0 _ => 0
}; };
} }
public sealed class UserBetStats public sealed class UserBetStats

View file

@ -374,7 +374,6 @@ quoteadd:
- qa - qa
- qadd - qadd
- quadd - quadd
- .
quoteedit: quoteedit:
- quoteedit - quoteedit
- qe - qe
@ -384,7 +383,6 @@ quoteprint:
- quoteprint - quoteprint
- qp - qp
- qup - qup
- ..
- qprint - qprint
quoteshow: quoteshow:
- quoteshow - quoteshow
@ -1658,4 +1656,8 @@ votefeed:
vote: vote:
- vote - vote
massping: massping:
- massping - massping
questlog:
- questlog
- qlog
- myquests

View file

@ -5206,5 +5206,12 @@ massping:
Run again to cancel. Run again to cancel.
ex: ex:
- '' - ''
params:
- { }
questlog:
desc: |-
Shows your active quests and progress.
ex:
- ''
params: params:
- { } - { }

View file

@ -1244,5 +1244,9 @@
"notify_cant_set": "This event doesn't support origin channel, Please specify a channel", "notify_cant_set": "This event doesn't support origin channel, Please specify a channel",
"vote_reward": "Thank you for voting! You've received {0}.", "vote_reward": "Thank you for voting! You've received {0}.",
"vote_suggest": "Voting for the bot once every 6 hours will get you {0}!", "vote_suggest": "Voting for the bot once every 6 hours will get you {0}!",
"vote_disabled": "Voting is disabled." "vote_disabled": "Voting is disabled.",
"quest_log": "Quest Log",
"dailies_done": "You've completed your dailies!",
"dailies_reset": "Reset {0}",
"daily_completed": "You've completed a daily quest: {0}"
} }