.fishop and .finv

You can list items in `.fishop`
Buy with `.fibuy`
See your inventory with `.finv`
Equip with `.fiuse`
Items are defined in items: array at the bottom of fish.yml
Items will show up in your .fili and bonuses will show up when you do .fish
The migrations for quests were meant to be sorted in 4c2b42ab7f but it kind of decided to be very stupid.
This commit is contained in:
Toastie 2025-03-29 20:33:25 +13:00
parent e4afa1e385
commit 0a1797700c
Signed by: toastie_t0ast
GPG key ID: 0861BE54AD481DC7
26 changed files with 1318 additions and 291 deletions

3
.gitignore vendored
View file

@ -371,8 +371,7 @@ site/
.aider.*
PROMPT.md
.aider*
.windsurfrules
.*rules
## Python pip/env files
Pipfile

View file

@ -0,0 +1,20 @@
START TRANSACTION;
CREATE TABLE userquest (
id integer GENERATED BY DEFAULT AS IDENTITY,
questnumber integer NOT NULL,
userid numeric(20,0) NOT NULL,
questid integer NOT NULL,
progress bigint NOT NULL,
iscompleted boolean NOT NULL,
dateassigned timestamp without time zone NOT NULL,
CONSTRAINT pk_userquest PRIMARY KEY (id)
);
CREATE INDEX ix_userquest_userid ON userquest (userid);
CREATE UNIQUE INDEX ix_userquest_userid_questnumber_dateassigned ON userquest (userid, questnumber, dateassigned);
INSERT INTO "__EFMigrationsHistory" (migrationid, productversion)
VALUES ('20250324230804_quests', '9.0.1');
COMMIT;

View file

@ -0,0 +1,25 @@
START TRANSACTION;
ALTER TABLE userfishstats DROP COLUMN bait;
ALTER TABLE userfishstats DROP COLUMN pole;
ALTER TABLE userquest ALTER COLUMN progress TYPE integer;
CREATE TABLE userfishitem (
id integer GENERATED BY DEFAULT AS IDENTITY,
userid numeric(20,0) NOT NULL,
itemtype integer NOT NULL,
itemid integer NOT NULL,
isequipped boolean NOT NULL,
usesleft integer,
expiresat timestamp without time zone,
CONSTRAINT pk_userfishitem PRIMARY KEY (id)
);
CREATE INDEX ix_userfishitem_userid ON userfishitem (userid);
INSERT INTO "__EFMigrationsHistory" (migrationid, productversion)
VALUES ('20250327001838_fishitems', '9.0.1');
COMMIT;

View file

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

View file

@ -864,57 +864,6 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("discorduser", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.EllieExpression", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowTarget")
.HasColumnType("boolean")
.HasColumnName("allowtarget");
b.Property<bool>("AutoDeleteTrigger")
.HasColumnType("boolean")
.HasColumnName("autodeletetrigger");
b.Property<bool>("ContainsAnywhere")
.HasColumnType("boolean")
.HasColumnName("containsanywhere");
b.Property<DateTime?>("DateAdded")
.HasColumnType("timestamp without time zone")
.HasColumnName("dateadded");
b.Property<bool>("DmResponse")
.HasColumnType("boolean")
.HasColumnName("dmresponse");
b.Property<decimal?>("GuildId")
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<string>("Reactions")
.HasColumnType("text")
.HasColumnName("reactions");
b.Property<string>("Response")
.HasColumnType("text")
.HasColumnName("response");
b.Property<string>("Trigger")
.HasColumnType("text")
.HasColumnName("trigger");
b.HasKey("Id")
.HasName("pk_expressions");
b.ToTable("expressions", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.FeedSub", b =>
{
b.Property<int>("Id")
@ -1859,6 +1808,57 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("ncpixel", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.EllieExpression", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowTarget")
.HasColumnType("boolean")
.HasColumnName("allowtarget");
b.Property<bool>("AutoDeleteTrigger")
.HasColumnType("boolean")
.HasColumnName("autodeletetrigger");
b.Property<bool>("ContainsAnywhere")
.HasColumnType("boolean")
.HasColumnName("containsanywhere");
b.Property<DateTime?>("DateAdded")
.HasColumnType("timestamp without time zone")
.HasColumnName("dateadded");
b.Property<bool>("DmResponse")
.HasColumnType("boolean")
.HasColumnName("dmresponse");
b.Property<decimal?>("GuildId")
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<string>("Reactions")
.HasColumnType("text")
.HasColumnName("reactions");
b.Property<string>("Response")
.HasColumnType("text")
.HasColumnName("response");
b.Property<string>("Trigger")
.HasColumnType("text")
.HasColumnName("trigger");
b.HasKey("Id")
.HasName("pk_expressions");
b.ToTable("expressions", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.Notify", b =>
{
b.Property<int>("Id")
@ -2985,6 +2985,52 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("unroletimer", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.UserQuest", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("DateAssigned")
.HasColumnType("timestamp without time zone")
.HasColumnName("dateassigned");
b.Property<bool>("IsCompleted")
.HasColumnType("boolean")
.HasColumnName("iscompleted");
b.Property<int>("Progress")
.HasColumnType("integer")
.HasColumnName("progress");
b.Property<int>("QuestId")
.HasColumnType("integer")
.HasColumnName("questid");
b.Property<int>("QuestNumber")
.HasColumnType("integer")
.HasColumnName("questnumber");
b.Property<decimal>("UserId")
.HasColumnType("numeric(20,0)")
.HasColumnName("userid");
b.HasKey("Id")
.HasName("pk_userquest");
b.HasIndex("UserId")
.HasDatabaseName("ix_userquest_userid");
b.HasIndex("UserId", "QuestNumber", "DateAssigned")
.IsUnique()
.HasDatabaseName("ix_userquest_userid_questnumber_dateassigned");
b.ToTable("userquest", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.UserXpStats", b =>
{
b.Property<int>("Id")
@ -3467,6 +3513,48 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("xpshopowneditem", (string)null);
});
modelBuilder.Entity("EllieBot.Modules.Games.Fish.Db.UserFishItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp without time zone")
.HasColumnName("expiresat");
b.Property<bool>("IsEquipped")
.HasColumnType("boolean")
.HasColumnName("isequipped");
b.Property<int>("ItemId")
.HasColumnType("integer")
.HasColumnName("itemid");
b.Property<int>("ItemType")
.HasColumnType("integer")
.HasColumnName("itemtype");
b.Property<decimal>("UserId")
.HasColumnType("numeric(20,0)")
.HasColumnName("userid");
b.Property<int?>("UsesLeft")
.HasColumnType("integer")
.HasColumnName("usesleft");
b.HasKey("Id")
.HasName("pk_userfishitem");
b.HasIndex("UserId")
.HasDatabaseName("ix_userfishitem_userid");
b.ToTable("userfishitem", (string)null);
});
modelBuilder.Entity("EllieBot.Modules.Games.FishCatch", b =>
{
b.Property<int>("Id")
@ -3510,14 +3598,6 @@ namespace EllieBot.Migrations.PostgreSql
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("Bait")
.HasColumnType("integer")
.HasColumnName("bait");
b.Property<int?>("Pole")
.HasColumnType("integer")
.HasColumnName("pole");
b.Property<int>("Skill")
.HasColumnType("integer")
.HasColumnName("skill");
@ -4181,4 +4261,4 @@ namespace EllieBot.Migrations.PostgreSql
#pragma warning restore 612, 618
}
}
}
}

View file

@ -0,0 +1,19 @@
BEGIN TRANSACTION;
CREATE TABLE "UserQuest" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_UserQuest" PRIMARY KEY AUTOINCREMENT,
"QuestNumber" INTEGER NOT NULL,
"UserId" INTEGER NOT NULL,
"QuestId" INTEGER NOT NULL,
"Progress" INTEGER NOT NULL,
"IsCompleted" INTEGER NOT NULL,
"DateAssigned" TEXT NOT NULL
);
CREATE INDEX "IX_UserQuest_UserId" ON "UserQuest" ("UserId");
CREATE UNIQUE INDEX "IX_UserQuest_UserId_QuestNumber_DateAssigned" ON "UserQuest" ("UserId", "QuestNumber", "DateAssigned");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20250324230801_quests', '9.0.1');
COMMIT;

View file

@ -0,0 +1,43 @@
BEGIN TRANSACTION;
CREATE TABLE "UserFishItem" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_UserFishItem" PRIMARY KEY AUTOINCREMENT,
"UserId" INTEGER NOT NULL,
"ItemType" INTEGER NOT NULL,
"ItemId" INTEGER NOT NULL,
"IsEquipped" INTEGER NOT NULL,
"UsesLeft" INTEGER NULL,
"ExpiresAt" TEXT NULL
);
CREATE INDEX "IX_UserFishItem_UserId" ON "UserFishItem" ("UserId");
CREATE TABLE "ef_temp_UserFishStats" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_UserFishStats" PRIMARY KEY AUTOINCREMENT,
"Skill" INTEGER NOT NULL,
"UserId" INTEGER NOT NULL
);
INSERT INTO "ef_temp_UserFishStats" ("Id", "Skill", "UserId")
SELECT "Id", "Skill", "UserId"
FROM "UserFishStats";
COMMIT;
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
DROP TABLE "UserFishStats";
ALTER TABLE "ef_temp_UserFishStats" RENAME TO "UserFishStats";
COMMIT;
PRAGMA foreign_keys = 1;
BEGIN TRANSACTION;
CREATE UNIQUE INDEX "IX_UserFishStats_UserId" ON "UserFishStats" ("UserId");
COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20250327001835_fishitems', '9.0.1');

View file

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

View file

@ -646,44 +646,6 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("DiscordUser");
});
modelBuilder.Entity("EllieBot.Db.Models.EllieExpression", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowTarget")
.HasColumnType("INTEGER");
b.Property<bool>("AutoDeleteTrigger")
.HasColumnType("INTEGER");
b.Property<bool>("ContainsAnywhere")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DateAdded")
.HasColumnType("TEXT");
b.Property<bool>("DmResponse")
.HasColumnType("INTEGER");
b.Property<ulong?>("GuildId")
.HasColumnType("INTEGER");
b.Property<string>("Reactions")
.HasColumnType("TEXT");
b.Property<string>("Response")
.HasColumnType("TEXT");
b.Property<string>("Trigger")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Expressions");
});
modelBuilder.Entity("EllieBot.Db.Models.FeedSub", b =>
{
b.Property<int>("Id")
@ -1388,6 +1350,44 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("NCPixel");
});
modelBuilder.Entity("EllieBot.Db.Models.EllieExpression", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowTarget")
.HasColumnType("INTEGER");
b.Property<bool>("AutoDeleteTrigger")
.HasColumnType("INTEGER");
b.Property<bool>("ContainsAnywhere")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DateAdded")
.HasColumnType("TEXT");
b.Property<bool>("DmResponse")
.HasColumnType("INTEGER");
b.Property<ulong?>("GuildId")
.HasColumnType("INTEGER");
b.Property<string>("Reactions")
.HasColumnType("TEXT");
b.Property<string>("Response")
.HasColumnType("TEXT");
b.Property<string>("Trigger")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Expressions");
});
modelBuilder.Entity("EllieBot.Db.Models.Notify", b =>
{
b.Property<int>("Id")
@ -2224,6 +2224,40 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("UnroleTimer");
});
modelBuilder.Entity("EllieBot.Db.Models.UserQuest", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAssigned")
.HasColumnType("TEXT");
b.Property<bool>("IsCompleted")
.HasColumnType("INTEGER");
b.Property<int>("Progress")
.HasColumnType("INTEGER");
b.Property<int>("QuestId")
.HasColumnType("INTEGER");
b.Property<int>("QuestNumber")
.HasColumnType("INTEGER");
b.Property<ulong>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("UserId", "QuestNumber", "DateAssigned")
.IsUnique();
b.ToTable("UserQuest");
});
modelBuilder.Entity("EllieBot.Db.Models.UserXpStats", b =>
{
b.Property<int>("Id")
@ -2579,6 +2613,37 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("XpShopOwnedItem");
});
modelBuilder.Entity("EllieBot.Modules.Games.Fish.Db.UserFishItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("IsEquipped")
.HasColumnType("INTEGER");
b.Property<int>("ItemId")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<ulong>("UserId")
.HasColumnType("INTEGER");
b.Property<int?>("UsesLeft")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserFishItem");
});
modelBuilder.Entity("EllieBot.Modules.Games.FishCatch", b =>
{
b.Property<int>("Id")
@ -2610,12 +2675,6 @@ namespace EllieBot.Migrations.Sqlite
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("Bait")
.HasColumnType("INTEGER");
b.Property<int?>("Pole")
.HasColumnType("INTEGER");
b.Property<int>("Skill")
.HasColumnType("INTEGER");
@ -3178,4 +3237,4 @@ namespace EllieBot.Migrations.Sqlite
#pragma warning restore 612, 618
}
}
}
}

View file

@ -0,0 +1,70 @@
using System;
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using EllieBot.Db;
using EllieBot.Modules.Games.Fish;
namespace EllieBot.Modules.Games.Fish.Db;
/// <summary>
/// Represents a fishing item owned by a user.
/// </summary>
public class UserFishItem
{
/// <summary>
/// The unique identifier for this user fish item.
/// </summary>
[Key]
public int Id { get; set; }
/// <summary>
/// The ID of the user who owns this item.
/// </summary>
public ulong UserId { get; set; }
/// <summary>
/// The type of the fishing item.
/// </summary>
public FishItemType ItemType { get; set; }
/// <summary>
/// The ID of the fishing item.
/// </summary>
public int ItemId { get; set; }
/// <summary>
/// Indicates whether the item is currently equipped by the user.
/// </summary>
public bool IsEquipped { get; set; }
/// <summary>
/// The number of uses left for this item. Null means unlimited uses.
/// </summary>
public int? UsesLeft { get; set; }
/// <summary>
/// The date and time when this item expires. Null means the item doesn't expire.
/// </summary>
public DateTime? ExpiresAt { get; set; }
public int? ExpiryFromNowInMinutes()
{
if (ExpiresAt is null)
return null;
return (int)(ExpiresAt.Value - DateTime.UtcNow).TotalMinutes;
}
}
/// <summary>
/// Entity configuration for UserFishItem.
/// </summary>
public class UserFishItemConfiguration : IEntityTypeConfiguration<UserFishItem>
{
public void Configure(EntityTypeBuilder<UserFishItem> builder)
{
builder.HasIndex(x => new { x.UserId });
}
}

View file

@ -9,20 +9,4 @@ public sealed class UserFishStats
public ulong UserId { get; set; }
public int Skill { get; set; }
public int? Pole { get; set; }
public int? Bait { get; set; }
}
// public sealed class FishingPole
// {
// [Key]
// public int Id { get; set; }
// public string Name { get; set; } = string.Empty;
// public long Price { get; set; }
// public string Emoji { get; set; } = string.Empty;
// }
}

View file

@ -14,43 +14,8 @@ public sealed partial class FishConfig : ICloneable<FishConfig>
public List<string> StarEmojis { get; set; } = new();
public List<string> SpotEmojis { get; set; } = new();
public FishChance Chance { get; set; } = new FishChance();
// public List<FishBait> Baits { get; set; } = new();
// public List<FishingPole> Poles { get; set; } = new();
public List<FishData> Fish { get; set; } = new();
public List<FishData> Trash { get; set; } = new();
}
// public sealed class FishBait : ICloneable<FishBait>
// {
// public int Id { get; set; }
// public string Name { get; set; } = string.Empty;
// public long Price { get; set; }
// public string Emoji { get; set; } = string.Empty;
// public int StackSize { get; set; } = 100;
//
// public string? OnlyWeather { get; set; }
// public string? OnlySpot { get; set; }
// public string? OnlyTime { get; set; }
//
// public double FishMulti { get; set; } = 1;
// public double TrashMulti { get; set; } = 1;
// public double NothingMulti { get; set; } = 1;
//
// public double RareFishMulti { get; set; } = 1;
// public double RareTrashMulti { get; set; } = 1;
//
// public double MaxStarMulti { get; set; } = 1;
// }
//
// public sealed class FishingPole : ICloneable<FishingPole>
// {
// public int Id { get; set; }
// public string Name { get; set; } = string.Empty;
// public long Price { get; set; }
// public string Emoji { get; set; } = string.Empty;
// public string Img { get; set; } = string.Empty;
//
// public double FishMulti { get; set; } = 1;
// public double TrashMulti { get; set; } = 1;
// public double NothingMulti { get; set; } = 1;
// }
public List<FishItem> Items { get; set; } = new();
}

View file

@ -0,0 +1,98 @@
namespace EllieBot.Modules.Games;
/// <summary>
/// Represents an item used in the fishing game.
/// </summary>
public class FishItem
{
/// <summary>
/// Unique identifier for the item.
/// </summary>
public int Id { get; set; }
/// <summary>
/// Type of the fishing item (pole, bait, boat, potion).
/// </summary>
public FishItemType ItemType { get; set; }
/// <summary>
/// Name of the item.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Item Emoji
/// </summary>
public string Emoji { get; set; } = string.Empty;
/// <summary>
/// Description of the item.
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// Price of the item.
/// </summary>
public int Price { get; set; }
/// <summary>
/// Number of times the item can be used. Null means unlimited uses.
/// </summary>
public int? Uses { get; set; }
/// <summary>
/// Duration of the item's effect in minutes. Null means permanent effect.
/// </summary>
public int? DurationMinutes { get; set; }
/// <summary>
/// Multiplier affecting the fish catch rate.
/// </summary>
public double? FishMultiplier { get; set; }
/// <summary>
/// Multiplier affecting the trash catch rate.
/// </summary>
public double? TrashMultiplier { get; set; }
/// <summary>
/// Multiplier affecting the maximum star rating of caught fish.
/// </summary>
public double? MaxStarMultiplier { get; set; }
/// <summary>
/// Multiplier affecting the chance of catching rare fish.
/// </summary>
public double? RareMultiplier { get; set; }
/// <summary>
/// Multiplier affecting the fishing speed.
/// </summary>
public double? FishingSpeedMultiplier { get; set; }
}
/// <summary>
/// Defines the types of items available in the fishing game.
/// </summary>
public enum FishItemType
{
/// <summary>
/// Fishing pole used to catch fish.
/// </summary>
Pole,
/// <summary>
/// Bait used to attract fish.
/// </summary>
Bait,
/// <summary>
/// Boat used for fishing.
/// </summary>
Boat,
/// <summary>
/// Potion that provides temporary effects.
/// </summary>
Potion
}

View file

@ -0,0 +1,240 @@
using EllieBot.Modules.Games.Fish.Db;
namespace EllieBot.Modules.Games;
public partial class Games
{
public class FishItemCommands(FishItemService fis, ICurrencyProvider cp) : EllieModule
{
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task FishShop()
{
var items = fis.GetItems();
await Response()
.Paginated()
.Items(items)
.PageSize(9)
.CurrentPage(0)
.Page((pageItems, i) =>
{
var eb = CreateEmbed()
.WithTitle(GetText(strs.fish_items_title))
.WithFooter("`.fibuy <id>` to by an item")
.WithOkColor();
foreach (var item in pageItems)
{
var description = GetItemDescription(item);
eb.AddField($"{item.Id}",
$"""
{description}
""",
true);
}
return eb;
})
.AddFooter(false)
.SendAsync();
}
private string GetItemDescription(FishItem item, UserFishItem? userItem = null)
{
var multiplierInfo = GetMultiplierInfo(item);
var priceText = userItem is null
? $"【 **{CurrencyHelper.N(item.Price, Culture, cp.GetCurrencySign())}** 】"
: "";
return $"""
**{item.Name}**
{GetEmoji(item.ItemType)} `{item.ItemType.ToString().ToLower()}` {priceText}
{item.Description}
{GetItemNotes(item, userItem)}
{multiplierInfo}
""";
}
private string GetItemNotes(FishItem item, UserFishItem? userItem)
{
var stats = new List<string>();
if (item.Uses.HasValue)
stats.Add($"**Uses:** {userItem?.UsesLeft ?? item.Uses}");
if (item.DurationMinutes.HasValue)
stats.Add($"**Duration:** {userItem?.ExpiryFromNowInMinutes() ?? item.DurationMinutes}m");
var toReturn = stats.Count > 0 ? string.Join(" | ", stats) + "\n" : "\n";
return "\n" + toReturn;
}
public static string GetEmoji(FishItemType itemType)
=> itemType switch
{
FishItemType.Pole => @"\🎣",
FishItemType.Boat => @"\⛵",
FishItemType.Bait => @"\🍥",
FishItemType.Potion => @"\🍷",
_ => ""
};
private string GetMultiplierInfo(FishItem item)
{
var multipliers = new FishMultipliers()
{
FishMultiplier = item.FishMultiplier ?? 1,
TrashMultiplier = item.TrashMultiplier ?? 1,
RareMultiplier = item.RareMultiplier ?? 1,
StarMultiplier = item.MaxStarMultiplier ?? 1,
FishingSpeedMultiplier = item.FishingSpeedMultiplier ?? 1
};
return GetMultiplierInfo(multipliers);
}
public static string GetMultiplierInfo(FishMultipliers item)
{
var multipliers = new List<string>();
if (item.FishMultiplier is not 1.0d)
multipliers.Add($"{AsPercent(item.FishMultiplier)} chance to catch fish");
if (item.TrashMultiplier is not 1.0d)
multipliers.Add($"{AsPercent(item.TrashMultiplier)} chance to catch trash");
if (item.RareMultiplier is not 1.0d)
multipliers.Add($"{AsPercent(item.RareMultiplier)} chance to catch rare fish");
if (item.StarMultiplier is not 1.0d)
multipliers.Add($"{AsPercent(item.StarMultiplier)} to max star rating");
if (item.FishingSpeedMultiplier is not 1.0d)
multipliers.Add($"{AsPercent(item.FishingSpeedMultiplier)} fishing speed");
return multipliers.Count > 0
? $"{string.Join("\n", multipliers)}\n"
: "";
}
private static string AsPercent(double multiplier)
{
var percentage = (int)((multiplier - 1.0f) * 100);
return percentage >= 0 ? $"**+{percentage}%**" : $"**{percentage}%**";
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task FishBuy(int itemId)
{
var res = await fis.BuyItemAsync(ctx.User.Id, itemId);
if (res.TryPickT1(out var err, out var eqItem))
{
if (err == BuyResult.InsufficientFunds)
await Response().Error(strs.not_enough(cp.GetCurrencySign())).SendAsync();
else
await Response().Error(strs.fish_item_not_found).SendAsync();
return;
}
var embed = CreateEmbed()
.WithDescription(GetText(strs.fish_buy_success))
.AddField(eqItem.Name, GetMultiplierInfo(eqItem));
await Response()
.Embed(embed)
.Interaction(_inter.Create(ctx.User.Id,
new ButtonBuilder("Inventory", Guid.NewGuid().ToString(), ButtonStyle.Secondary),
(smc) => FishInv()))
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task FishUse(int index)
{
var eqItem = await fis.EquipItemAsync(ctx.User.Id, index);
if (eqItem is null)
{
await Response().Error(strs.fish_item_not_found).SendAsync();
return;
}
var embed = CreateEmbed()
.WithDescription(GetText(strs.fish_use_success))
.AddField(eqItem.Name, GetMultiplierInfo(eqItem));
await Response().Embed(embed).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task FishUnequip(FishItemType itemType)
{
var res = await fis.UnequipItemAsync(ctx.User.Id, itemType);
if (res == UnequipResult.Success)
await Response().Confirm(strs.fish_unequip_success).SendAsync();
else if (res == UnequipResult.NotFound)
await Response().Error(strs.fish_item_not_found).SendAsync();
else
await Response().Error(strs.fish_cant_uneq_potion).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task FishInv()
{
var userItems = await fis.GetUserItemsAsync(ctx.User.Id);
await Response()
.Paginated()
.Items(userItems)
.PageSize(9)
.Page((items, page) =>
{
page += 1;
var eb = CreateEmbed()
.WithAuthor(ctx.User)
.WithTitle(GetText(strs.fish_inv_title))
.WithFooter($"`.fiuse <num>` to use/equip an item")
.WithOkColor();
for (var i = 0; i < items.Count; i++)
{
var (userItem, item) = items[i];
var isEquipped = userItem.IsEquipped;
if (item is null)
{
eb.AddField($"{(page * 9) + i + 1} | Item not found", $"ID: {userItem.Id}", true);
continue;
}
var description = GetItemDescription(item, userItem);
if (isEquipped)
description = "🫴 **IN USE**\n" + description;
eb.AddField($"{i + 1} | {item.Name} ",
$"""
{description}
""",
true);
}
return eb;
})
.AddFooter(false)
.SendAsync();
}
}
}

View file

@ -0,0 +1,276 @@
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using EllieBot.Modules.Games.Fish.Db;
namespace EllieBot.Modules.Games;
/// <summary>
/// Service for managing fish items that users can buy, equip, and use.
/// </summary>
public sealed class FishItemService(
DbService db,
ICurrencyService cs,
FishConfigService fcs) : IEService
{
private IReadOnlyList<FishItem> _items
=> fcs.Data.Items;
/// <summary>
/// Gets all available fish items.
/// </summary>
public IReadOnlyList<FishItem> GetItems()
=> _items;
/// <summary>
/// Gets a specific fish item by ID.
/// </summary>
public FishItem? GetItem(int id)
=> _items.FirstOrDefault(i => i.Id == id);
/// <summary>
/// Gets all items of a specific type.
/// </summary>
public List<FishItem> GetItemsByType(FishItemType type)
=> _items.Where(i => i.ItemType == type).ToList();
/// <summary>
/// Gets all items owned by a user.
/// </summary>
public async Task<List<(UserFishItem UserItem, FishItem? Item)>> GetUserItemsAsync(ulong userId)
{
await using var ctx = db.GetDbContext();
var userItems = await ctx.GetTable<UserFishItem>()
.Where(x => x.UserId == userId)
.ToListAsyncLinqToDB();
return userItems
.Select(ui => (ui, GetItem(ui.ItemId)))
.Where(x => x.Item2 != null)
.ToList();
}
/// <summary>
/// Gets all equipped items for a user.
/// </summary>
public async Task<List<(UserFishItem UserItem, FishItem Item)>> GetEquippedItemsAsync(ulong userId)
{
await CheckExpiredItemsAsync(userId);
await using var ctx = db.GetDbContext();
var items = await ctx.GetTable<UserFishItem>()
.Where(x => x.UserId == userId && x.IsEquipped)
.ToListAsyncLinqToDB();
var output = new List<(UserFishItem, FishItem)>();
foreach (var item in items)
{
var fishItem = GetItem(item.ItemId);
if (fishItem is not null)
output.Add((item, fishItem));
}
return output;
}
/// <summary>
/// Buys an item for a user.
/// </summary>
public async Task<OneOf.OneOf<FishItem, BuyResult>> BuyItemAsync(ulong userId, int itemId)
{
var item = GetItem(itemId);
if (item is null)
return BuyResult.NotFound;
await using var ctx = db.GetDbContext();
var removed = await cs.RemoveAsync(userId, item.Price, new("fish_item_purchase", item.Name));
if (!removed)
return BuyResult.InsufficientFunds;
// Add item to user's inventory
await ctx.GetTable<UserFishItem>()
.InsertAsync(() => new UserFishItem
{
UserId = userId,
ItemId = itemId,
ItemType = item.ItemType,
UsesLeft = item.Uses,
IsEquipped = false,
});
return item;
}
/// <summary>
/// Equips an item for a user.
/// </summary>
public async Task<FishItem?> EquipItemAsync(ulong userId, int index)
{
await using var ctx = db.GetDbContext();
await using var tr = await ctx.Database.BeginTransactionAsync();
try
{
var userItem = await ctx.GetTable<UserFishItem>()
.Where(x => x.UserId == userId)
.Skip(index - 1)
.Take(1)
.FirstOrDefaultAsync();
if (userItem is null)
return null;
var fishItem = GetItem(userItem.ItemId);
if (fishItem is null)
return null;
if (userItem.ItemType == FishItemType.Potion)
{
var query = ctx.GetTable<UserFishItem>()
.Where(x => x.Id == userItem.Id && !x.IsEquipped)
.Set(x => x.IsEquipped, true);
if (fishItem.DurationMinutes is { } dur)
query = query
.Set(x => x.ExpiresAt, DateTime.UtcNow.AddMinutes(dur));
await query.UpdateAsync();
await tr.CommitAsync();
return fishItem;
}
// UnEquip any currently equipped item of the same type
// and equip current one
await ctx.GetTable<UserFishItem>()
.Where(x => x.UserId == userId && x.ItemType == userItem.ItemType)
.Set(x => x.IsEquipped, x => x.Id == userItem.Id)
.UpdateAsync();
await tr.CommitAsync();
return fishItem;
}
catch
{
await tr.RollbackAsync();
return null;
}
}
/// <summary>
/// Unequips an item for a user.
/// </summary>
public async Task<UnequipResult> UnequipItemAsync(ulong userId, FishItemType itemType)
{
// can't unequip potions
if (itemType == FishItemType.Potion)
return UnequipResult.Potion;
await using var ctx = db.GetDbContext();
var affected = await ctx.GetTable<UserFishItem>()
.Where(x => x.UserId == userId && x.ItemType == itemType && x.IsEquipped)
.Set(x => x.IsEquipped, false)
.UpdateAsync();
if (affected > 0)
return UnequipResult.Success;
else
return UnequipResult.NotFound;
}
/// <summary>
/// Gets the multipliers from a user's equipped items.
/// </summary>
public async Task<FishMultipliers> GetUserMultipliersAsync(ulong userId)
{
var equippedItems = await GetEquippedItemsAsync(userId);
var multipliers = new FishMultipliers();
foreach (var (_, item) in equippedItems)
{
multipliers.FishMultiplier *= item.FishMultiplier ?? 1;
multipliers.TrashMultiplier *= item.TrashMultiplier ?? 1;
multipliers.StarMultiplier *= item.MaxStarMultiplier ?? 1;
multipliers.RareMultiplier *= item.RareMultiplier ?? 1;
multipliers.FishingSpeedMultiplier *= item.FishingSpeedMultiplier ?? 1;
}
return multipliers;
}
/// <summary>
/// Uses a bait item (reduces uses left) when fishing.
/// </summary>
public async Task<bool> UseBaitAsync(ulong userId)
{
await using var ctx = db.GetDbContext();
var updated = await ctx.GetTable<UserFishItem>()
.Where(x =>
x.UserId == userId &&
x.ItemType == FishItemType.Bait &&
x.IsEquipped)
.Set(x => x.UsesLeft, x => x.UsesLeft - 1)
.UpdateWithOutputAsync((o, n) => n);
if (updated.Length == 0)
return false;
if (updated[0].UsesLeft <= 0)
{
await ctx.GetTable<UserFishItem>()
.DeleteAsync(x => x.Id == updated[0].Id);
}
return true;
}
/// <summary>
/// Checks and removes expired items.
/// </summary>
public async Task CheckExpiredItemsAsync(ulong userId)
{
await using var ctx = db.GetDbContext();
var now = DateTime.UtcNow;
await ctx.GetTable<UserFishItem>()
.Where(x => x.UserId == userId && x.ExpiresAt.HasValue && x.ExpiresAt < now)
.DeleteAsync();
}
}
/// <summary>
/// Represents the result of a buy operation.
/// </summary>
public enum BuyResult
{
NotFound,
InsufficientFunds
}
/// <summary>
/// Represents the result of an equip operation.
/// </summary>
public enum UnequipResult
{
Success,
NotFound,
Potion
}
/// <summary>
/// Contains multipliers applied to fishing based on equipped items.
/// </summary>
public class FishMultipliers
{
public double FishMultiplier { get; set; } = 1.0;
public double TrashMultiplier { get; set; } = 1.0;
public double StarMultiplier { get; set; } = 1.0;
public double RareMultiplier { get; set; } = 1.0;
public double FishingSpeedMultiplier { get; set; } = 1.0;
}

View file

@ -13,7 +13,8 @@ public sealed class FishService(
IBotCache cache,
DbService db,
INotifySubscriber notify,
QuestService quests
QuestService quests,
FishItemService itemService
)
: IEService
{
@ -24,19 +25,24 @@ public sealed class FishService(
private static TypedKey<bool> FishingKey(ulong userId)
=> new($"fishing:{userId}");
public async Task<OneOf.OneOf<Task<FishResult?>, AlreadyFishing>> FishAsync(ulong userId, ulong channelId)
public async Task<OneOf.OneOf<Task<FishResult?>, AlreadyFishing>> FishAsync(ulong userId, ulong channelId,
FishMultipliers multipliers)
{
var duration = _rng.Next(3, 6);
var duration = _rng.Next(3, 6) / multipliers.FishingSpeedMultiplier;
if (!await cache.AddAsync(FishingKey(userId), true, TimeSpan.FromSeconds(duration), overwrite: false))
{
return new AlreadyFishing();
}
return TryFishAsync(userId, channelId, duration);
return TryFishAsync(userId, channelId, duration, multipliers);
}
private async Task<FishResult?> TryFishAsync(ulong userId, ulong channelId, int duration)
private async Task<FishResult?> TryFishAsync(
ulong userId,
ulong channelId,
double duration,
FishMultipliers multipliers)
{
var conf = fcs.Data;
await Task.Delay(TimeSpan.FromSeconds(duration));
@ -46,8 +52,8 @@ public sealed class FishService(
var trashChanceMultiplier = Math.Clamp(((2 * MAX_SKILL) - playerSkill) / MAX_SKILL, 1, 2);
var nothingChance = conf.Chance.Nothing;
var fishChance = conf.Chance.Fish * fishChanceMultiplier;
var trashChance = conf.Chance.Trash * trashChanceMultiplier;
var fishChance = conf.Chance.Fish * fishChanceMultiplier * multipliers.FishMultiplier;
var trashChance = conf.Chance.Trash * trashChanceMultiplier * multipliers.TrashMultiplier;
// first roll whether it's fish, trash or nothing
var totalChance = fishChance + trashChance + conf.Chance.Nothing;
@ -59,13 +65,21 @@ public sealed class FishService(
return null;
}
var items = typeRoll < nothingChance + fishChance
var isFish = typeRoll < nothingChance + fishChance;
var items = isFish
? conf.Fish
: conf.Trash;
var result = await FishAsyncInternal(userId, channelId, items, multipliers);
// use bait
if (result is not null)
{
await itemService.UseBaitAsync(userId);
}
var result = await FishAsyncInternal(userId, channelId, items);
// skill
if (result is not null)
{
var isSkillUp = await TrySkillUpAsync(userId, playerSkill);
@ -91,16 +105,16 @@ public sealed class FishService(
GetStarText(result.Stars, result.Fish.Stars)
));
}
}
await quests.ReportActionAsync(userId,
QuestEventType.FishCaught,
new()
{
{ "fish", result.Fish.Name },
{ "type", typeRoll < nothingChance + fishChance ? "fish" : "trash" },
{ "stars", result.Stars.ToString() }
});
await quests.ReportActionAsync(userId,
QuestEventType.FishCaught,
new()
{
{ "fish", result.Fish.Name },
{ "type", typeRoll < nothingChance + fishChance ? "fish" : "trash" },
{ "stars", result.Stars.ToString() }
});
}
return result;
}
@ -118,20 +132,20 @@ public sealed class FishService(
var maxSkill = (int)MAX_SKILL;
await ctx.GetTable<UserFishStats>()
.InsertOrUpdateAsync(() => new()
{
UserId = userId,
Skill = 1,
},
(old) => new()
{
UserId = userId,
Skill = old.Skill > maxSkill ? maxSkill : old.Skill + 1
},
() => new()
{
UserId = userId,
Skill = playerSkill
});
{
UserId = userId,
Skill = 1,
},
(old) => new()
{
UserId = userId,
Skill = old.Skill > maxSkill ? maxSkill : old.Skill + 1
},
() => new()
{
UserId = userId,
Skill = playerSkill
});
return true;
}
@ -162,7 +176,11 @@ public sealed class FishService(
return (skill, (int)MAX_SKILL);
}
private async Task<FishResult?> FishAsyncInternal(ulong userId, ulong channelId, List<FishData> items)
private async Task<FishResult?> FishAsyncInternal(
ulong userId,
ulong channelId,
List<FishData> items,
FishMultipliers multipliers)
{
var filteredItems = new List<FishData>();
@ -192,7 +210,20 @@ public sealed class FishService(
filteredItems.Add(item);
}
var maxSum = filteredItems.Sum(x => x.Chance * 100);
var maxSum = filteredItems
.Select(x => (x.Id, x.Chance, x.Stars))
.Select(x =>
{
if (x.Chance <= 15)
return x with
{
Chance = x.Chance *= multipliers.RareMultiplier
};
return x;
})
.Sum(x => { return x.Chance * 100; });
var roll = _rng.NextDouble() * maxSum;
@ -209,7 +240,7 @@ public sealed class FishService(
caught = new FishResult()
{
Fish = i,
Stars = GetRandomStars(i.Stars),
Stars = GetRandomStars(i.Stars, multipliers),
};
break;
}
@ -221,22 +252,22 @@ public sealed class FishService(
await uow.GetTable<FishCatch>()
.InsertOrUpdateAsync(() => new FishCatch()
{
UserId = userId,
FishId = caught.Fish.Id,
MaxStars = caught.Stars,
Count = 1
},
(old) => new FishCatch()
{
Count = old.Count + 1,
MaxStars = Math.Max(old.MaxStars, caught.Stars),
},
() => new()
{
FishId = caught.Fish.Id,
UserId = userId
});
{
UserId = userId,
FishId = caught.Fish.Id,
MaxStars = caught.Stars,
Count = 1
},
(old) => new FishCatch()
{
Count = old.Count + 1,
MaxStars = Math.Max(old.MaxStars, caught.Stars),
},
() => new()
{
FishId = caught.Fish.Id,
UserId = userId
});
return caught;
}
@ -353,25 +384,30 @@ public sealed class FishService(
/// if maxStars == 5, returns 1 (40%) or 2 (30%) or 3 (15%) or 4 (10%) or 5 (5%)
/// </summary>
/// <param name="maxStars">Max Number of stars to generate</param>
/// <param name="multipliers"></param>
/// <returns>Random number of stars</returns>
private int GetRandomStars(int maxStars)
private int GetRandomStars(int maxStars, FishMultipliers multipliers)
{
if (maxStars == 1)
return 1;
var maxStarMulti = multipliers.StarMultiplier;
double baseChance;
if (maxStars == 2)
{
// 15% chance of 1 star, 85% chance of 2 stars
return _rng.NextDouble() < 0.85 ? 1 : 2;
baseChance = Math.Clamp(0.15 * multipliers.StarMultiplier, 0, 1);
return _rng.NextDouble() < (1 - baseChance) ? 1 : 2;
}
if (maxStars == 3)
{
// 65% chance of 1 star, 30% chance of 2 stars, 5% chance of 3 stars
baseChance = 0.05 * multipliers.StarMultiplier;
var r = _rng.NextDouble();
if (r < 0.65)
if (r < (1 - baseChance - 0.3))
return 1;
if (r < 0.95)
if (r < (1 - baseChance))
return 2;
return 3;
}
@ -381,26 +417,28 @@ public sealed class FishService(
// this should never happen
// 50% chance of 1 star, 25% chance of 2 stars, 18% chance of 3 stars, 7% chance of 4 stars
var r = _rng.NextDouble();
if (r < 0.55)
baseChance = 0.02 * multipliers.StarMultiplier;
if (r < (1 - baseChance - 0.45))
return 1;
if (r < 0.80)
if (r < (1 - baseChance - 0.15))
return 2;
if (r < 0.98)
if (r < (1 - baseChance))
return 3;
return 4;
}
if (maxStars == 5)
{
// 40% chance of 1 star, 30% chance of 2 stars, 15% chance of 3 stars, 10% chance of 4 stars, 5% chance of 5 stars
// 40% chance of 1 star, 30% chance of 2 stars, 15% chance of 3 stars, 10% chance of 4 stars, 2% chance of 5 stars
var r = _rng.NextDouble();
if (r < 0.4)
baseChance = 0.02 * multipliers.StarMultiplier;
if (r < (1 - baseChance - 0.6))
return 1;
if (r < 0.7)
if (r < (1 - baseChance - 0.3))
return 2;
if (r < 0.9)
if (r < (1 - baseChance - 0.1))
return 3;
if (r < 0.98)
if (r < (1 - baseChance))
return 4;
return 5;
}

View file

@ -1,14 +1,13 @@
using System.ComponentModel.DataAnnotations;
using System.Text;
using EllieBot.Modules.Games.Fish;
using EllieBot.Modules.Games.Fish;
using Format = Discord.Format;
namespace EllieBot.Modules.Games;
public partial class Games
{
public class FishCommands(
public class FishingCommands(
FishService fs,
FishItemService fis,
FishConfigService fcs,
IBotCache cache,
CaptchaService captchaService) : EllieModule
@ -34,7 +33,10 @@ public partial class Games
using var stream = await img.ToStreamAsync();
var toSend = Response()
.File(stream, "timely.png");
.File(stream, "timely.png")
.Embed(CreateEmbed()
.WithFooter("captcha: type the text from the image")
.WithImageUrl("attachment://timely.png"));
#if GLOBAL_ELLIE
if (_rng.Next(0, 8) == 0)
@ -64,7 +66,8 @@ public partial class Games
}
var fishResult = await fs.FishAsync(ctx.User.Id, ctx.Channel.Id);
var multis = await fis.GetUserMultipliersAsync(ctx.User.Id);
var fishResult = await fs.FishAsync(ctx.User.Id, ctx.Channel.Id, multis);
if (fishResult.TryPickT1(out _, out var fishTask))
{
return;
@ -78,7 +81,10 @@ public partial class Games
.Embed(CreateEmbed()
.WithPendingColor()
.WithAuthor(ctx.User)
.WithDescription(GetText(strs.fish_waiting))
.WithDescription($"""
{GetText(strs.fish_waiting)}
{FishItemCommands.GetMultiplierInfo(multis)}
""")
.AddField(GetText(strs.fish_spot), GetSpotEmoji(spot) + " " + spot.ToString(), true)
.AddField(GetText(strs.fish_weather),
GetWeatherEmoji(currentWeather) + " " + currentWeather,
@ -133,7 +139,7 @@ public partial class Games
}
[Cmd]
public async Task Fishlist(int page = 1)
public async Task FishList(int page = 1)
{
if (--page < 0)
return;
@ -145,20 +151,33 @@ public partial class Games
var catchDict = catches.ToDictionary(x => x.FishId, x => x);
var items = await fis.GetEquippedItemsAsync(ctx.User.Id);
var desc = $"""
🧠 {skill} / {maxSkill}
""";
foreach (var itemType in Enum.GetValues<FishItemType>())
{
var i = items.Where(x => x.Item.ItemType == itemType).ToArray();
desc += " · " + FishItemCommands.GetEmoji(itemType) + " " +
(i.Any() ? string.Join(", ", i.Select(x => x.Item.Name)) : "None");
}
await Response()
.Paginated()
.Items(fishes)
.PageSize(9)
.CurrentPage(page)
.Page((fishes, i) =>
.Page((pageFish, i) =>
{
var eb = CreateEmbed()
.WithDescription($"🧠 **Skill:** {skill} / {maxSkill}")
.WithDescription(desc)
.WithAuthor(ctx.User)
.WithTitle(GetText(strs.fish_list_title))
.WithOkColor();
foreach (var f in fishes)
foreach (var f in pageFish)
{
if (catchDict.TryGetValue(f.Id, out var c))
{
@ -224,9 +243,6 @@ public partial class Games
FishingWeather.Clear => "☀️",
_ => ""
};
}
}

View file

@ -0,0 +1,21 @@
{
"fish_items_title": "Available Fishing Items",
"fish_buy_success": "Item purchased successfully!",
"fish_buy_not_found": "Item not found.",
"fish_buy_already_owned": "You already own this item.",
"fish_buy_insufficient_funds": "You don't have enough currency to buy this item.",
"fish_buy_error": "An error occurred while trying to buy the item.",
"fish_use_success": "Item equipped successfully!",
"fish_use_not_found": "Item not found.",
"fish_use_not_owned": "You don't own this item.",
"fish_use_expired": "This item has expired.",
"fish_use_no_uses": "This item has no uses left.",
"fish_use_error": "An error occurred while trying to use the item.",
"fish_unequip_success": "Item unequipped successfully!",
"fish_unequip_error": "Could not unequip item.",
"fish_inv_title": "{0}'s Fishing Inventory",
"fish_gift_self": "You can't gift items to yourself.",
"fish_gift_not_owned": "You don't own this item.",
"fish_gift_equipped": "You can't gift equipped items. Unequip it first.",
"fish_gift_success": "Item successfully gifted to {0}!"
}

View file

@ -13,8 +13,6 @@ public sealed class QuestService(
DiscordSocketClient client
) : IEService, IExecPreCommand
{
private readonly EllieRandom rng = new();
private readonly IQuest[] _availableQuests =
[
new HangmanWinQuest(),
@ -48,10 +46,10 @@ public sealed class QuestService(
_ = Task.Run(async () =>
{
Log.Information("Action reported by {UserId}: {EventType} {Metadata}",
userId,
eventType,
metadata.ToJson());
// Log.Information("Action reported by {UserId}: {EventType} {Metadata}",
// userId,
// eventType,
// metadata.ToJson());
metadata ??= new();
var now = DateTime.UtcNow;
@ -140,11 +138,11 @@ public sealed class QuestService(
{
var today = date.Date;
var timeUntilTomorrow = today.AddDays(1) - DateTime.UtcNow;
if (!await botCache.AddAsync(UserHasQuestsKey(userId), true, expiry: timeUntilTomorrow))
if (!await botCache.AddAsync(UserHasQuestsKey(userId), true, expiry: timeUntilTomorrow, overwrite: false))
return;
await using var uow = db.GetDbContext();
var newQuests = GenerateDailyQuestsAsync(userId);
var newQuests = GenerateDailyQuestsAsync();
for (var i = 0; i < MAX_QUESTS_PER_DAY; i++)
{
await uow.GetTable<UserQuest>()
@ -170,7 +168,7 @@ public sealed class QuestService(
}
}
private IReadOnlyList<IQuest> GenerateDailyQuestsAsync(ulong userId)
private IReadOnlyList<IQuest> GenerateDailyQuestsAsync()
{
return _availableQuests
.ToList()

View file

@ -1,4 +1,6 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using EllieBot.Modules.Games.Quests;
namespace EllieBot.Db.Models;
@ -18,4 +20,19 @@ public class UserQuest
public bool IsCompleted { get; set; }
public DateTime DateAssigned { get; set; }
}
public sealed class UserQuestEntityConfiguration : IEntityTypeConfiguration<UserQuest>
{
public void Configure(EntityTypeBuilder<UserQuest> builder)
{
builder.HasIndex(x => x.UserId);
builder.HasIndex(x => new
{
x.UserId,
x.QuestNumber,
x.DateAssigned
}).IsUnique();
}
}

View file

@ -46,7 +46,7 @@ public partial class ResponseBuilder
GetInteractions()
{
var leftButton = new ButtonBuilder()
.WithStyle(ButtonStyle.Primary)
.WithStyle(ButtonStyle.Secondary)
.WithCustomId(BUTTON_LEFT)
.WithEmote(InteractionHelpers.ArrowLeft)
.WithDisabled(lastPage == 0 || currentPage <= 0);
@ -80,7 +80,7 @@ public partial class ResponseBuilder
}
var rightButton = new ButtonBuilder()
.WithStyle(ButtonStyle.Primary)
.WithStyle(ButtonStyle.Secondary)
.WithCustomId(BUTTON_RIGHT)
.WithEmote(InteractionHelpers.ArrowRight)
.WithDisabled(lastPage == 0 || currentPage >= lastPage);

View file

@ -172,6 +172,8 @@ public sealed class GamblingTxTracker(
if (txData is null)
return;
await Task.Yield();
if (_gamblingTypes.Contains(txData.Type))
{
globalStats.AddOrUpdate(txData.Type,

View file

@ -1,26 +1,25 @@
#!/bin/bash
set -euo pipefail
# Check if migration name is provided
if [ -z "$1" ]; then
if [ $# -eq 0 ]; then
echo "Error: Migration name must be specified."
echo "Usage: $0 <MigrationName>"
exit 1
fi
MIGRATION_NAME=$1
MIGRATION_NAME="$1"
# Step 1: Create initial migration
echo "Creating new migration..."
# Step 1: Create initial migrations
dotnet build
# Getting previous migration names in order to generate SQL scripts
dotnet ef migrations add "${MIGRATION_NAME}" --context SqliteContext --output-dir "Migrations/Sqlite" --no-build
dotnet ef migrations add "${MIGRATION_NAME}" --context PostgresqlContext --output-dir "Migrations/PostgreSql" --no-build
dotnet ef migrations add "$MIGRATION_NAME" --context SqliteContext --output-dir "Migrations/Sqlite" --no-build
dotnet ef migrations add "$MIGRATION_NAME" --context PostgresqlContext --output-dir "Migrations/PostgreSql" --no-build
dotnet build
# Check for migration creation success
if [ $? -ne 0 ]; then
echo "Error: Failed to create migrations"
exit 1
@ -29,34 +28,27 @@ fi
# Step 2: Generate SQL scripts
echo "Generating diff SQL scripts..."
NEW_MIGRATION_ID_SQLITE=$(dotnet ef migrations list --context SqliteContext --no-build --no-connect | tail -2 | head -1 | cut -d' ' -f1)
NEW_MIGRATION_ID_POSTGRESQL=$(dotnet ef migrations list --context PostgresqlContext --no-build --no-connect | tail -2 | head -1 | cut -d' ' -f1)
dotnet ef migrations script init $MIGRATION_NAME --context SqliteContext -o "Migrations/Sqlite/${NEW_MIGRATION_ID_SQLITE}.sql" --no-build
dotnet ef migrations script init $MIGRATION_NAME --context PostgresqlContext -o "Migrations/Postgresql/${NEW_MIGRATION_ID_POSTGRESQL}.sql" --no-build
NEW_MIGRATION_ID_SQLITE=$(dotnet ef migrations list --context SqliteContext --no-build --no-connect | tail -n 2 | head -n 1 | awk '{print $1}')
NEW_MIGRATION_ID_POSTGRESQL=$(dotnet ef migrations list --context PostgresqlContext --no-build --no-connect | tail -n 2 | head -n 1 | awk '{print $1}')
dotnet ef migrations script init "$MIGRATION_NAME" --context SqliteContext -o "Migrations/Sqlite/${NEW_MIGRATION_ID_SQLITE}.sql" --no-build
dotnet ef migrations script init "$MIGRATION_NAME" --context PostgresqlContext -o "Migrations/PostgreSql/${NEW_MIGRATION_ID_POSTGRESQL}.sql" --no-build
if [ $? -ne 0 ]; then
echo "Error: Failed to generate SQL script"
exit 1
fi
# Step 3: Cleanup migration files
echo "Cleaning up all migration files..."
# Step 3: Clean up migration files by removing everything
for file in "Migrations/Sqlite/"*.cs; do
echo "Deleting: $(basename "$file")"
rm -- "$file"
done
for file in "Migrations/Postgresql/"*.cs; do
echo "Deleting: $(basename "$file")"
rm -- "$file"
done
# Step 4: Adding new initial migration
echo "Creating new initial migration..."
find "Migrations/Sqlite" -name "*.cs" -type f -print -delete
find "Migrations/PostgreSql" -name "*.cs" -type f -print -delete
dotnet build
# Step 4: Create new initial migrations
echo "Creating new initial migration..."
dotnet ef migrations add init --context SqliteContext --output-dir "Migrations/Sqlite" --no-build
dotnet ef migrations add init --context PostgresqlContext --output-dir "Migrations/PostgreSql" --no-build

View file

@ -1660,4 +1660,26 @@ massping:
questlog:
- questlog
- qlog
- myquests
- myquests
fishshop:
- fishshop
- fishop
fishbuy:
- fishbuy
- fibuy
fishuse:
- fishuse
- fiuse
- fiequip
- fieq
- fiquip
fishunequip:
- fishunequip
- fiuneq
- fiunequip
- fiunuse
- fishunuse
fishinv:
- fishinv
- finv
- fiinv

View file

@ -5214,4 +5214,48 @@ questlog:
ex:
- ''
params:
- { }
- { }
fishshop:
desc: |-
Opens the fish shop.
Lists all fish items available for sale
ex:
- ''
params:
- { }
fishinv:
desc: |-
Opens your fish inventory.
Your inventory contains all items you've purchased but not spent.
ex:
- ''
params:
- { }
fishbuy:
desc: |-
Purchase a fishing item with the specified id.
After purchase the item will appear in your inventory where you can use/equip it.
ex:
- '1'
params:
- id:
desc: "The ID of the item to buy."
fishuse:
desc: |-
Use a fishing item in your inventory.
You can unequip it later, unless its a potion.
ex:
- '1'
params:
- index:
desc: "The index of the item to use."
fishunequip:
desc: |-
Unequips an item by specifying its index in your inventory.
You can use it again later.
You can't unequip potions.
ex:
- '1'
params:
- index:
desc: "The index of the item to unequip."

View file

@ -1248,5 +1248,16 @@
"quest_log": "Quest Log",
"dailies_done": "You've completed your dailies!",
"dailies_reset": "Reset {0}",
"daily_completed": "You've completed a daily quest: {0}"
"daily_completed": "You've completed a daily quest: {0}",
"fish_items_title": "Fishing Shop",
"fish_buy_success": "Item purchased successfully!",
"fish_item_not_found": "Item not found.",
"fish_buy_insufficient_funds": "You don't have enough currency to buy this item.",
"fish_buy_error": "An error occurred while trying to buy the item.",
"fish_use_success": "Item equipped successfully!",
"fish_use_error": "Unknown error occurred while trying to equip the item.",
"fish_unequip_success": "Item unequipped successfully!",
"fish_unequip_error": "Could not unequip item.",
"fish_inv_title": "Fishing Inventory",
"fish_cant_uneq_potion": "You can't unequip a potion."
}