Added .fish command
This commit is contained in:
parent
c574956d94
commit
b71e8969a0
23 changed files with 15047 additions and 6612 deletions
88
src/EllieBot.Tests/FishTests.cs
Normal file
88
src/EllieBot.Tests/FishTests.cs
Normal file
|
@ -0,0 +1,88 @@
|
|||
// using System;
|
||||
// using System.Collections.Generic;
|
||||
// using System.Diagnostics;
|
||||
// using System.IO;
|
||||
// using System.Linq;
|
||||
// using Ellie.Common;
|
||||
// using EllieBot.Modules.Games;
|
||||
// using NUnit.Framework;
|
||||
//
|
||||
// namespace EllieBot.Tests;
|
||||
//
|
||||
// public class FishTests
|
||||
// {
|
||||
// [Test]
|
||||
// public void TestWeather()
|
||||
// {
|
||||
// var fs = new FishService(null, null);
|
||||
//
|
||||
// var rng = new Random();
|
||||
//
|
||||
// // output = @"ro+dD:bN0uVqV3ZOAv6r""EFeA'A]u]uSyz2Qd'r#0Vf:5zOX\VgSsF8LgRCL/uOW";
|
||||
// while (true)
|
||||
// {
|
||||
// var output = "";
|
||||
// for (var i = 0; i < 64; i++)
|
||||
// {
|
||||
// var c = (char)rng.Next(33, 123);
|
||||
// output += c;
|
||||
// }
|
||||
//
|
||||
// output = "";
|
||||
// var weathers = new List<FishingWeather>();
|
||||
// for (var i = 0; i < 1_000_000; i++)
|
||||
// {
|
||||
// var w = fs.GetWeather(DateTime.UtcNow.AddHours(6 * i), output);
|
||||
// weathers.Add(w);
|
||||
// }
|
||||
//
|
||||
// var vals = weathers.GroupBy(x => x)
|
||||
// .ToDictionary(x => x.Key, x => x.Count());
|
||||
//
|
||||
// var str = weathers.Select(x => (int)x).Join("");
|
||||
// var maxLength = MaxLength(str);
|
||||
//
|
||||
// if (maxLength < 12)
|
||||
// {
|
||||
// foreach (var v in vals)
|
||||
// {
|
||||
// Console.WriteLine($"{v.Key}: {v.Value}");
|
||||
// }
|
||||
//
|
||||
// Console.WriteLine(output);
|
||||
// Console.WriteLine(maxLength);
|
||||
//
|
||||
// File.WriteAllText("data.txt", weathers.Select(x => (int)x).Join(""));
|
||||
//
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // string with same characters
|
||||
// static int MaxLength(String s)
|
||||
// {
|
||||
// int ans = 1, temp = 1;
|
||||
//
|
||||
// // Traverse the string
|
||||
// for (int i = 1; i < s.Length; i++)
|
||||
// {
|
||||
// // If character is same as
|
||||
// // previous increment temp value
|
||||
// if (s[i] == s[i - 1])
|
||||
// {
|
||||
// ++temp;
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// ans = Math.Max(ans, temp);
|
||||
// temp = 1;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// ans = Math.Max(ans, temp);
|
||||
//
|
||||
// // Return the required answer
|
||||
// return ans;
|
||||
// }
|
||||
// }
|
|
@ -1,4 +1,5 @@
|
|||
using Ellie.Common;
|
||||
using System;
|
||||
using Ellie.Common;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace EllieBot.Tests
|
||||
|
@ -120,5 +121,12 @@ namespace EllieBot.Tests
|
|||
num = new kwum(int.MaxValue);
|
||||
Assert.AreEqual("3zzzzzz", num.ToString());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestPower()
|
||||
{
|
||||
var num = new kwum((int)Math.Pow(32, 2));
|
||||
Assert.AreEqual("322", num.ToString());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -74,6 +74,9 @@ public abstract class EllieContext : DbContext
|
|||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// load all entities from current assembly
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(EllieContext).Assembly);
|
||||
|
||||
#region Notify
|
||||
|
||||
modelBuilder.Entity<Notify>(e =>
|
||||
|
|
4151
src/EllieBot/Migrations/PostgreSql/20250114060020_fishes.Designer.cs
generated
Normal file
4151
src/EllieBot/Migrations/PostgreSql/20250114060020_fishes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
39
src/EllieBot/Migrations/PostgreSql/20250114060020_fishes.cs
Normal file
39
src/EllieBot/Migrations/PostgreSql/20250114060020_fishes.cs
Normal file
|
@ -0,0 +1,39 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EllieBot.Migrations.PostgreSql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class fishes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "fishcatch",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
userid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
|
||||
fishid = table.Column<int>(type: "integer", nullable: false),
|
||||
count = table.Column<int>(type: "integer", nullable: false),
|
||||
maxstars = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_fishcatch", x => x.id);
|
||||
table.UniqueConstraint("ak_fishcatch_userid_fishid", x => new { x.userid, x.fishid });
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "fishcatch");
|
||||
}
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load diff
3198
src/EllieBot/Migrations/Sqlite/20250114055850_fishes.Designer.cs
generated
Normal file
3198
src/EllieBot/Migrations/Sqlite/20250114055850_fishes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
38
src/EllieBot/Migrations/Sqlite/20250114055850_fishes.cs
Normal file
38
src/EllieBot/Migrations/Sqlite/20250114055850_fishes.cs
Normal file
|
@ -0,0 +1,38 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EllieBot.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class fishes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FishCatch",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
UserId = table.Column<ulong>(type: "INTEGER", nullable: false),
|
||||
FishId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Count = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
MaxStars = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_FishCatch", x => x.Id);
|
||||
table.UniqueConstraint("AK_FishCatch_UserId_FishId", x => new { x.UserId, x.FishId });
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "FishCatch");
|
||||
}
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load diff
55
src/EllieBot/Modules/Games/Fish/CaptchaService.cs
Normal file
55
src/EllieBot/Modules/Games/Fish/CaptchaService.cs
Normal file
|
@ -0,0 +1,55 @@
|
|||
using SixLabors.Fonts;
|
||||
using SixLabors.Fonts.Unicode;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Drawing.Processing;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using Color = SixLabors.ImageSharp.Color;
|
||||
|
||||
namespace EllieBot.Modules.Games;
|
||||
|
||||
public sealed class CaptchaService(FontProvider fonts) : IEService
|
||||
{
|
||||
private readonly EllieRandom _rng = new();
|
||||
|
||||
public Image<Rgba32> GetPasswordImage(string password)
|
||||
{
|
||||
var img = new Image<Rgba32>(50, 24);
|
||||
|
||||
var font = fonts.NotoSans.CreateFont(22);
|
||||
var outlinePen = new SolidPen(Color.Black, 0.5f);
|
||||
var strikeoutRun = new RichTextRun
|
||||
{
|
||||
Start = 0,
|
||||
End = password.GetGraphemeCount(),
|
||||
Font = font,
|
||||
StrikeoutPen = new SolidPen(Color.White, 4),
|
||||
TextDecorations = TextDecorations.Strikeout
|
||||
};
|
||||
|
||||
// draw password on the image
|
||||
img.Mutate(x =>
|
||||
{
|
||||
DrawTextExtensions.DrawText(x,
|
||||
new RichTextOptions(font)
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
FallbackFontFamilies = fonts.FallBackFonts,
|
||||
Origin = new(25, 12),
|
||||
TextRuns = [strikeoutRun]
|
||||
},
|
||||
password,
|
||||
Brushes.Solid(Color.White),
|
||||
outlinePen);
|
||||
});
|
||||
|
||||
return img;
|
||||
}
|
||||
|
||||
public string GeneratePassword()
|
||||
{
|
||||
var num = _rng.Next((int)Math.Pow(31, 2), (int)Math.Pow(32, 3));
|
||||
return new kwum(num).ToString();
|
||||
}
|
||||
}
|
27
src/EllieBot/Modules/Games/Fish/FishCatch.cs
Normal file
27
src/EllieBot/Modules/Games/Fish/FishCatch.cs
Normal file
|
@ -0,0 +1,27 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace EllieBot.Modules.Games;
|
||||
|
||||
public sealed class FishCatch
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
public ulong UserId { get; set; }
|
||||
public int FishId { get; set; }
|
||||
public int Count { get; set; }
|
||||
public int MaxStars { get; set; }
|
||||
}
|
||||
|
||||
public sealed class FishCatchConfiguration : IEntityTypeConfiguration<FishCatch>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<FishCatch> builder)
|
||||
{
|
||||
builder.HasAlternateKey(x => new
|
||||
{
|
||||
x.UserId,
|
||||
x.FishId
|
||||
});
|
||||
}
|
||||
}
|
8
src/EllieBot/Modules/Games/Fish/FishChance.cs
Normal file
8
src/EllieBot/Modules/Games/Fish/FishChance.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
namespace EllieBot.Modules.Games;
|
||||
|
||||
public sealed class FishChance
|
||||
{
|
||||
public int Fish { get; set; } = 75;
|
||||
public int Trash { get; set; } = 20;
|
||||
public int Nothing { get; set; } = 0;
|
||||
}
|
292
src/EllieBot/Modules/Games/Fish/FishCommands.cs
Normal file
292
src/EllieBot/Modules/Games/Fish/FishCommands.cs
Normal file
|
@ -0,0 +1,292 @@
|
|||
using System.Text;
|
||||
using Format = Discord.Format;
|
||||
|
||||
namespace EllieBot.Modules.Games;
|
||||
|
||||
public partial class Games
|
||||
{
|
||||
public class FishCommands(
|
||||
FishService fs,
|
||||
FishConfigService fcs,
|
||||
IBotCache cache,
|
||||
CaptchaService service) : EllieModule
|
||||
{
|
||||
private TypedKey<bool> FishingWhitelistKey(ulong userId)
|
||||
=> new($"fishingwhitelist:{userId}");
|
||||
|
||||
[Cmd]
|
||||
public async Task Fish()
|
||||
{
|
||||
var cRes = await cache.GetAsync(FishingWhitelistKey(ctx.User.Id));
|
||||
if (cRes.TryPickT1(out _, out _))
|
||||
{
|
||||
var password = await GetUserCaptcha(ctx.User.Id);
|
||||
var img = service.GetPasswordImage(password);
|
||||
using var stream = await img.ToStreamAsync();
|
||||
var captcha = await Response()
|
||||
.File(stream, "timely.png")
|
||||
.SendAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var userInput = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id);
|
||||
if (userInput?.ToLowerInvariant() != password?.ToLowerInvariant())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// whitelist the user for 30 minutes
|
||||
await cache.AddAsync(FishingWhitelistKey(ctx.User.Id), true, TimeSpan.FromMinutes(30));
|
||||
// reset the password
|
||||
await ClearUserCaptcha(ctx.User.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ = captcha.DeleteAsync();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var fishResult = await fs.FishAsync(ctx.User.Id, ctx.Channel.Id);
|
||||
if (fishResult.TryPickT1(out _, out var fishTask))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentWeather = fs.GetCurrentWeather();
|
||||
var currentTod = fs.GetTime();
|
||||
var spot = fs.GetSpot(ctx.Channel.Id);
|
||||
|
||||
var msg = await Response()
|
||||
.Embed(CreateEmbed()
|
||||
.WithPendingColor()
|
||||
.WithAuthor(ctx.User)
|
||||
.WithDescription(GetText(strs.fish_waiting))
|
||||
.AddField(GetText(strs.fish_spot), GetSpotEmoji(spot) + " " + spot.ToString(), true)
|
||||
.AddField(GetText(strs.fish_weather),
|
||||
GetWeatherEmoji(currentWeather) + " " + currentWeather,
|
||||
true)
|
||||
.AddField(GetText(strs.fish_tod), GetTodEmoji(currentTod) + " " + currentTod, true))
|
||||
.SendAsync();
|
||||
|
||||
var res = await fishTask;
|
||||
if (res is null)
|
||||
{
|
||||
await Response().Error(strs.fish_nothing).SendAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await Response()
|
||||
.Embed(CreateEmbed()
|
||||
.WithOkColor()
|
||||
.WithAuthor(ctx.User)
|
||||
.WithDescription(GetText(strs.fish_caught(Format.Bold(res.Fish.Name))))
|
||||
.AddField(GetText(strs.fish_quality), GetStarText(res.Stars, res.Fish.Stars), true)
|
||||
.AddField(GetText(strs.desc), res.Fish.Fluff, true)
|
||||
.WithThumbnailUrl(res.Fish.Image))
|
||||
.SendAsync();
|
||||
|
||||
await msg.DeleteAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task FishSpot()
|
||||
{
|
||||
var ws = fs.GetWeatherForPeriods(7);
|
||||
var spot = fs.GetSpot(ctx.Channel.Id);
|
||||
var time = fs.GetTime();
|
||||
|
||||
await Response()
|
||||
.Embed(CreateEmbed()
|
||||
.WithOkColor()
|
||||
.WithDescription(GetText(strs.fish_weather_duration(fs.GetWeatherPeriodDuration())))
|
||||
.AddField(GetText(strs.fish_spot), GetSpotEmoji(spot) + " " + spot, true)
|
||||
.AddField(GetText(strs.fish_tod), GetTodEmoji(time) + " " + time, true)
|
||||
.AddField(GetText(strs.fish_weather_forecast),
|
||||
ws.Select(x => GetWeatherEmoji(x)).Join(""),
|
||||
true))
|
||||
.SendAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Fishlist(int page = 1)
|
||||
{
|
||||
if (--page < 0)
|
||||
return;
|
||||
|
||||
var fishes = await fs.GetAllFish();
|
||||
|
||||
Log.Information(fishes.Count.ToString());
|
||||
var catches = await fs.GetUserCatches(ctx.User.Id);
|
||||
|
||||
var catchDict = catches.ToDictionary(x => x.FishId, x => x);
|
||||
|
||||
await Response()
|
||||
.Paginated()
|
||||
.Items(fishes)
|
||||
.PageSize(9)
|
||||
.CurrentPage(page)
|
||||
.Page((fs, i) =>
|
||||
{
|
||||
var eb = CreateEmbed()
|
||||
.WithOkColor();
|
||||
|
||||
foreach (var f in fs)
|
||||
{
|
||||
if (catchDict.TryGetValue(f.Id, out var c))
|
||||
{
|
||||
eb.AddField(f.Name,
|
||||
GetFishEmoji(f, c.Count)
|
||||
+ " "
|
||||
+ GetSpotEmoji(f.Spot)
|
||||
+ GetTodEmoji(f.Time)
|
||||
+ GetWeatherEmoji(f.Weather)
|
||||
+ "\n"
|
||||
+ GetStarText(c.MaxStars, f.Stars)
|
||||
+ "\n"
|
||||
+ Format.Italics(f.Fluff),
|
||||
true);
|
||||
}
|
||||
else
|
||||
{
|
||||
eb.AddField("?", GetFishEmoji(null, 0) + "\n" + GetStarText(0, f.Stars), true);
|
||||
}
|
||||
}
|
||||
|
||||
return eb;
|
||||
})
|
||||
.SendAsync();
|
||||
}
|
||||
|
||||
private string GetFishEmoji(FishData? fish, int count)
|
||||
{
|
||||
if (fish is null)
|
||||
return "";
|
||||
|
||||
return fish.Emoji + " x" + count;
|
||||
}
|
||||
|
||||
private string GetSpotEmoji(FishingSpot? spot)
|
||||
{
|
||||
if (spot is not FishingSpot fs)
|
||||
return string.Empty;
|
||||
|
||||
var conf = fcs.Data;
|
||||
|
||||
return conf.SpotEmojis[(int)fs];
|
||||
}
|
||||
|
||||
private string GetTodEmoji(FishingTime? fishTod)
|
||||
{
|
||||
return fishTod switch
|
||||
{
|
||||
FishingTime.Night => "🌑",
|
||||
FishingTime.Dawn => "🌅",
|
||||
FishingTime.Dusk => "🌆",
|
||||
FishingTime.Day => "☀️",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
|
||||
private string GetWeatherEmoji(FishingWeather? w)
|
||||
=> w switch
|
||||
{
|
||||
FishingWeather.Rain => "🌧️",
|
||||
FishingWeather.Snow => "❄️",
|
||||
FishingWeather.Storm => "⛈️",
|
||||
FishingWeather.Clear => "☀️",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
private string GetStarText(int resStars, int fishStars)
|
||||
{
|
||||
if (resStars == fishStars)
|
||||
{
|
||||
return MultiplyStars(fcs.Data.StarEmojis[^1], fishStars);
|
||||
}
|
||||
|
||||
var c = fcs.Data;
|
||||
var starsp1 = MultiplyStars(c.StarEmojis[resStars], resStars);
|
||||
var starsp2 = MultiplyStars(c.StarEmojis[0], fishStars - resStars);
|
||||
|
||||
return starsp1 + starsp2;
|
||||
}
|
||||
|
||||
private string MultiplyStars(string starEmoji, int count)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
sb.Append(starEmoji);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static TypedKey<string> CaptchaPasswordKey(ulong userId)
|
||||
=> new($"timely_password:{userId}");
|
||||
|
||||
private async Task<string> GetUserCaptcha(ulong userId)
|
||||
{
|
||||
var pw = await cache.GetOrAddAsync(CaptchaPasswordKey(userId),
|
||||
() =>
|
||||
{
|
||||
var password = service.GeneratePassword();
|
||||
return Task.FromResult(password)!;
|
||||
});
|
||||
|
||||
return pw!;
|
||||
}
|
||||
|
||||
private ValueTask<bool> ClearUserCaptcha(ulong userId)
|
||||
=> cache.RemoveAsync(CaptchaPasswordKey(userId));
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// public sealed class UserFishStats
|
||||
// {
|
||||
// [Key]
|
||||
// public int Id { get; set; }
|
||||
//
|
||||
// public ulong UserId { get; set; }
|
||||
//
|
||||
// public ulong CommonCatches { get; set; }
|
||||
// public ulong RareCatches { get; set; }
|
||||
// public ulong VeryRareCatches { get; set; }
|
||||
// public ulong EpicCatches { get; set; }
|
||||
//
|
||||
// public ulong CommonMaxCatches { get; set; }
|
||||
// public ulong RareMaxCatches { get; set; }
|
||||
// public ulong VeryRareMaxCatches { get; set; }
|
||||
// public ulong EpicMaxCatches { get; set; }
|
||||
//
|
||||
// public int TotalStars { get; set; }
|
||||
// }
|
||||
|
||||
public enum FishingSpot
|
||||
{
|
||||
Ocean,
|
||||
River,
|
||||
Lake,
|
||||
Swamp,
|
||||
Reef
|
||||
}
|
||||
|
||||
public enum FishingTime
|
||||
{
|
||||
Night,
|
||||
Dawn,
|
||||
Day,
|
||||
Dusk
|
||||
}
|
||||
|
||||
public enum FishingWeather
|
||||
{
|
||||
Clear,
|
||||
Rain,
|
||||
Storm,
|
||||
Snow
|
||||
}
|
19
src/EllieBot/Modules/Games/Fish/FishConfig.cs
Normal file
19
src/EllieBot/Modules/Games/Fish/FishConfig.cs
Normal file
|
@ -0,0 +1,19 @@
|
|||
using Cloneable;
|
||||
using EllieBot.Common.Yml;
|
||||
|
||||
namespace EllieBot.Modules.Games;
|
||||
|
||||
[Cloneable]
|
||||
public sealed partial class FishConfig : ICloneable<FishConfig>
|
||||
{
|
||||
[Comment("DO NOT CHANGE")]
|
||||
public int Version { get; set; } = 1;
|
||||
|
||||
public string WeatherSeed { get; set; } = string.Empty;
|
||||
public List<string> StarEmojis { get; set; } = new();
|
||||
public List<string> SpotEmojis { get; set; } = new();
|
||||
public FishChance Chance { get; set; } = new FishChance();
|
||||
|
||||
public List<FishData> Fish { get; set; } = new();
|
||||
public List<FishData> Trash { get; set; } = new();
|
||||
}
|
19
src/EllieBot/Modules/Games/Fish/FishConfigService.cs
Normal file
19
src/EllieBot/Modules/Games/Fish/FishConfigService.cs
Normal file
|
@ -0,0 +1,19 @@
|
|||
using EllieBot.Common.Configs;
|
||||
|
||||
namespace EllieBot.Modules.Games;
|
||||
|
||||
public sealed class FishConfigService : ConfigServiceBase<FishConfig>
|
||||
{
|
||||
private static string FILE_PATH = "data/fish.yml";
|
||||
private static readonly TypedKey<FishConfig> _changeKey = new("config.fish.updated");
|
||||
|
||||
public override string Name
|
||||
=> "fishing";
|
||||
|
||||
public FishConfigService(
|
||||
IConfigSeria serializer,
|
||||
IPubSub pubSub)
|
||||
: base(FILE_PATH, serializer, pubSub, _changeKey)
|
||||
{
|
||||
}
|
||||
}
|
16
src/EllieBot/Modules/Games/Fish/FishData.cs
Normal file
16
src/EllieBot/Modules/Games/Fish/FishData.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
namespace EllieBot.Modules.Games;
|
||||
|
||||
public class FishData
|
||||
{
|
||||
public required int Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public FishingWeather? Weather { get; set; }
|
||||
public FishingSpot? Spot { get; set; }
|
||||
public FishingTime? Time { get; set; }
|
||||
public required double Chance { get; set; }
|
||||
public required int Stars { get; set; }
|
||||
public required string Fluff { get; set; }
|
||||
public List<string>? Condition { get; set; }
|
||||
public string? Image { get; init; }
|
||||
public string? Emoji { get; set; }
|
||||
}
|
9
src/EllieBot/Modules/Games/Fish/FishResult.cs
Normal file
9
src/EllieBot/Modules/Games/Fish/FishResult.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
namespace EllieBot.Modules.Games;
|
||||
|
||||
public sealed class FishResult
|
||||
{
|
||||
public required FishData Fish { get; init; }
|
||||
public int Stars { get; init; }
|
||||
}
|
||||
|
||||
public readonly record struct AlreadyFishing;
|
318
src/EllieBot/Modules/Games/Fish/FishService.cs
Normal file
318
src/EllieBot/Modules/Games/Fish/FishService.cs
Normal file
|
@ -0,0 +1,318 @@
|
|||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace EllieBot.Modules.Games;
|
||||
|
||||
public sealed class FishService(FishConfigService fcs, IBotCache cache, DbService db) : IEService
|
||||
{
|
||||
private Random _rng = new Random();
|
||||
|
||||
private static TypedKey<bool> FishingKey(ulong userId)
|
||||
=> new($"fishing:{userId}");
|
||||
|
||||
public async Task<OneOf.OneOf<Task<FishResult?>, AlreadyFishing>> FishAsync(ulong userId, ulong channelId)
|
||||
{
|
||||
var duration = _rng.Next(1, 9);
|
||||
|
||||
if (!await cache.AddAsync(FishingKey(userId), true, TimeSpan.FromSeconds(duration), overwrite: false))
|
||||
{
|
||||
return new AlreadyFishing();
|
||||
}
|
||||
|
||||
return TryFishAsync(userId, channelId, duration);
|
||||
}
|
||||
|
||||
private async Task<FishResult?> TryFishAsync(ulong userId, ulong channelId, int duration)
|
||||
{
|
||||
var conf = fcs.Data;
|
||||
await Task.Delay(TimeSpan.FromSeconds(duration));
|
||||
|
||||
// first roll whether it's fish, trash or nothing
|
||||
var totalChance = conf.Chance.Fish + conf.Chance.Trash + conf.Chance.Nothing;
|
||||
var typeRoll = _rng.NextDouble() * totalChance;
|
||||
|
||||
if (typeRoll < conf.Chance.Nothing)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var items = typeRoll < conf.Chance.Nothing + conf.Chance.Fish
|
||||
? conf.Fish
|
||||
: conf.Trash;
|
||||
|
||||
return await FishAsyncInternal(userId, channelId, items);
|
||||
}
|
||||
|
||||
private async Task<FishResult?> FishAsyncInternal(ulong userId, ulong channelId, List<FishData> items)
|
||||
{
|
||||
var filteredItems = new List<FishData>();
|
||||
|
||||
var loc = GetSpot(channelId);
|
||||
var time = GetTime();
|
||||
var w = GetWeather(DateTime.UtcNow);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.Condition is { Count: > 0 })
|
||||
{
|
||||
if (!item.Condition.Any(x => channelId.ToString().EndsWith(x)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (item.Spot is not null && item.Spot != loc)
|
||||
continue;
|
||||
|
||||
if (item.Time is not null && item.Time != time)
|
||||
continue;
|
||||
|
||||
if (item.Weather is not null && item.Weather != w)
|
||||
continue;
|
||||
|
||||
filteredItems.Add(item);
|
||||
Log.Information("Added {FishName} to filtered items", item.Name);
|
||||
}
|
||||
|
||||
var maxSum = filteredItems.Sum(x => x.Chance * 100);
|
||||
|
||||
|
||||
var roll = _rng.NextDouble() * maxSum;
|
||||
Log.Information("Roll: {Roll}, MaxSum: {MaxSum}", roll, maxSum);
|
||||
|
||||
FishResult? caught = null;
|
||||
|
||||
var curSum = 0d;
|
||||
foreach (var i in filteredItems)
|
||||
{
|
||||
curSum += i.Chance * 100;
|
||||
|
||||
if (roll < curSum)
|
||||
{
|
||||
caught = new FishResult()
|
||||
{
|
||||
Fish = i,
|
||||
Stars = GetRandomStars(i.Stars),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (caught is not null)
|
||||
{
|
||||
await using var uow = db.GetDbContext();
|
||||
|
||||
await uow.GetTable<FishCatch>()
|
||||
.InsertOrUpdateAsync(() => new FishCatch()
|
||||
{
|
||||
UserId = userId,
|
||||
FishId = caught.Fish.Id,
|
||||
MaxStars = caught.Stars,
|
||||
Count = 1
|
||||
},
|
||||
(old) => new()
|
||||
{
|
||||
Count = old.Count + 1,
|
||||
MaxStars = Math.Max((int)old.MaxStars, caught.Stars),
|
||||
},
|
||||
() => new()
|
||||
{
|
||||
FishId = caught.Fish.Id,
|
||||
UserId = userId
|
||||
});
|
||||
|
||||
return caught;
|
||||
}
|
||||
|
||||
Log.Error(
|
||||
"Something went wrong in the fish command, no fish with sufficient chance was found, Roll: {Roll}, MaxSum: {MaxSum}",
|
||||
roll,
|
||||
maxSum);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public FishingSpot GetSpot(ulong channelId)
|
||||
{
|
||||
var cid = (channelId >> 22 >> 8) & 1;
|
||||
|
||||
return cid switch
|
||||
{
|
||||
< 1 => FishingSpot.Reef,
|
||||
< 3 => FishingSpot.River,
|
||||
< 5 => FishingSpot.Lake,
|
||||
< 7 => FishingSpot.Swamp,
|
||||
_ => FishingSpot.Ocean,
|
||||
};
|
||||
}
|
||||
|
||||
public FishingTime GetTime()
|
||||
{
|
||||
var hour = DateTime.UtcNow.Hour % 12;
|
||||
|
||||
if (hour < 3)
|
||||
return FishingTime.Night;
|
||||
|
||||
if (hour < 4)
|
||||
return FishingTime.Dawn;
|
||||
|
||||
if (hour < 11)
|
||||
return FishingTime.Day;
|
||||
|
||||
return FishingTime.Dusk;
|
||||
|
||||
}
|
||||
|
||||
private const int WEATHER_PERIODS_PER_DAY = 12;
|
||||
|
||||
public IReadOnlyList<FishingWeather> GetWeatherForPeriods(int periods)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var result = new FishingWeather[periods];
|
||||
|
||||
for (var i = 0; i < periods; i++)
|
||||
{
|
||||
result[i] = GetWeather(now.AddHours(i * GetWeatherPeriodDuration()));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public FishingWeather GetCurrentWeather()
|
||||
=> GetWeather(DateTime.UtcNow);
|
||||
|
||||
public FishingWeather GetWeather(DateTime time)
|
||||
=> GetWeather(time, fcs.Data.WeatherSeed);
|
||||
|
||||
private FishingWeather GetWeather(DateTime time, string seed)
|
||||
{
|
||||
var year = time.Year;
|
||||
var dayOfYear = time.DayOfYear;
|
||||
var hour = time.Hour;
|
||||
|
||||
var num = (year * 100_000) + (dayOfYear * 100) + (hour / GetWeatherPeriodDuration());
|
||||
|
||||
Span<byte> dataArray = stackalloc byte[4];
|
||||
BitConverter.TryWriteBytes(dataArray, num);
|
||||
|
||||
Span<byte> seedArray = stackalloc byte[seed.Length];
|
||||
for (var index = 0; index < seed.Length; index++)
|
||||
{
|
||||
var c = seed[index];
|
||||
seedArray[index] = (byte)c;
|
||||
}
|
||||
|
||||
Span<byte> arr = stackalloc byte[dataArray.Length + seedArray.Length];
|
||||
|
||||
dataArray.CopyTo(arr);
|
||||
seedArray.CopyTo(arr[dataArray.Length..]);
|
||||
|
||||
using var algo = SHA512.Create();
|
||||
|
||||
Span<byte> hash = stackalloc byte[64];
|
||||
algo.TryComputeHash(arr, hash, out _);
|
||||
|
||||
byte reduced = 0;
|
||||
foreach (var u in hash)
|
||||
reduced ^= u;
|
||||
|
||||
var r = reduced % 16;
|
||||
|
||||
// return (FishingWeather)r;
|
||||
return r switch
|
||||
{
|
||||
< 5 => FishingWeather.Clear,
|
||||
< 9 => FishingWeather.Rain,
|
||||
< 13 => FishingWeather.Storm,
|
||||
_ => FishingWeather.Snow
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns a random number of stars between 1 and maxStars
|
||||
/// if maxStars == 1, returns 1
|
||||
/// if maxStars == 2, returns 1 (66%) or 2 (33%)
|
||||
/// if maxStars == 3, returns 1 (65%) or 2 (25%) or 3 (10%)
|
||||
/// 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>
|
||||
/// <returns>Random number of stars</returns>
|
||||
private int GetRandomStars(int maxStars)
|
||||
{
|
||||
if (maxStars == 1)
|
||||
return 1;
|
||||
|
||||
if (maxStars == 2)
|
||||
{
|
||||
// 66% chance of 1 star, 33% chance of 2 stars
|
||||
return _rng.NextDouble() < 0.66 ? 1 : 2;
|
||||
}
|
||||
|
||||
if (maxStars == 3)
|
||||
{
|
||||
// 65% chance of 1 star, 25% chance of 2 stars, 10% chance of 3 stars
|
||||
var r = _rng.NextDouble();
|
||||
if (r < 0.65)
|
||||
return 1;
|
||||
if (r < 0.9)
|
||||
return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (maxStars == 4)
|
||||
{
|
||||
// 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.5)
|
||||
return 1;
|
||||
if (r < 0.7)
|
||||
return 2;
|
||||
if (r < 0.85)
|
||||
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
|
||||
var r = _rng.NextDouble();
|
||||
if (r < 0.4)
|
||||
return 1;
|
||||
if (r < 0.7)
|
||||
return 2;
|
||||
if (r < 0.9)
|
||||
return 3;
|
||||
if (r < 0.95)
|
||||
return 4;
|
||||
return 5;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
public int GetWeatherPeriodDuration()
|
||||
=> 24 / WEATHER_PERIODS_PER_DAY;
|
||||
|
||||
public async Task<List<FishData>> GetAllFish()
|
||||
{
|
||||
await Task.Yield();
|
||||
|
||||
var conf = fcs.Data;
|
||||
return conf.Fish.Concat(conf.Trash).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<FishCatch>> GetUserCatches(ulong userId)
|
||||
{
|
||||
await using var ctx = db.GetDbContext();
|
||||
|
||||
var catches = await ctx.GetTable<FishCatch>()
|
||||
.Where(x => x.UserId == userId)
|
||||
.ToListAsyncLinqToDB();
|
||||
|
||||
return catches;
|
||||
}
|
||||
}
|
|
@ -54,13 +54,9 @@ public sealed class EllieRandom : Random
|
|||
{
|
||||
var bytes = new byte[sizeof(double)];
|
||||
_rng.GetBytes(bytes);
|
||||
return Math.Abs((BitConverter.ToDouble(bytes, 0) / double.MaxValue) + 1);
|
||||
return Math.Abs((BitConverter.ToDouble(bytes, 0) / (double.MaxValue + 1)));
|
||||
}
|
||||
|
||||
public override double NextDouble()
|
||||
{
|
||||
var bytes = new byte[sizeof(double)];
|
||||
_rng.GetBytes(bytes);
|
||||
return BitConverter.ToDouble(bytes, 0);
|
||||
}
|
||||
=> Sample();
|
||||
}
|
|
@ -1559,4 +1559,17 @@ notifyclear:
|
|||
- notifclr
|
||||
winlb:
|
||||
- winlb
|
||||
- wins
|
||||
- wins
|
||||
fish:
|
||||
- fish
|
||||
- fi
|
||||
fishlist:
|
||||
- fishlist
|
||||
- fili
|
||||
- fishes
|
||||
- fil
|
||||
- fishlist
|
||||
fishspot:
|
||||
- fishspot
|
||||
- fisp
|
||||
- fish?
|
43
src/EllieBot/data/fish.yml
Normal file
43
src/EllieBot/data/fish.yml
Normal file
|
@ -0,0 +1,43 @@
|
|||
# DO NOT CHANGE
|
||||
version: 1
|
||||
weatherSeed: "w%29';^eGE)9oWHM(aI9I;%1[.r^z2ZS7ShV,l')o(e%#\"hVzb>oxQq^`.&/7srh"
|
||||
chance:
|
||||
fish: 80
|
||||
trash: 15
|
||||
nothing: 5
|
||||
starEmojis:
|
||||
- <:emptystar:1326838565786877962>
|
||||
- <:onestar:1326838456739168361>
|
||||
- <:twostar:1326838508198957107>
|
||||
- <:threestar:1326838525601251429>
|
||||
- <:fourstar:1326838552520294462>
|
||||
spotEmojis:
|
||||
- <:ocean:1328519734953771120>
|
||||
- <:river:1328519754620862504>
|
||||
- <:lake:1328315260561788989>
|
||||
- <:swamp:1328519766083633224>
|
||||
- <:reef:1328519744646545421>
|
||||
fish:
|
||||
- name: Bass
|
||||
id: 0
|
||||
weather:
|
||||
spot:
|
||||
time:
|
||||
chance: 100
|
||||
stars: 4
|
||||
fluff: Very common.
|
||||
condition:
|
||||
image: https://cdn.nadeko.bot/fish/bass.png
|
||||
emoji: "<:bass:1328520376892002386>"
|
||||
trash:
|
||||
- name: Plastic Bag
|
||||
id: 1002
|
||||
weather:
|
||||
spot:
|
||||
time:
|
||||
chance: 50
|
||||
stars: 4
|
||||
fluff: "Trophy of your contribution to the environment."
|
||||
condition:
|
||||
image: https://cdn.nadeko.bot/fish/plasticbag.png
|
||||
emoji: "<:plasticbag:1328520895454515211>"
|
|
@ -4904,4 +4904,30 @@ winlb:
|
|||
- '5'
|
||||
params:
|
||||
- page:
|
||||
desc: "The optional page to display."
|
||||
desc: "The optional page to display."
|
||||
fish:
|
||||
desc: |-
|
||||
Attempt to catch a fish.
|
||||
Different fish live in different places, at different times and during different times of the day.
|
||||
ex:
|
||||
- ''
|
||||
params:
|
||||
- { }
|
||||
fishlist:
|
||||
desc: |-
|
||||
Look at your fish catalogue.
|
||||
Shows how many of each fish you caught and what was the highest quality.
|
||||
For each caught fish, it also shows its required spot, time of day and weather.
|
||||
ex:
|
||||
- ''
|
||||
params:
|
||||
- { }
|
||||
- page:
|
||||
desc: "The optional page to display."
|
||||
fishspot:
|
||||
desc: |-
|
||||
Shows information about the current fish spot, weather and time.
|
||||
ex:
|
||||
- ''
|
||||
params:
|
||||
- { }
|
|
@ -1159,5 +1159,15 @@
|
|||
"notify_desc_removerolerew": "Triggers when a user loses a role as a reward for reaching a level (xprew).",
|
||||
"notify_desc_not_found": "No description found for this notify event. Please report this.",
|
||||
"winlb": "Biggest Wins Leaderboard",
|
||||
"no_banner": "No banner set."
|
||||
"no_banner": "No banner set.",
|
||||
"fish_nothing": "You caught nothing, try again.",
|
||||
"fish_caught": "You caught a {0}!",
|
||||
"fish_quality": "Quality",
|
||||
"fish_spot": "Spot",
|
||||
"fish_waiting": "Fishing...",
|
||||
"fish_weather": "Weather",
|
||||
"fish_weather_duration": "Each weather period lasts for {0} hours.",
|
||||
"fish_weather_current": "Current",
|
||||
"fish_weather_forecast": "Forecast",
|
||||
"fish_tod": "Time of Day"
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue