Compare commits

...

3 Commits

Author SHA1 Message Date
barelyprofessional a568fec88e Add switch to disable Cecil
Also oops I accidentally committed my horrible CSV hack for quotes previously. Oh well.
2026-05-16 11:45:31 -05:00
barelyprofessional 915a4cc8bf Added MathNet.Numerics for Cecil. Refactored and fixed some issues 2026-05-16 11:34:37 -05:00
alogindtractor e14a08e3d5 Adds Cecil (#119)
* Add Cecil for mechanics

Used to predetermine the outcome of games according to a probability function, which is stored as a skew. Each game made using cecil is intended to have its own skew (or maybe multiple skews for different difficulties)
Will eventually be used for planes 2

* Add CecilCommand for gambling functionality

Skip all the fancy casino visuals and let Cecil take the wheel
Customizeable difficulty
!cecil <bet> <optional difficulty, default 1> <optional max win>
https://i.ddos.lgbt/raw/CecilHelper.html

* Validate max win value in CecilCommand

Added validation to ensure max win is greater than 1.

* Adjust slot symbol probabilities and random range

9208000 | Payout: 0 | RTP: 97.84% | Feach Chance: 0.78% | Hit Rate: 34.03% | Win Rate: 15.14% | Biggest Win: 14100.0x | Avg Win: 6.01x | Median Hit: 0.6x

got some complaint about slots so figured I'd change it up a bit
2026-05-16 17:58:21 +02:00
7 changed files with 277 additions and 14 deletions
@@ -0,0 +1,114 @@
using System.Text.RegularExpressions;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
namespace KfChatDotNetBot.Commands.Kasino;
[KasinoCommand]
[WagerCommand]
public class CecilCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^cecil (?<bet>\d+(?:\.\d+)?) (?<difficulty>\d+(?:\.\d+)?) (?<maxwin>\d+(?:\.\d+)?)", RegexOptions.IgnoreCase),
new Regex(@"^cecil (?<bet>\d+(?:\.\d+)?) (?<difficulty>\d+(?:\.\d+)?)", RegexOptions.IgnoreCase),
new Regex(@"^cecil (?<bet>\d+(?:\.\d+)?)", RegexOptions.IgnoreCase),
new Regex("^cecil")
];
public string? HelpText => "!cecil <bet> <optional difficulty> <optional max win>";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(60);
public RateLimitOptionsModel? RateLimitOptions => new()
{
MaxInvocations = 5,
Window = TimeSpan.FromSeconds(10)
};
public bool WhisperCanInvoke => true;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user,
GroupCollection arguments,
CancellationToken ctx)
{
if (message is { IsWhisper: false, MessageUuid: not null })
{
await botInstance.KfClient.DeleteMessageAsync(message.MessageUuid);
}
var cleanupDelay = TimeSpan.FromSeconds(15);
var settings = await SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.KasinoCecilEnabled]);
var cecilEnabled = settings[BuiltIn.Keys.KasinoCecilEnabled].ToBoolean();
if (!cecilEnabled)
{
await botInstance.ReplyToUser(message,
$"{user.FormatUsername()}, Cecil is currently disabled.",
true, autoDeleteAfter: cleanupDelay);
return;
}
if (!arguments.TryGetValue("bet", out var amount)) //if user just enters !keno
{
await botInstance.ReplyToUser(message,
$"{user.FormatUsername()}, not enough arguments. !cecil <bet> <optional difficulty> <[i]optional max win > 1[/i] - Cecil Tool: https://i.ddos.lgbt/raw/CecilHelper.html>",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var wager = Convert.ToDecimal(amount.Value);
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}");
if (gambler.Balance < wager)
{
await botInstance.ReplyToUser(message,
$"{user.FormatUsername()}, your balance of {await gambler.Balance.FormatKasinoCurrencyAsync()} isn't enough for this wager.",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var difficulty = 1.0;
double result;
if (arguments.TryGetValue("difficulty", out var diff))
{
difficulty = Convert.ToDouble(diff.Value);
}
if (!arguments.TryGetValue("maxwin", out var maxWin))
{
var skew = new GammaSkew(difficulty, 0);
result = Cecil.Consult(skew, 0);
}
else
{
var mWin = Convert.ToDouble(maxWin.Value);
if (mWin < 1)
{
await botInstance.ReplyToUser(message, $"{user.FormatUsername()}, max win must be greater than 1.", true, autoDeleteAfter: cleanupDelay);
return;
}
var skew = new BetaSkew(difficulty, mWin, 0);
result = Cecil.Consult(skew);
}
var payout = wager * Convert.ToDecimal(result);
var net = payout - wager;
var newBalance = await Money.NewWagerAsync(gambler.Id, wager, net, WagerGame.Cecil, ct: ctx);
var colors =
await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor
]);
var red = colors[BuiltIn.Keys.KiwiFarmsRedColor].Value;
var green = colors[BuiltIn.Keys.KiwiFarmsGreenColor].Value;
var color = (payout > wager) ? green : red;
await botInstance.ReplyToUser(message,
$"{user.FormatUsername()}, Cecil has determined you are due [color={color}]{await payout.FormatKasinoCurrencyAsync()}[/color] from your wager of {await wager.FormatKasinoCurrencyAsync()}. Balance: {await newBalance.FormatKasinoCurrencyAsync()}",
true, autoDeleteAfter: cleanupDelay);
}
}
+13 -13
View File
@@ -477,7 +477,7 @@ public class SlotsCommand : ICommand
for (var i = 0; i < 5; i++) {
for (var j = 0; j < 5; j++)
{
var r = _rand.NextDouble() * 100.1;
var r = _rand.NextDouble() * 100;
if (f != 0 && j > 1) r *= 1.1;
if (rigged == 'W') // guarantee max win
@@ -608,18 +608,18 @@ public class SlotsCommand : ICommand
if (rigged == 'L') RigSlotBoard();
char PickSlotSymbol(double r, int i, int j)
{
if (r < 22.5) return 'A';
else if (r < 44.5) return 'B';
else if (r < 52.5) return 'C';
else if (r < 66.5) return 'D';
else if (r < 78.5) return 'E';
else if (r < 84.5) return 'F';
else if (r < 89.5) return 'G';
else if (r < 92.5) return 'H';
else if (r < 95.5) return 'I';
else if (r < 97.5) return 'J';
else if (r < 98.5) return WILD;
else if (r < (j <= 2 ? 99 : 99.5)) { if (!ex.Contains(j)) { return EXPANDER; } else return WILD; }
if (r < 15) return 'A';
else if (r < 30) return 'B';
else if (r < 40) return 'C';
else if (r < 50) return 'D';
else if (r < 65) return 'E';
else if (r < 72.5) return 'F';
else if (r < 80) return 'G';
else if (r < 86) return 'H';
else if (r < 92) return 'I';
else if (r < 97) return 'J';
else if (r < 97.5) return WILD;
else if (r < (j <= 2 ? 98.25 : 98.5)) { if (!ex.Contains(j)) { return EXPANDER; } else return WILD; }
else { if (fc < 5) { fc++;
return FEATURE;
} else return WILD; }
+1
View File
@@ -14,6 +14,7 @@
<PackageReference Include="Homoglyphic" Version="2.0.1" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
<PackageReference Include="MathNet.Numerics" Version="5.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
<PrivateAssets>all</PrivateAssets>
@@ -310,7 +310,8 @@ public enum WagerGame
Plinko,
[Description("Roulette but live")]
Roulette,
Krash
Krash,
Cecil
}
public enum GamblerState
+36
View File
@@ -65,6 +65,42 @@ namespace KfChatDotNetBot
}
await db.SaveChangesAsync();
}
if (Path.Exists("tags.csv"))
{
logger.Info("Importing from tags.csv");
var tags = await File.ReadAllTextAsync("tags.csv");
var i = 0;
foreach (var row in tags.Split(Environment.NewLine))
{
i++;
var values = row.Split(",", StringSplitOptions.RemoveEmptyEntries);
if (values.Length < 2)
{
logger.Error($"Row {i} does not have enough columns");
continue;
}
var image = await db.Images.FirstOrDefaultAsync(image => image.Id == Convert.ToInt32(values[0]));
if (image == null)
{
logger.Error($"Row {i} has an unknown image ID");
continue;
}
var importTags = values[1].ToLower().Split(" ",
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList();
if (importTags.Count == 0)
{
logger.Error($"Row {i} has no tags after splitting the string");
continue;
}
var newList = image.TagList.Concat(importTags).Distinct().ToList();
if (newList == image.TagList) continue;
image.TagList = newList;
}
await db.SaveChangesAsync();
}
logger.Info("Handing over to bot now");
Console.OutputEncoding = Encoding.UTF8;
_ = new ChatBot();
+109
View File
@@ -0,0 +1,109 @@
using MathNet.Numerics;
using MathNet.Numerics.Distributions;
using RandN;
using RandN.Compat;
namespace KfChatDotNetBot.Services;
public static class Cecil
{
private static readonly RandomShim<StandardRng> Rand = RandomShim.Create(StandardRng.Create());
public static double Consult(Skew skew, double minThreshold = 0)
{
var r = Rand.NextDouble();
if (r < skew.LossRate)
{
return 0;
}
var winRate = 1 - skew.LossRate;
var scaledR = (r - skew.LossRate) / winRate;
double baseResult;
switch (skew)
{
case BetaSkew betaSkew:
baseResult = Beta.InvCDF(betaSkew.Weight, betaSkew.Beta, scaledR) * betaSkew.CalibratedMaxWin;
break;
case GammaSkew gammaSkew:
baseResult = Gamma.InvCDF(gammaSkew.Weight, gammaSkew.Weight, scaledR);
break;
default:
return 0;
}
baseResult /= winRate;
if (minThreshold == 0) return baseResult;
var limit = (skew is BetaSkew b) ? b.MaxWin : double.MaxValue;
return Math.Min(Round(baseResult, minThreshold), limit);
double Round(double baseR, double minT)
{
var lower = Math.Floor(baseR / minT) * minT;
var upper = lower + minT;
var roundChance = (baseR - lower) / minT;
return (Rand.NextDouble() < roundChance) ? lower : upper;
}
}
}
public abstract class Skew
{
public double Weight;
public double LossRate;
protected double TargetEv = 1;
public abstract void Calibrate(double winRate);
public void Rig(double desiredEv, double lr)
{
TargetEv = desiredEv;
Calibrate(1-lr);
}
}
public class BetaSkew : Skew
{
public readonly double MaxWin;
public double CalibratedMaxWin;
public double Beta;
public double Alpha;
public BetaSkew(double vol, double mw, double lr)
{
MaxWin = mw;
Weight = vol;
LossRate = lr;
Rig(1, lr);
}
public override void Calibrate(double winRate)
{
CalibratedMaxWin = MaxWin * winRate;
var normalizer = TargetEv / CalibratedMaxWin;
Alpha = normalizer * Weight;
Beta = (1 - normalizer) * Weight;
}
}
public class GammaSkew : Skew
{
public double Alpha;
public double B;
public GammaSkew(double wght, double lr)
{
Weight = wght;
LossRate = lr;
Alpha = Math.Pow(Weight, Weight) / SpecialFunctions.Gamma(wght);
Rig(1, lr);
}
public override void Calibrate(double winRate)
{
B = (Weight / TargetEv) * winRate;
}
}
+2
View File
@@ -595,6 +595,8 @@ public static class BuiltIn
public static string KasinoPlanesSize = "Kasino.Planes.Size";
[BuiltInSetting("Size (%) of the Plinko board", SettingValueType.Text, "70", WholeNumberRegex)]
public static string KasinoPlinkoSize = "Kasino.Plinko.Size";
[BuiltInSetting("Whether Cecil is enabled", SettingValueType.Boolean, "true", BooleanRegex)]
public static string KasinoCecilEnabled = "Kasino.Cecil.Enabled";
}
}