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
{
[DbContext(typeof(PostgreSqlContext))]
[Migration("20250323022235_init")]
[Migration("20250328080459_init")]
partial class init
{
/// <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
{
[DbContext(typeof(SqliteContext))]
[Migration("20250323022218_init")]
[Migration("20250328080413_init")]
partial class init
{
/// <inheritdoc />

View file

@ -1,6 +1,7 @@
#nullable disable
using EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
using EllieBot.Modules.Games.Common;
using EllieBot.Modules.Games.Quests;
namespace EllieBot.Modules.Gambling.Common.AnimalRacing;
@ -35,12 +36,18 @@ public sealed class AnimalRace : IDisposable
private readonly ICurrencyService _currency;
private readonly RaceOptions _options;
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;
_options = options;
_animalsQueue = new(availableAnimals);
_quests = quests;
MaxUsers = _animalsQueue.Count;
if (_animalsQueue.Count == 0)
@ -60,7 +67,10 @@ public sealed class AnimalRace : IDisposable
await Start();
}
finally { _locker.Release(); }
finally
{
_locker.Release();
}
});
public async Task<AnimalRacingUser> JoinRace(ulong userId, string userName, long bet = 0)
@ -93,7 +103,10 @@ public sealed class AnimalRace : IDisposable
return user;
}
finally { _locker.Release(); }
finally
{
_locker.Release();
}
}
private async Task Start()
@ -104,7 +117,9 @@ public sealed class AnimalRace : IDisposable
foreach (var user in _users)
{
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);
@ -112,6 +127,11 @@ public sealed class AnimalRace : IDisposable
return;
}
foreach (var user in _users)
{
await _quests.ReportActionAsync(user.UserId, QuestEventType.RaceJoined);
}
_ = OnStarted?.Invoke(this);
_ = 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.Exceptions;
using EllieBot.Modules.Gambling.Services;
using EllieBot.Modules.Games.Quests;
using EllieBot.Modules.Games.Services;
namespace EllieBot.Modules.Gambling;
@ -17,6 +18,7 @@ public partial class Gambling
private readonly ICurrencyService _cs;
private readonly DiscordSocketClient _client;
private readonly GamesConfigService _gamesConf;
private readonly QuestService _quests;
private IUserMessage raceMessage;
@ -24,12 +26,14 @@ public partial class Gambling
ICurrencyService cs,
DiscordSocketClient client,
GamblingConfigService gamblingConf,
GamesConfigService gamesConf)
GamesConfigService gamesConf,
QuestService quests)
: base(gamblingConf)
{
_cs = cs;
_client = client;
_gamesConf = gamesConf;
_quests = quests;
}
[Cmd]
@ -39,11 +43,11 @@ public partial class Gambling
{
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))
return Response()
.Error(GetText(strs.animal_race), GetText(strs.animal_race_already_started))
.SendAsync();
.Error(GetText(strs.animal_race), GetText(strs.animal_race_already_started))
.SendAsync();
ar.Initialize();
@ -61,7 +65,9 @@ public partial class Gambling
raceMessage = null;
}
}
catch { }
catch
{
}
});
return Task.CompletedTask;
}
@ -74,22 +80,22 @@ public partial class Gambling
if (race.FinishedUsers[0].Bet > 0)
{
return Response()
.Embed(CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.animal_race))
.WithDescription(GetText(strs.animal_race_won_money(
Format.Bold(winner.Username),
winner.Animal.Icon,
N(race.FinishedUsers[0].Bet * race.Multi))))
.WithFooter($"x{race.Multi:F2}"))
.SendAsync();
.Embed(CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.animal_race))
.WithDescription(GetText(strs.animal_race_won_money(
Format.Bold(winner.Username),
winner.Animal.Icon,
N(race.FinishedUsers[0].Bet * race.Multi))))
.WithFooter($"x{race.Multi:F2}"))
.SendAsync();
}
ar.Dispose();
return Response()
.Confirm(GetText(strs.animal_race),
GetText(strs.animal_race_won(Format.Bold(winner.Username), winner.Animal.Icon)))
.SendAsync();
.Confirm(GetText(strs.animal_race),
GetText(strs.animal_race_won(Format.Bold(winner.Username), winner.Animal.Icon)))
.SendAsync();
}
ar.OnStartingFailed += Ar_OnStartingFailed;
@ -99,10 +105,10 @@ public partial class Gambling
_client.MessageReceived += ClientMessageReceived;
return Response()
.Confirm(GetText(strs.animal_race),
GetText(strs.animal_race_starting(options.StartTime)),
footer: GetText(strs.animal_race_join_instr(prefix)))
.SendAsync();
.Confirm(GetText(strs.animal_race),
GetText(strs.animal_race_starting(options.StartTime)),
footer: GetText(strs.animal_race_join_instr(prefix)))
.SendAsync();
}
private Task Ar_OnStarted(AnimalRace race)
@ -110,9 +116,9 @@ public partial class Gambling
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_starting_with_x(race.Users.Count)))
.SendAsync();
.Confirm(GetText(strs.animal_race),
GetText(strs.animal_race_starting_with_x(race.Users.Count)))
.SendAsync();
}
private async Task Ar_OnStateUpdate(AnimalRace race)
@ -133,10 +139,10 @@ public partial class Gambling
else
{
await msg.ModifyAsync(x => x.Embed = CreateEmbed()
.WithTitle(GetText(strs.animal_race))
.WithDescription(text)
.WithOkColor()
.Build());
.WithTitle(GetText(strs.animal_race))
.WithDescription(text)
.WithOkColor()
.Build());
}
}
@ -166,15 +172,15 @@ public partial class Gambling
if (amount > 0)
{
await Response()
.Confirm(GetText(strs.animal_race_join_bet(ctx.User.Mention,
user.Animal.Icon,
amount + CurrencySign)))
.SendAsync();
.Confirm(GetText(strs.animal_race_join_bet(ctx.User.Mention,
user.Animal.Icon,
amount + CurrencySign)))
.SendAsync();
}
else
await Response()
.Confirm(strs.animal_race_join(ctx.User.Mention, user.Animal.Icon))
.SendAsync();
.Confirm(strs.animal_race_join(ctx.User.Mention, user.Animal.Icon))
.SendAsync();
}
catch (ArgumentOutOfRangeException)
{

View file

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

View file

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

View file

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

View file

@ -4,6 +4,7 @@ using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
using EllieBot.Modules.Games.Quests;
using SixLabors.Fonts;
using SixLabors.Fonts.Unicode;
using SixLabors.ImageSharp;
@ -15,67 +16,47 @@ using Image = SixLabors.ImageSharp.Image;
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
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 = [];
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)
=> PotentialFlowerGeneration(msg);
private string GetText(ulong gid, LocStr str)
=> _strings.GetText(str, gid);
=> strings.GetText(str, gid);
public async Task<bool> ToggleCurrencyGeneration(ulong gid, ulong cid)
{
bool enabled;
await using var uow = _db.GetDbContext();
await using var uow = db.GetDbContext();
if (_generationChannels.Add(cid))
{
await uow.GetTable<GCChannelId>()
.InsertOrUpdateAsync(() => new()
.InsertOrUpdateAsync(() => new()
{
ChannelId = cid,
GuildId = gid
}, (x) => new()
},
(x) => new()
{
ChannelId = cid,
GuildId = gid
}, () => new()
},
() => new()
{
ChannelId = cid,
GuildId = gid
@ -87,8 +68,8 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
else
{
await uow.GetTable<GCChannelId>()
.Where(x => x.ChannelId == cid && x.GuildId == gid)
.DeleteAsync();
.Where(x => x.ChannelId == cid && x.GuildId == gid)
.DeleteAsync();
_generationChannels.TryRemove(cid);
enabled = false;
@ -99,9 +80,9 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
public async Task<IReadOnlyCollection<GCChannelId>> GetAllGeneratingChannels()
{
await using var uow = _db.GetDbContext();
await using var uow = db.GetDbContext();
return await uow.GetTable<GCChannelId>()
.ToListAsyncLinqToDB();
.ToListAsyncLinqToDB();
}
/// <summary>
@ -111,7 +92,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
/// <returns>Stream of the currency image</returns>
public async Task<(Stream, string)> GetRandomCurrencyImageAsync(string pass)
{
var curImg = await _images.GetCurrencyImageAsync();
var curImg = await images.GetCurrencyImageAsync();
if (curImg is null)
return (new MemoryStream(), null);
@ -142,7 +123,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
pass = pass.TrimTo(10, true).ToLowerInvariant();
using var img = Image.Load<Rgba32>(curImg);
// 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 =>
{
// 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
x.DrawText(new RichTextOptions(font)
{
Origin = new(0, 0),
TextRuns =
{
Origin = new(0, 0),
TextRuns =
[
strikeoutRun
]
},
},
pass,
new SolidBrush(Color.White));
});
@ -200,7 +181,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
{
try
{
var config = _gss.Data;
var config = gss.Data;
var lastGeneration = LastGenerations.GetOrAdd(channel.Id, DateTime.MinValue.ToBinary());
var rng = new EllieRandom();
@ -219,7 +200,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
if (dropAmount > 0)
{
var prefix = _cmdHandler.GetPrefix(channel.Guild.Id);
var prefix = cmdHandler.GetPrefix(channel.Guild.Id);
var toSend = dropAmount == 1
? 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));
var pw = config.Generation.HasPassword ? _gs.GeneratePassword().ToUpperInvariant() : null;
var pw = config.Generation.HasPassword ? gs.GeneratePassword().ToUpperInvariant() : null;
IUserMessage sent;
var (stream, ext) = await GetRandomCurrencyImageAsync(pw);
@ -238,7 +219,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
var res = await AddPlantToDatabase(channel.GuildId,
channel.Id,
_client.CurrentUser.Id,
client.CurrentUser.Id,
sent.Id,
dropAmount,
pw,
@ -261,12 +242,12 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
public async Task<long> PickAsync(
ulong gid,
ITextChannel ch,
ulong uid,
ulong userId,
string pass)
{
long amount;
ulong[] ids;
await using (var uow = _db.GetDbContext())
await using (var uow = db.GetDbContext())
{
// this method will sum all plants with that password,
// 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();
// gets all plants in this channel with the same password
var entries = await uow.GetTable<PlantedCurrency>()
.Where(x => x.ChannelId == ch.Id && pass == x.Password)
.DeleteWithOutputAsync();
.Where(x => x.ChannelId == ch.Id && pass == x.Password)
.DeleteWithOutputAsync();
if (!entries.Any())
return 0;
@ -285,14 +266,24 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
}
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
{
_ = ch.DeleteMessagesAsync(ids);
}
catch { }
catch
{
}
// return the amount of currency the user picked
return amount;
@ -308,8 +299,8 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
try
{
// get the text
var prefix = _cmdHandler.GetPrefix(gid);
var msgToSend = GetText(gid, strs.planted(Format.Bold(user), amount + _gss.Data.Currency.Sign));
var prefix = cmdHandler.GetPrefix(gid);
var msgToSend = GetText(gid, strs.planted(Format.Bold(user), amount + gss.Data.Currency.Sign));
if (amount > 1)
msgToSend += " " + GetText(gid, strs.pick_pl(prefix));
@ -337,7 +328,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
public async Task<bool> PlantAsync(
ulong gid,
ITextChannel ch,
ulong uid,
ulong userId,
string user,
long amount,
string pass)
@ -349,19 +340,20 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
return false;
// 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
var msgId = await SendPlantMessageAsync(gid, ch, user, amount, pass);
if (msgId is null)
{
// 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;
}
// 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;
}
@ -379,43 +371,42 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
string pass,
bool auto = false)
{
await using var uow = _db.GetDbContext();
await using var uow = db.GetDbContext();
PlantedCurrency[] deleted = [];
if (!string.IsNullOrWhiteSpace(pass) && auto)
{
deleted = await uow.GetTable<PlantedCurrency>()
.Where(x => x.GuildId == gid
&& x.ChannelId == cid
&& x.Password != null
&& x.Password.Length == pass.Length)
.DeleteWithOutputAsync();
.Where(x => x.GuildId == gid
&& x.ChannelId == cid
&& x.Password != null
&& x.Password.Length == pass.Length)
.DeleteWithOutputAsync();
}
var totalDeletedAmount = deleted.Length == 0 ? 0 : deleted.Sum(x => x.Amount);
await uow.GetTable<PlantedCurrency>()
.InsertAsync(() => new()
{
Amount = totalDeletedAmount + amount,
GuildId = gid,
ChannelId = cid,
Password = pass,
UserId = uid,
MessageId = mid,
});
.InsertAsync(() => new()
{
Amount = totalDeletedAmount + amount,
GuildId = gid,
ChannelId = cid,
Password = pass,
UserId = uid,
MessageId = mid,
});
return (totalDeletedAmount + amount, deleted.Select(x => x.MessageId).ToArray());
}
public async Task OnReadyAsync()
{
await using var uow = _db.GetDbContext();
await using var uow = db.GetDbContext();
_generationChannels = (await uow.GetTable<GCChannelId>()
.Select(x => x.ChannelId)
.ToListAsyncLinqToDB())
.Select(x => x.ChannelId)
.ToListAsyncLinqToDB())
.ToHashSet()
.ToConcurrentSet();
}
}

View file

@ -6,6 +6,7 @@ using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
using EllieBot.Modules.Gambling.Common;
using EllieBot.Modules.Gambling.Common.Waifu;
using EllieBot.Modules.Games.Quests;
namespace EllieBot.Modules.Gambling.Services;
@ -15,23 +16,23 @@ public class WaifuService : IEService, IReadyExecutor
private readonly ICurrencyService _cs;
private readonly IBotCache _cache;
private readonly GamblingConfigService _gss;
private readonly IBotCreds _creds;
private readonly DiscordSocketClient _client;
private readonly QuestService _quests;
public WaifuService(
DbService db,
ICurrencyService cs,
IBotCache cache,
GamblingConfigService gss,
IBotCreds creds,
DiscordSocketClient client)
DiscordSocketClient client,
QuestService quests)
{
_db = db;
_cs = cs;
_cache = cache;
_gss = gss;
_creds = creds;
_client = client;
_quests = quests;
}
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);
else
w.Price += totalValue / 2;
await _quests.ReportActionAsync(from.Id, QuestEventType.WaifuGiftSent);
}
else
{

View file

@ -4,6 +4,7 @@ using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using EllieBot.Modules.Administration;
using EllieBot.Modules.Administration.Services;
using EllieBot.Modules.Games.Quests;
namespace EllieBot.Modules.Games.Fish;
@ -11,7 +12,8 @@ public sealed class FishService(
FishConfigService fcs,
IBotCache cache,
DbService db,
INotifySubscriber notify
INotifySubscriber notify,
QuestService quests
)
: 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;
}

View file

@ -2,6 +2,7 @@
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Modules.Games.Services;
using System.Diagnostics.CodeAnalysis;
using EllieBot.Modules.Games.Quests;
namespace EllieBot.Modules.Games.Hangman;
@ -13,6 +14,7 @@ public sealed class HangmanService : IHangmanService, IExecNoCommand
private readonly GamesConfigService _gcs;
private readonly ICurrencyService _cs;
private readonly IMemoryCache _cdCache;
private readonly QuestService _quests;
private readonly object _locker = new();
public HangmanService(
@ -20,13 +22,15 @@ public sealed class HangmanService : IHangmanService, IExecNoCommand
IMessageSenderService sender,
GamesConfigService gcs,
ICurrencyService cs,
IMemoryCache cdCache)
IMemoryCache cdCache,
QuestService quests)
{
_source = source;
_sender = sender;
_gcs = gcs;
_cs = cs;
_cdCache = cdCache;
_quests = quests;
}
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)
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);
}

View file

@ -3,6 +3,7 @@ using LinqToDB.Data;
using LinqToDB.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
using EllieBot.Modules.Games.Quests;
using SixLabors.ImageSharp.ColorSpaces;
using SixLabors.ImageSharp.ColorSpaces.Conversion;
using SixLabors.ImageSharp.PixelFormats;
@ -17,6 +18,7 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
private readonly IBotCache _cache;
private readonly DiscordSocketClient _client;
private readonly ICurrencyService _cs;
private readonly QuestService _quests;
public const int CANVAS_WIDTH = 500;
public const int CANVAS_HEIGHT = 350;
@ -26,12 +28,14 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
DbService db,
IBotCache cache,
DiscordSocketClient client,
ICurrencyService cs)
ICurrencyService cs,
QuestService quests)
{
_db = db;
_cache = cache;
_client = client;
_cs = cs;
_quests = quests;
}
public async Task OnReadyAsync()
@ -59,23 +63,23 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
}
await uow.GetTable<NCPixel>()
.BulkCopyAsync(toAdd.Select(x =>
{
var clr = ColorSpaceConverter.ToRgb(new Hsv(((float)Random.Shared.NextDouble() * 360),
(float)(0.5 + (Random.Shared.NextDouble() * 0.49)),
(float)(0.4 + (Random.Shared.NextDouble() / 5 + (x % 100 * 0.2)))))
.ToVector3();
.BulkCopyAsync(toAdd.Select(x =>
{
var clr = ColorSpaceConverter.ToRgb(new Hsv(((float)Random.Shared.NextDouble() * 360),
(float)(0.5 + (Random.Shared.NextDouble() * 0.49)),
(float)(0.4 + (Random.Shared.NextDouble() / 5 + (x % 100 * 0.2)))))
.ToVector3();
var packed = new Rgba32(clr).PackedValue;
return new NCPixel()
{
Color = packed,
Price = 1,
Position = x,
Text = "",
OwnerId = 0
};
}));
var packed = new Rgba32(clr).PackedValue;
return new NCPixel()
{
Color = packed,
Price = 1,
Position = x,
Text = "",
OwnerId = 0
};
}));
}
@ -83,9 +87,9 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
{
await using var uow = _db.GetDbContext();
var colors = await uow.GetTable<NCPixel>()
.OrderBy(x => x.Position)
.Select(x => x.Color)
.ToArrayAsyncLinqToDB();
.OrderBy(x => x.Position)
.Select(x => x.Color)
.ToArrayAsyncLinqToDB();
return colors;
}
@ -121,15 +125,15 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
{
await using var uow = _db.GetDbContext();
var updates = await uow.GetTable<NCPixel>()
.Where(x => x.Position == position && x.Price <= price)
.UpdateAsync(old => new NCPixel()
{
Position = position,
Color = color,
Text = text,
OwnerId = userId,
Price = price + 1
});
.Where(x => x.Position == position && x.Price <= price)
.UpdateAsync(old => new NCPixel()
{
Position = position,
Color = color,
Text = text,
OwnerId = userId,
Price = price + 1
});
success = updates > 0;
}
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"));
}
else
{
await _quests.ReportActionAsync(userId, QuestEventType.PixelSet);
}
return success ? SetPixelResult.Success : SetPixelResult.InsufficientPayment;
}
@ -152,14 +160,14 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
await using var uow = _db.GetDbContext();
await uow.GetTable<NCPixel>().DeleteAsync();
await uow.GetTable<NCPixel>()
.BulkCopyAsync(colors.Select((x, i) => new NCPixel()
{
Color = x,
Price = INITIAL_PRICE,
Position = i,
Text = "",
OwnerId = 0
}));
.BulkCopyAsync(colors.Select((x, i) => new NCPixel()
{
Color = x,
Price = INITIAL_PRICE,
Position = i,
Text = "",
OwnerId = 0
}));
return true;
}
@ -190,12 +198,12 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
await using var uow = _db.GetDbContext();
return await uow.GetTable<NCPixel>()
.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)
.OrderBy(x => x.Position)
.ToArrayAsyncLinqToDB();
.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)
.OrderBy(x => x.Position)
.ToArrayAsyncLinqToDB();
}
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();
}
private static CancellationTokenSource _cts = null;
private static CancellationTokenSource? _cts = null;
[Cmd]
public async Task MassPing()
@ -22,6 +22,8 @@ public class Owner(VoteRewardService vrs) : EllieModule
await t.CancelAsync();
}
_cts = new();
try
{
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,
long amount,
TxData? txData);
Task<IReadOnlyList<DiscordUser>> GetTopRichest(ulong ignoreId, int page = 0, int perPage = 9);
Task<IReadOnlyList<CurrencyTransaction>> GetTransactionsAsync(
@ -47,4 +47,14 @@ public interface ICurrencyService
int perPage = 15);
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;
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)
{
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));
}
@ -53,16 +44,16 @@ public sealed class CurrencyService : ICurrencyService, IEService
{
if (type == CurrencyType.Default)
{
await using var ctx = _db.GetDbContext();
await using var ctx = db.GetDbContext();
await ctx
.GetTable<DiscordUser>()
.Where(x => userIds.Contains(x.UserId))
.UpdateAsync(du => new()
{
CurrencyAmount = du.CurrencyAmount >= amount
? du.CurrencyAmount - amount
: 0
});
.GetTable<DiscordUser>()
.Where(x => userIds.Contains(x.UserId))
.UpdateAsync(du => new()
{
CurrencyAmount = du.CurrencyAmount >= amount
? du.CurrencyAmount - amount
: 0
});
await ctx.SaveChangesAsync();
return;
}
@ -77,7 +68,7 @@ public sealed class CurrencyService : ICurrencyService, IEService
{
var wallet = await GetWalletAsync(userId);
await wallet.Add(amount, txData);
await _txTracker.TrackAdd(userId, amount, txData);
await txTracker.TrackAdd(userId, amount, txData);
}
public async Task AddAsync(
@ -97,7 +88,7 @@ public sealed class CurrencyService : ICurrencyService, IEService
var wallet = await GetWalletAsync(userId);
var result = await wallet.Take(amount, txData);
if (result)
await _txTracker.TrackRemove(userId, amount, txData);
await txTracker.TrackRemove(userId, amount, txData);
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)
{
await using var uow = _db.GetDbContext();
await using var uow = db.GetDbContext();
return await uow.Set<DiscordUser>().GetTopRichest(ignoreId, page, perPage);
}
@ -118,23 +109,63 @@ public sealed class CurrencyService : ICurrencyService, IEService
int page,
int perPage = 15)
{
await using var uow = _db.GetDbContext();
await using var uow = db.GetDbContext();
var trs = await uow.GetTable<CurrencyTransaction>()
.Where(x => x.UserId == userId)
.OrderByDescending(x => x.DateAdded)
.Skip(perPage * page)
.Take(perPage)
.ToListAsyncLinqToDB();
.Where(x => x.UserId == userId)
.OrderByDescending(x => x.DateAdded)
.Skip(perPage * page)
.Take(perPage)
.ToListAsyncLinqToDB();
return trs;
}
public async Task<int> GetTransactionsCountAsync(ulong userId)
{
await using var uow = _db.GetDbContext();
await using var uow = db.GetDbContext();
return await uow.GetTable<CurrencyTransaction>()
.Where(x => x.UserId == userId)
.CountAsyncLinqToDB();
.Where(x => x.UserId == userId)
.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 async Task<long> GetBalanceAsync(this ICurrencyService cs, ulong userId)
{
var wallet = await cs.GetWalletAsync(userId);
return await wallet.GetBalance();
}
// 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 EllieBot.Modules.Administration;
using EllieBot.Modules.Gambling.Services;
using EllieBot.Modules.Games.Quests;
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[]
{
@ -21,17 +26,6 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
private NonBlocking.ConcurrentDictionary<string, (decimal Bet, decimal PaidOut)> globalStats = 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()
=> await Task.WhenAll(RunUserStatsCollector(), RunBetStatsCollector());
@ -40,7 +34,7 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
while (await timer.WaitForNextTickAsync())
{
await using var ctx = _db.GetDbContext();
await using var ctx = db.GetDbContext();
try
{
@ -51,22 +45,22 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
if (globalStats.TryRemove(key, out var stat))
{
await ctx.GetTable<GamblingStats>()
.InsertOrUpdateAsync(() => new()
{
Feature = key,
Bet = stat.Bet,
PaidOut = stat.PaidOut,
DateAdded = DateTime.UtcNow
},
old => new()
{
Bet = old.Bet + stat.Bet,
PaidOut = old.PaidOut + stat.PaidOut,
},
() => new()
{
Feature = key
});
.InsertOrUpdateAsync(() => new()
{
Feature = key,
Bet = stat.Bet,
PaidOut = stat.PaidOut,
DateAdded = DateTime.UtcNow
},
old => new()
{
Bet = old.Bet + stat.Bet,
PaidOut = old.PaidOut + stat.PaidOut,
},
() => new()
{
Feature = key
});
}
}
}
@ -100,68 +94,68 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
// update userstats
foreach (var (k, x) in users.GroupBy(x => (x.UserId, x.Game))
.ToDictionary(x => x.Key,
x => x.Aggregate((a, b) => new()
{
WinCount = a.WinCount + b.WinCount,
LoseCount = a.LoseCount + b.LoseCount,
TotalBet = a.TotalBet + b.TotalBet,
PaidOut = a.PaidOut + b.PaidOut,
MaxBet = Math.Max(a.MaxBet, b.MaxBet),
MaxWin = Math.Max(a.MaxWin, b.MaxWin),
})))
.ToDictionary(x => x.Key,
x => x.Aggregate((a, b) => new()
{
WinCount = a.WinCount + b.WinCount,
LoseCount = a.LoseCount + b.LoseCount,
TotalBet = a.TotalBet + b.TotalBet,
PaidOut = a.PaidOut + b.PaidOut,
MaxBet = Math.Max(a.MaxBet, b.MaxBet),
MaxWin = Math.Max(a.MaxWin, b.MaxWin),
})))
{
rakebacks.TryAdd(k.UserId, 0m);
rakebacks[k.UserId] += x.TotalBet * GetHouseEdge(k.Game) * BASE_RAKEBACK;
// bulk upsert in the future
await using var uow = _db.GetDbContext();
await using var uow = db.GetDbContext();
await uow.GetTable<UserBetStats>()
.InsertOrUpdateAsync(() => new()
{
UserId = k.UserId,
Game = k.Game,
WinCount = x.WinCount,
LoseCount = Math.Max(0, x.LoseCount),
TotalBet = x.TotalBet,
PaidOut = x.PaidOut,
MaxBet = x.MaxBet,
MaxWin = x.MaxWin
},
o => new()
{
WinCount = o.WinCount + x.WinCount,
LoseCount = Math.Max(0, o.LoseCount + x.LoseCount),
TotalBet = o.TotalBet + x.TotalBet,
PaidOut = o.PaidOut + x.PaidOut,
MaxBet = Math.Max(o.MaxBet, x.MaxBet),
MaxWin = Math.Max(o.MaxWin, x.MaxWin),
},
() => new()
{
UserId = k.UserId,
Game = k.Game
});
.InsertOrUpdateAsync(() => new()
{
UserId = k.UserId,
Game = k.Game,
WinCount = x.WinCount,
LoseCount = Math.Max(0, x.LoseCount),
TotalBet = x.TotalBet,
PaidOut = x.PaidOut,
MaxBet = x.MaxBet,
MaxWin = x.MaxWin
},
o => new()
{
WinCount = o.WinCount + x.WinCount,
LoseCount = Math.Max(0, o.LoseCount + x.LoseCount),
TotalBet = o.TotalBet + x.TotalBet,
PaidOut = o.PaidOut + x.PaidOut,
MaxBet = Math.Max(o.MaxBet, x.MaxBet),
MaxWin = Math.Max(o.MaxWin, x.MaxWin),
},
() => new()
{
UserId = k.UserId,
Game = k.Game
});
}
foreach (var (k, v) in rakebacks)
{
await _db.GetDbContext()
.GetTable<Rakeback>()
.InsertOrUpdateAsync(() => new()
{
UserId = k,
Amount = v
},
(old) => new()
{
Amount = old.Amount + v
},
() => new()
{
UserId = k
});
await db.GetDbContext()
.GetTable<Rakeback>()
.InsertOrUpdateAsync(() => new()
{
UserId = k,
Amount = v
},
(old) => new()
{
Amount = old.Amount + v
},
() => new()
{
UserId = k
});
}
}
catch (Exception ex)
@ -173,10 +167,10 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
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)
return Task.CompletedTask;
return;
if (_gamblingTypes.Contains(txData.Type))
{
@ -188,7 +182,7 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
var mType = GetGameType(txData.Type);
if (mType is not { } type)
return Task.CompletedTask;
return;
// var bigWin = _gcs.Data.BigWin;
// if (bigWin > 0 && amount >= bigWin)
@ -211,7 +205,7 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
MaxBet = 0,
MaxWin = amount,
});
return Task.CompletedTask;
return;
}
}
else if (txData.Type == "animalrace")
@ -230,7 +224,7 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
MaxWin = 0,
});
return Task.CompletedTask;
return;
}
}
@ -245,14 +239,12 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
MaxBet = 0,
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)
return Task.CompletedTask;
return;
if (_gamblingTypes.Contains(txData.Type))
{
@ -264,7 +256,7 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
var mType = GetGameType(txData.Type);
if (mType is not { } type)
return Task.CompletedTask;
return;
userStats.Add(new UserBetStats()
{
@ -278,7 +270,14 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
MaxWin = 0
});
return Task.CompletedTask;
await quests.ReportActionAsync(userId,
QuestEventType.BetPlaced,
new()
{
{ "type", txData.Type },
{ "amount", amount.ToString() }
}
);
}
private static GamblingGame? GetGameType(string game)
@ -296,26 +295,26 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
public async Task<IReadOnlyCollection<GamblingStats>> GetAllAsync()
{
await using var ctx = _db.GetDbContext();
await using var ctx = db.GetDbContext();
return await ctx.Set<GamblingStats>()
.ToListAsyncEF();
.ToListAsyncEF();
}
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)
return await ctx
.GetTable<UserBetStats>()
.Where(x => x.UserId == userId)
.ToListAsync();
.GetTable<UserBetStats>()
.Where(x => x.UserId == userId)
.ToListAsync();
return await ctx
.GetTable<UserBetStats>()
.Where(x => x.UserId == userId && x.Game == game)
.ToListAsync();
.GetTable<UserBetStats>()
.Where(x => x.UserId == userId && x.Game == game)
.ToListAsync();
}
public decimal GetHouseEdge(GamblingGame game)
@ -330,8 +329,6 @@ public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
GamblingGame.Race => 0.06m,
_ => 0
};
}
public sealed class UserBetStats

View file

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

View file

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

View file

@ -1244,5 +1244,9 @@
"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_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}"
}