Added the initial framework for the new Money system.

Includes
- 5 new tables: Gamblers, Transactions, Wagers, Exclusions, Perks
- Still heavily WIP and not ready to be enabled, no games present and a lot of missing functionality
- For now it's completely disabled until it's ready to be used.
This commit is contained in:
barelyprofessional
2025-08-20 14:59:09 -05:00
parent 8d100b013b
commit 6ca1cf055c
15 changed files with 1831 additions and 14 deletions

View File

@@ -23,4 +23,9 @@ public class ApplicationDbContext : DbContext
// public DbSet<PocketWatchTransactionDbModel> PocketWatchTransactions { get; set; }
public DbSet<MomDbModel> Moms { get; set; }
public DbSet<StreamDbModel> Streams { get; set; }
public DbSet<GamblerDbModel> Gamblers { get; set; }
public DbSet<TransactionDbModel> Transactions { get; set; }
public DbSet<WagerDbModel> Wagers { get; set; }
public DbSet<GamblerExclusionDbModel> Exclusions { get; set; }
public DbSet<GamblerPerkDbModel> Perks { get; set; }
}

View File

@@ -1,5 +1,6 @@
using System.Net;
using System.Text.Json;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;

View File

@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;

View File

@@ -1,7 +1,7 @@
using System.Text;
using System.Text.RegularExpressions;
namespace KfChatDotNetBot;
namespace KfChatDotNetBot.Extensions;
public static class Extensions
{

View File

@@ -0,0 +1,254 @@
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using NLog;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace KfChatDotNetBot.Extensions;
public static class MoneyExtensions
{
/// <summary>
/// Retrieve a gambler entity for a given user
/// Returns null if createIfNoneExists is false and no gambler exists
/// Also returns null if the user was permanently banned from gambling
/// If there are multiple "active" gamblers, only the newest is returned
/// </summary>
/// <param name="user">User whose gambler entity you wish to retrieve</param>
/// <param name="createIfNoneExists">Whether to create a gambler entity if none exists already</param>
/// <param name="ct">Cancellation token</param>
/// <returns></returns>
public static async Task<GamblerDbModel?> GetGamblerEntity(this UserDbModel user, bool createIfNoneExists = true, CancellationToken ct = default)
{
await using var db = new ApplicationDbContext();
db.Attach(user);
var gambler =
await db.Gamblers.LastOrDefaultAsync(g => g.User == user && g.State != GamblerState.PermanentlyBanned,
cancellationToken: ct);
if (!createIfNoneExists) return gambler;
var permaBanned = await db.Gamblers.AnyAsync(g => g.User == user && g.State == GamblerState.PermanentlyBanned, cancellationToken: ct);
if (permaBanned) return null;
var initialBalance = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.MoneyInitialBalance)).ToType<decimal>();
await db.Gamblers.AddAsync(new GamblerDbModel
{
User = user,
Balance = initialBalance,
Created = DateTimeOffset.UtcNow,
RandomSeed = Guid.NewGuid().ToString(),
State = GamblerState.Active,
TotalWagered = 0,
NextVipLevelWagerRequirement = Money.VipLevels[0].BaseWagerRequirement
}, ct);
await db.SaveChangesAsync(ct);
return await db.Gamblers.LastOrDefaultAsync(g => g.User == user, cancellationToken: ct);
}
/// <summary>
/// Simple check to see whether a user has been permanently banned from the kasino
/// </summary>
/// <param name="user">User to check for the permaban</param>
/// <param name="ct">Cancellation token</param>
/// <returns></returns>
public static async Task<bool> IsPermanentlyBanned(this UserDbModel user, CancellationToken ct = default)
{
await using var db = new ApplicationDbContext();
db.Attach(user);
return await db.Gamblers.AnyAsync(u => u.User == user && u.State == GamblerState.PermanentlyBanned,
cancellationToken: ct);
}
/// <summary>
/// Modify a gambler's balance by a given +/- amount
/// </summary>
/// <param name="gambler">Gambler entity whose balance you wish to modify</param>
/// <param name="effect">The 'effect' of this modification, as in how much to add or remove</param>
/// <param name="eventSource">The event which initiated this balance modification</param>
/// <param name="comment">Optional comment to provide for the transaction</param>
/// <param name="from">If applicable, who sent the transaction (e.g. if a juicer)</param>
/// <param name="ct">Cancellation token</param>
public static async Task ModifyBalance(this GamblerDbModel gambler, decimal effect,
TransactionSourceEventType eventSource, string? comment = null, GamblerDbModel? from = null,
CancellationToken ct = default)
{
await using var db = new ApplicationDbContext();
db.Attach(gambler);
gambler.Balance += effect;
await db.Transactions.AddAsync(new TransactionDbModel
{
Gambler = gambler,
EventSource = eventSource,
Effect = effect,
Time = DateTimeOffset.UtcNow,
Comment = comment,
From = from,
NewBalance = gambler.Balance,
TimeUnixEpochSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
}, ct);
await db.SaveChangesAsync(ct);
}
/// <summary>
/// Add a wager to the database
/// Will also issue a balance update unless you explicitly disable autoModifyBalance
/// </summary>
/// <param name="gambler">Gambler who wagered</param>
/// <param name="wagerAmount">The amount they wagered</param>
/// <param name="wagerEffect">The effect of the wager on the gambler's balance.
/// Please note this includes the wager itself. So for a bet of 500 that paid out 50, you pass in an effect of -450
/// If instead they won 600 then the effect would be +100. the wagered amount is not factored into balance changes,
/// it's just recorded for calculating bonuses and statistics</param>
/// <param name="game">The game which was played as part of this wager</param>
/// <param name="autoModifyBalance">Whether tu automatically update the user's balance according to the wager effect.
/// Typically you should leave this on so as to ensure every wager has an associated transaction.</param>
/// <param name="gameMeta">Optionally store metadata related to the wager, such as player choices, or game outcomes.
/// Data will be serialized to JSON.</param>
/// <param name="isComplete">Whether the game is 'complete'. Set to false for wagers with unknown outcomes.
/// NOTE: wagerEffect will be ignored, instead value will be derived from the wagerAmount</param>
/// <param name="ct">Cancellation token</param>
public static async Task NewWager(this GamblerDbModel gambler, decimal wagerAmount, decimal wagerEffect,
WagerGame game, bool autoModifyBalance = true, dynamic? gameMeta = null, bool isComplete = true,
CancellationToken ct = default)
{
var logger = LogManager.GetCurrentClassLogger();
await using var db = new ApplicationDbContext();
db.Attach(gambler);
string? metaJson = null;
if (gameMeta != null)
{
metaJson = JsonConvert.SerializeObject(gameMeta, Formatting.Indented);
logger.Debug("Serialized metadata follows");
logger.Debug(metaJson);
}
if (!isComplete)
{
wagerEffect = -wagerAmount;
logger.Debug($"isComplete is false, set wagerEffect to {wagerEffect}");
}
decimal multi = 0;
if (isComplete && wagerAmount > 0 && wagerEffect > 0 && wagerAmount + wagerEffect > 0)
{
multi = (wagerAmount + wagerEffect) / wagerAmount;
logger.Debug($"multi is {multi}");
}
gambler.TotalWagered += wagerAmount;
var wager = await db.Wagers.AddAsync(new WagerDbModel
{
Gambler = gambler,
Time = DateTimeOffset.UtcNow,
WagerAmount = wagerAmount,
WagerEffect = wagerEffect,
Multiplier = multi,
Game = game,
GameMeta = metaJson,
IsComplete = isComplete,
TimeUnixEpochSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
}, ct);
await db.SaveChangesAsync(ct);
gambler.Balance += wagerEffect;
if (!autoModifyBalance) return;
await db.Transactions.AddAsync(new TransactionDbModel
{
Gambler = gambler,
EventSource = TransactionSourceEventType.Gambling,
Time = DateTimeOffset.UtcNow,
Effect = wagerEffect,
Comment = $"Win from wager {wager.Entity.Id}",
From = null,
NewBalance = gambler.Balance,
TimeUnixEpochSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
}, ct);
await db.SaveChangesAsync(ct);
}
/// <summary>
/// Get an active exclusion, returns null if there's no active exclusion
/// If there's somehow multiple exclusions, will just grab the most recent one
/// </summary>
/// <param name="gambler">Gambler entity to retrieve the exclusion for</param>
/// <param name="ct">Cancellation token</param>
/// <returns></returns>
public static async Task<GamblerExclusionDbModel?> GetActiveExclusion(this GamblerDbModel gambler, CancellationToken ct = default)
{
await using var db = new ApplicationDbContext();
db.Attach(gambler);
return (await db.Exclusions.Where(g => g.Gambler == gambler).ToListAsync(ct))
.LastOrDefault(e => e.Expires <= DateTimeOffset.UtcNow);
}
/// <summary>
/// Get random number using the gambler's seed and a given number of iterations
/// </summary>
/// <param name="gambler">Gambler entity to reference their random seed</param>
/// <param name="min">Minimum value for generating random</param>
/// <param name="max">Maximum value for random, incremented by 1 if add1ToMaxParam is true
/// so it's consistent with the behavior of min</param>
/// <param name="iterations">Number of random number generator iterations to run before returning a result</param>
/// <param name="incrementMaxParam">Increments the 'max' param by 1 as otherwise the value will never be returned by Random.Next()
/// This is because the default behavior of .NET is unintuitive, min value can be returned but max is never by default</param>
/// <returns>A random number based on the given parameters</returns>
/// <exception cref="ArgumentException"></exception>
public static int GetRandomNumber(this GamblerDbModel gambler, int min, int max, int iterations = 10,
bool incrementMaxParam = true)
{
var random = new Random(gambler.RandomSeed.GetHashCode());
var result = 0;
var i = 0;
if (incrementMaxParam) max++;
if (iterations <= 0) throw new ArgumentException("Iterations cannot be 0 or lower");
while (i < iterations)
{
i++;
result = random.Next(min, max);
}
return result;
}
/// <summary>
/// Get the user's current VIP level
/// </summary>
/// <param name="gambler">Gambler entity whose VIP level you want to get</param>
/// <param name="ct">Cancellation token</param>
/// <returns></returns>
public static async Task<GamblerPerkDbModel?> GetVipLevel(this GamblerDbModel gambler, CancellationToken ct = default)
{
await using var db = new ApplicationDbContext();
db.Attach(gambler);
var perk = await db.Perks.LastOrDefaultAsync(
p => p.Gambler == gambler && p.PerkType == GamblerPerkType.VipLevel, ct);
return perk;
}
public static async Task<decimal> UpgradeVipLevel(this GamblerDbModel gambler, NextVipLevelModel nextVipLevel,
CancellationToken ct = default)
{
await using var db = new ApplicationDbContext();
db.Attach(gambler);
var payout = nextVipLevel.VipLevel.BonusPayout;
if (nextVipLevel.Tier > 1)
{
payout = nextVipLevel.VipLevel.BonusPayout / (nextVipLevel.VipLevel.Tiers - 1);
}
await db.Perks.AddAsync(new GamblerPerkDbModel
{
Gambler = gambler,
PerkType = GamblerPerkType.VipLevel,
PerkName = nextVipLevel.VipLevel.Name,
PerkTier = nextVipLevel.Tier,
Time = DateTimeOffset.UtcNow,
Payout = payout,
}, ct);
gambler.NextVipLevelWagerRequirement = nextVipLevel.WagerRequirement;
await db.SaveChangesAsync(ct);
await gambler.ModifyBalance(payout, TransactionSourceEventType.Bonus,
$"VIP Level '{nextVipLevel.VipLevel.Icon} {nextVipLevel.VipLevel.Name}' Tier {nextVipLevel.Tier} level up bonus", ct: ct);
return payout;
}
}

View File

@@ -0,0 +1,633 @@
// <auto-generated />
using System;
using KfChatDotNetBot;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace KfChatDotNetBot.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20250820195308_Money")]
partial class Money
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.ChipsggBetDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<double>("Amount")
.HasColumnType("REAL");
b.Property<string>("BetId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Created")
.HasColumnType("TEXT");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("TEXT");
b.Property<float>("CurrencyPrice")
.HasColumnType("REAL");
b.Property<string>("GameTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<float>("Multiplier")
.HasColumnType("REAL");
b.Property<DateTimeOffset>("Updated")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Win")
.HasColumnType("INTEGER");
b.Property<double>("Winnings")
.HasColumnType("REAL");
b.HasKey("Id");
b.ToTable("ChipsggBets");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<decimal>("Balance")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Created")
.HasColumnType("TEXT");
b.Property<decimal>("NextVipLevelWagerRequirement")
.HasColumnType("TEXT");
b.Property<string>("RandomSeed")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<decimal>("TotalWagered")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Gamblers");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerExclusionDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Created")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Expires")
.HasColumnType("TEXT");
b.Property<int>("GamblerId")
.HasColumnType("INTEGER");
b.Property<int>("Source")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GamblerId");
b.ToTable("Exclusions");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerPerkDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("GamblerId")
.HasColumnType("INTEGER");
b.Property<string>("Metadata")
.HasColumnType("TEXT");
b.Property<decimal?>("Payout")
.HasColumnType("TEXT");
b.Property<string>("PerkName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("PerkTier")
.HasColumnType("INTEGER");
b.Property<int>("PerkType")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GamblerId");
b.ToTable("Perks");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.HowlggBetsDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Bet")
.HasColumnType("INTEGER");
b.Property<int>("BetId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Date")
.HasColumnType("TEXT");
b.Property<string>("Game")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("GameId")
.HasColumnType("INTEGER");
b.Property<long>("Profit")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("HowlggBets");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.ImageDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("LastSeen")
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Images");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.JuicerDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<float>("Amount")
.HasColumnType("REAL");
b.Property<DateTimeOffset>("JuicedAt")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Juicers");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.MomDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Moms");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.RainbetBetsDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BetId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("BetSeenAt")
.HasColumnType("TEXT");
b.Property<string>("GameName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<float>("Multiplier")
.HasColumnType("REAL");
b.Property<float>("Payout")
.HasColumnType("REAL");
b.Property<string>("PublicId")
.HasColumnType("TEXT");
b.Property<int>("RainbetUserId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<float>("Value")
.HasColumnType("REAL");
b.HasKey("Id");
b.ToTable("RainbetBets");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.SettingDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<double>("CacheDuration")
.HasColumnType("REAL");
b.Property<string>("Default")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("IsSecret")
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Regex")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.Property<int>("ValueType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Settings");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.StreamDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AutoCapture")
.HasColumnType("INTEGER");
b.Property<string>("Metadata")
.HasColumnType("TEXT");
b.Property<int>("Service")
.HasColumnType("INTEGER");
b.Property<string>("StreamUrl")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Streams");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TransactionDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Comment")
.HasColumnType("TEXT");
b.Property<decimal>("Effect")
.HasColumnType("TEXT");
b.Property<int>("EventSource")
.HasColumnType("INTEGER");
b.Property<int?>("FromId")
.HasColumnType("INTEGER");
b.Property<int>("GamblerId")
.HasColumnType("INTEGER");
b.Property<decimal>("NewBalance")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.Property<long>("TimeUnixEpochSeconds")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("FromId");
b.HasIndex("GamblerId");
b.ToTable("Transactions");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TwitchViewCountDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<double>("ServerTime")
.HasColumnType("REAL");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.Property<string>("Topic")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Viewers")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("TwitchViewCounts");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Ignored")
.HasColumnType("INTEGER");
b.Property<int>("KfId")
.HasColumnType("INTEGER");
b.Property<string>("KfUsername")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("UserRight")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserWhoWasDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ActivityType")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("FirstOccurence")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("LatestOccurence")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UsersWhoWere");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.WagerDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("GamblerId")
.HasColumnType("INTEGER");
b.Property<int>("Game")
.HasColumnType("INTEGER");
b.Property<string>("GameMeta")
.HasColumnType("TEXT");
b.Property<bool>("IsComplete")
.HasColumnType("INTEGER");
b.Property<decimal>("Multiplier")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.Property<long>("TimeUnixEpochSeconds")
.HasColumnType("INTEGER");
b.Property<decimal>("WagerAmount")
.HasColumnType("TEXT");
b.Property<decimal>("WagerEffect")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GamblerId");
b.ToTable("Wagers");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerExclusionDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler")
.WithMany()
.HasForeignKey("GamblerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Gambler");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerPerkDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler")
.WithMany()
.HasForeignKey("GamblerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Gambler");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.JuicerDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.MomDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.StreamDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
.WithMany()
.HasForeignKey("UserId");
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TransactionDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "From")
.WithMany()
.HasForeignKey("FromId");
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler")
.WithMany()
.HasForeignKey("GamblerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("From");
b.Navigation("Gambler");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserWhoWasDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.WagerDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler")
.WithMany()
.HasForeignKey("GamblerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Gambler");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,194 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace KfChatDotNetBot.Migrations
{
/// <inheritdoc />
public partial class Money : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Gamblers",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<int>(type: "INTEGER", nullable: false),
Balance = table.Column<decimal>(type: "TEXT", nullable: false),
State = table.Column<int>(type: "INTEGER", nullable: false),
RandomSeed = table.Column<string>(type: "TEXT", maxLength: 256, nullable: false),
Created = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
TotalWagered = table.Column<decimal>(type: "TEXT", nullable: false),
NextVipLevelWagerRequirement = table.Column<decimal>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Gamblers", x => x.Id);
table.ForeignKey(
name: "FK_Gamblers_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Exclusions",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
GamblerId = table.Column<int>(type: "INTEGER", nullable: false),
Expires = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Created = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Source = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Exclusions", x => x.Id);
table.ForeignKey(
name: "FK_Exclusions_Gamblers_GamblerId",
column: x => x.GamblerId,
principalTable: "Gamblers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Perks",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
GamblerId = table.Column<int>(type: "INTEGER", nullable: false),
PerkName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: false),
Time = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Metadata = table.Column<string>(type: "TEXT", nullable: true),
PerkType = table.Column<int>(type: "INTEGER", nullable: false),
PerkTier = table.Column<int>(type: "INTEGER", nullable: true),
Payout = table.Column<decimal>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Perks", x => x.Id);
table.ForeignKey(
name: "FK_Perks_Gamblers_GamblerId",
column: x => x.GamblerId,
principalTable: "Gamblers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Transactions",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
GamblerId = table.Column<int>(type: "INTEGER", nullable: false),
EventSource = table.Column<int>(type: "INTEGER", nullable: false),
Time = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
TimeUnixEpochSeconds = table.Column<long>(type: "INTEGER", nullable: false),
Effect = table.Column<decimal>(type: "TEXT", nullable: false),
Comment = table.Column<string>(type: "TEXT", nullable: true),
FromId = table.Column<int>(type: "INTEGER", nullable: true),
NewBalance = table.Column<decimal>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Transactions", x => x.Id);
table.ForeignKey(
name: "FK_Transactions_Gamblers_FromId",
column: x => x.FromId,
principalTable: "Gamblers",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Transactions_Gamblers_GamblerId",
column: x => x.GamblerId,
principalTable: "Gamblers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Wagers",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
GamblerId = table.Column<int>(type: "INTEGER", nullable: false),
Time = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
TimeUnixEpochSeconds = table.Column<long>(type: "INTEGER", nullable: false),
WagerAmount = table.Column<decimal>(type: "TEXT", nullable: false),
WagerEffect = table.Column<decimal>(type: "TEXT", nullable: false),
Game = table.Column<int>(type: "INTEGER", nullable: false),
Multiplier = table.Column<decimal>(type: "TEXT", nullable: false),
GameMeta = table.Column<string>(type: "TEXT", nullable: true),
IsComplete = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Wagers", x => x.Id);
table.ForeignKey(
name: "FK_Wagers_Gamblers_GamblerId",
column: x => x.GamblerId,
principalTable: "Gamblers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Exclusions_GamblerId",
table: "Exclusions",
column: "GamblerId");
migrationBuilder.CreateIndex(
name: "IX_Gamblers_UserId",
table: "Gamblers",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Perks_GamblerId",
table: "Perks",
column: "GamblerId");
migrationBuilder.CreateIndex(
name: "IX_Transactions_FromId",
table: "Transactions",
column: "FromId");
migrationBuilder.CreateIndex(
name: "IX_Transactions_GamblerId",
table: "Transactions",
column: "GamblerId");
migrationBuilder.CreateIndex(
name: "IX_Wagers_GamblerId",
table: "Wagers",
column: "GamblerId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Exclusions");
migrationBuilder.DropTable(
name: "Perks");
migrationBuilder.DropTable(
name: "Transactions");
migrationBuilder.DropTable(
name: "Wagers");
migrationBuilder.DropTable(
name: "Gamblers");
}
}
}

View File

@@ -69,6 +69,103 @@ namespace KfChatDotNetBot.Migrations
b.ToTable("ChipsggBets");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<decimal>("Balance")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Created")
.HasColumnType("TEXT");
b.Property<decimal>("NextVipLevelWagerRequirement")
.HasColumnType("TEXT");
b.Property<string>("RandomSeed")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<decimal>("TotalWagered")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Gamblers");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerExclusionDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Created")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Expires")
.HasColumnType("TEXT");
b.Property<int>("GamblerId")
.HasColumnType("INTEGER");
b.Property<int>("Source")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GamblerId");
b.ToTable("Exclusions");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerPerkDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("GamblerId")
.HasColumnType("INTEGER");
b.Property<string>("Metadata")
.HasColumnType("TEXT");
b.Property<decimal?>("Payout")
.HasColumnType("TEXT");
b.Property<string>("PerkName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("PerkTier")
.HasColumnType("INTEGER");
b.Property<int>("PerkType")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GamblerId");
b.ToTable("Perks");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.HowlggBetsDbModel", b =>
{
b.Property<int>("Id")
@@ -273,6 +370,45 @@ namespace KfChatDotNetBot.Migrations
b.ToTable("Streams");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TransactionDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Comment")
.HasColumnType("TEXT");
b.Property<decimal>("Effect")
.HasColumnType("TEXT");
b.Property<int>("EventSource")
.HasColumnType("INTEGER");
b.Property<int?>("FromId")
.HasColumnType("INTEGER");
b.Property<int>("GamblerId")
.HasColumnType("INTEGER");
b.Property<decimal>("NewBalance")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.Property<long>("TimeUnixEpochSeconds")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("FromId");
b.HasIndex("GamblerId");
b.ToTable("Transactions");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TwitchViewCountDbModel", b =>
{
b.Property<int>("Id")
@@ -346,6 +482,79 @@ namespace KfChatDotNetBot.Migrations
b.ToTable("UsersWhoWere");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.WagerDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("GamblerId")
.HasColumnType("INTEGER");
b.Property<int>("Game")
.HasColumnType("INTEGER");
b.Property<string>("GameMeta")
.HasColumnType("TEXT");
b.Property<bool>("IsComplete")
.HasColumnType("INTEGER");
b.Property<decimal>("Multiplier")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.Property<long>("TimeUnixEpochSeconds")
.HasColumnType("INTEGER");
b.Property<decimal>("WagerAmount")
.HasColumnType("TEXT");
b.Property<decimal>("WagerEffect")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GamblerId");
b.ToTable("Wagers");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerExclusionDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler")
.WithMany()
.HasForeignKey("GamblerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Gambler");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerPerkDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler")
.WithMany()
.HasForeignKey("GamblerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Gambler");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.JuicerDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
@@ -377,6 +586,23 @@ namespace KfChatDotNetBot.Migrations
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TransactionDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "From")
.WithMany()
.HasForeignKey("FromId");
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler")
.WithMany()
.HasForeignKey("GamblerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("From");
b.Navigation("Gambler");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserWhoWasDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
@@ -387,6 +613,17 @@ namespace KfChatDotNetBot.Migrations
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.WagerDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler")
.WithMany()
.HasForeignKey("GamblerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Gambler");
});
#pragma warning restore 612, 618
}
}

View File

@@ -1,4 +1,5 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace KfChatDotNetBot.Models.DbModels;
@@ -25,11 +26,22 @@ public class GamblerDbModel
/// <summary>
/// The seed value given to any instance of Random that's associated with the gambler
/// </summary>
[MaxLength(256)]
public required string RandomSeed { get; set; }
/// <summary>
/// When the gambler entity was created
/// </summary>
public required DateTimeOffset Created { get; set; }
/// <summary>
/// Reference value for total wagered during the entity's lifetime
/// This value is recalculated whenever the bot restarts to ensure integrity
/// </summary>
public required decimal TotalWagered { get; set; }
/// <summary>
/// Wager requirement for the next VIP level
/// If TotalWagered reaches this value, it'll trigger the calculation
/// </summary>
public required decimal NextVipLevelWagerRequirement { get; set; }
}
public class TransactionDbModel
@@ -39,9 +51,9 @@ public class TransactionDbModel
/// </summary>
public int Id { get; set; }
/// <summary>
/// User whose balance was affected by this transaction
/// Gambler whose balance was affected by this transaction
/// </summary>
public required GamblerDbModel User { get; set; }
public required GamblerDbModel Gambler { get; set; }
/// <summary>
/// Source of the transaction event
/// </summary>
@@ -51,6 +63,13 @@ public class TransactionDbModel
/// </summary>
public required DateTimeOffset Time { get; set; }
/// <summary>
/// Time represented as a 64-bit UNIX epoch
/// This just exists to make it far more efficient to query a range of txns
/// as then we can use native SQLite dialect to select e.g. last 24 hours
/// instead of copying thousands of rows into memory and using LINQ
/// </summary>
public required long TimeUnixEpochSeconds { get; set; }
/// <summary>
/// Effect of the transaction, plus or minus
/// </summary>
public required decimal Effect { get; set; }
@@ -62,6 +81,10 @@ public class TransactionDbModel
/// Sender of the transaction in the case of a juicer, null otherwise
/// </summary>
public GamblerDbModel? From { get; set; } = null;
/// <summary>
/// Snapshot of the gambler's balance after this transaction's effect was applied
/// </summary>
public required decimal NewBalance { get; set; }
}
public class WagerDbModel
@@ -71,19 +94,26 @@ public class WagerDbModel
/// </summary>
public int Id { get; set; }
/// <summary>
/// User who wagered
/// Gambler who wagered
/// </summary>
public required GamblerDbModel User { get; set; }
public required GamblerDbModel Gambler { get; set; }
/// <summary>
/// Time they wagered
/// </summary>
public required DateTimeOffset Time { get; set; }
/// <summary>
/// Amount the user wagered
/// Time represented as a 64-bit UNIX epoch
/// This just exists to make it far more efficient to query a range of wagers
/// as then we can use native SQLite dialect to select e.g. last 24 hours
/// instead of copying thousands of rows into memory and using LINQ
/// </summary>
public required long TimeUnixEpochSeconds { get; set; }
/// <summary>
/// Amount the gambler wagered
/// </summary>
public required decimal WagerAmount { get; set; }
/// <summary>
/// Effect of the wager on the user's balance
/// Effect of the wager on the gambler's balance
/// </summary>
public required decimal WagerEffect { get; set; }
/// <summary>
@@ -92,13 +122,108 @@ public class WagerDbModel
/// </summary>
public required WagerGame Game { get; set; }
/// <summary>
/// Multiplier if applicable. 0 if it was a complete loss
/// Multiplier, e.g. 10.5x if a $1 wager paid out $10.50. 0 if it was a complete loss
/// </summary>
public required decimal Multiplier { get; set; }
/// <summary>
/// An optional field to store serialized information about the game that was played
/// </summary>
public string? GameMeta { get; set; } = null;
/// <summary>
/// Whether the results of the wager have been realized yet (i.e., is the game 'complete'?)
/// This is useful for wagers related to bets on the outcome of events
/// For incomplete bets: set the effect to -wager, subtract it from the user's balance, generate a txn for the wager
/// Then when the outcome of the bet is fully realized, modify the effect accordingly, generate a new txn for the
/// payout and set a multiplier based on the win (if any)
/// </summary>
public required bool IsComplete { get; set; }
}
public class GamblerExclusionDbModel
{
/// <summary>
/// ID fo the database row
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gambler who is excluded
/// </summary>
public required GamblerDbModel Gambler { get; set; }
/// <summary>
/// When the exclusion expires
/// </summary>
public required DateTimeOffset Expires { get; set; }
/// <summary>
/// When the exclusion was created / began
/// </summary>
public required DateTimeOffset Created { get; set; }
/// <summary>
/// What triggered the exclusion
/// </summary>
public required ExclusionSource Source { get; set; }
}
public class GamblerPerkDbModel
{
/// <summary>
/// ID fo the database row
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gambler entity the perk is associated with
/// </summary>
public required GamblerDbModel Gambler { get; set; }
/// <summary>
/// Name of the perk
/// </summary>
[MaxLength(256)]
public required string PerkName { get; set; }
/// <summary>
/// Time when the perk was attained
/// </summary>
public required DateTimeOffset Time { get; set; }
/// <summary>
/// Optional metadata associated with the perk
/// </summary>
public string? Metadata { get; set; } = null;
/// <summary>
/// What type of perk is this
/// </summary>
public required GamblerPerkType PerkType { get; set; }
/// <summary>
/// The tier the perk is at.
/// If tiers are not applicable, set to null
/// </summary>
public int? PerkTier { get; set; }
/// <summary>
/// The payout from this perk, if any. If none, set to null
/// </summary>
public decimal? Payout { get; set; }
}
public enum GamblerPerkType
{
/// <summary>
/// For literally anything else, though you should probably just extend this enum
/// </summary>
Other = -1,
/// <summary>
/// Used for tracking VIP levels attained
/// </summary>
[Description("VIP Level")]
VipLevel
}
public enum ExclusionSource
{
/// <summary>
/// Exclusion as a result of the hostess' action
/// </summary>
Hostess,
/// <summary>
/// Exclusions placed by administrators
/// </summary>
Administrative
}
/// <summary>
@@ -123,10 +248,20 @@ public enum TransactionSourceEventType
/// </summary>
Administrative,
/// <summary>
/// Some type of bonus, like rakeback or a reload. Do not use for hostess rewards
/// Some type of bonus, like a VIP level up. Rakeback / reloads have separate enums for this
/// </summary>
Bonus,
/// <summary>
/// Specifically use for rakeback as we use the delta between last rakeback txn to calculate total wagered
/// to figure out what the next rakeback should be (if they've wagered enough to be eligible for one)
/// </summary>
Rakeback,
/// <summary>
/// Use specifically for daily reloads as we use the timing of the last reload txn to figure out if the most
/// recent reload has been claimed yet or not
/// </summary>
Reload,
/// <summary>
/// Use this only for hostess juicers as the sum of these juicers in a given day can influence the hostess' behavior
/// </summary>
Hostess
@@ -142,7 +277,11 @@ public enum WagerGame
LambChop,
Keno,
[Description("Coinflip")]
CoinFlip
CoinFlip,
/// <summary>
/// This is for betting pools based on some sort of event or outcome
/// </summary>
Event
}
public enum GamblerState

View File

@@ -0,0 +1,3 @@
namespace KfChatDotNetBot.Models;
// Stash all the models used for perk or game metadata here

View File

@@ -0,0 +1,57 @@
using KfChatDotNetBot.Models.DbModels;
namespace KfChatDotNetBot.Models;
public class MoneyVipLevel
{
/// <summary>
/// Name of the VIP level
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Number of tiers the VIP level has
/// Steps between VIP tiers are calculated by comparing with the next VIP level and dividing by number of tiers
/// e.g. (100,000 - 10,000) / 5 = 18,000 steps
/// Tier 1 = 10,000
/// Tier 2 = 28,000
/// Tier 3 = 46,000
/// Tier 4 = 64,000
/// Tier 5 = 84,000
/// Next VIP level at 100,000
/// What happens if they're at the last VIP level? They remain stuck at tier 1 forever regardless of this value
/// This is really just so that we have flexibility to add further tiers later without messing anything up
/// since there's no telling how easy it will be to attain the high levels at this point
/// </summary>
public required int Tiers { get; set; }
/// <summary>
/// Icon to display next to the name, like an emoji diamond or a small image embedded with bbcode [img] tags
/// </summary>
public required string Icon { get; set; }
/// <summary>
/// The wager requirement for this level. This is the requirement for the base (tier 1) level
/// Remaining tiers are calculated based on the wager requirement for the next tier
/// </summary>
public required decimal BaseWagerRequirement { get; set; }
/// <summary>
/// Payout when you attain this level.
/// Tiers (beyond 1) pay out: BonusPayout / (Tiers - 1) (e.g. 1,000 / 4 = 250 for tier 2-5
/// </summary>
public required decimal BonusPayout { get; set; }
}
public class NextVipLevelModel
{
/// <summary>
/// The VIP level that's coming up next.
/// Could be the same as the existing level if it's just the next tier.
/// </summary>
public required MoneyVipLevel VipLevel { get; set; }
/// <summary>
/// What tier this is for
/// </summary>
public required int Tier { get; set; }
/// <summary>
/// The wager requirement to reach this tier that factors in the tier
/// </summary>
public required decimal WagerRequirement { get; set; }
}

View File

@@ -1,7 +1,9 @@
using System.Text.RegularExpressions;
using Humanizer;
using KfChatDotNetBot.Commands;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
using NLog;
@@ -17,9 +19,9 @@ internal class BotCommands
private IEnumerable<ICommand> Commands;
private CancellationToken _cancellationToken;
internal BotCommands(ChatBot bot, CancellationToken? ctx = null)
internal BotCommands(ChatBot bot, CancellationToken ctx = default)
{
_cancellationToken = ctx ?? CancellationToken.None;
_cancellationToken = ctx;
_bot = bot;
var interfaceType = typeof(ICommand);
Commands =
@@ -60,6 +62,33 @@ internal class BotCommands
if (user == null) return;
if (user.Ignored) return;
var continueAfterProcess = HasAttribute<AllowAdditionalMatches>(command);
var kasinoCommand = HasAttribute<KasinoCommand>(command);
var wagerCommand = HasAttribute<WagerCommand>(command);
if (kasinoCommand)
{
var kasinoEnabled = SettingsProvider.GetValueAsync(BuiltIn.Keys.MoneyEnabled).Result.ToBoolean();
if (!kasinoEnabled) return;
}
if (kasinoCommand && user.IsPermanentlyBanned(_cancellationToken).Result)
{
_bot.SendChatMessage($"@{message.Author.Username}, you've been permanently banned from the kasino. Contact support for more information.", true);
return;
}
if (wagerCommand)
{
// GetGamblerEntity will only return null if the user is permanbanned
// and we have a check further up the chain for that hence ignoring the null
var exclusion = user.GetGamblerEntity(ct: _cancellationToken).Result
!.GetActiveExclusion(ct: _cancellationToken).Result;
if (exclusion != null)
{
_bot.SendChatMessage(
$"@{message.Author.Username}, you're self excluded from the kasino for another {(exclusion.Expires - DateTimeOffset.UtcNow).Humanize(precision: 3)}", true);
return;
}
}
if (user.UserRight < command.RequiredRight)
{
_bot.SendChatMessage($"@{message.Author.Username}, you do not have access to use this command. Your rank: {user.UserRight.Humanize()}; Required rank: {command.RequiredRight.Humanize()}", true);
@@ -89,7 +118,25 @@ internal class BotCommands
{
_logger.Error("Command task failed");
_logger.Error(task.Exception);
return;
}
var moneySettings =
await SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.MoneyEnabled, BuiltIn.Keys.MoneySymbolSuffix]);
if (!moneySettings[BuiltIn.Keys.MoneyEnabled].ToBoolean()) return;
var wagerCommand = HasAttribute<WagerCommand>(command);
if (!wagerCommand) return;
var gambler = await user.GetGamblerEntity(ct: _cancellationToken);
if (gambler == null) return;
if (gambler.TotalWagered < gambler.NextVipLevelWagerRequirement) return;
// The reason for doing this instead of passing in TotalWagered is that otherwise VIP levels might
// get skipped if the user is a low VIP level but wagering very large amounts
var newLevel = Money.GetNextVipLevel(gambler.NextVipLevelWagerRequirement);
if (newLevel == null) return;
var payout = await gambler.UpgradeVipLevel(newLevel, _cancellationToken);
await _bot.SendChatMessageAsync(
$"🤑🤑 {user.KfUsername} has leveled up to to {newLevel.VipLevel.Icon} {newLevel.VipLevel.Name} Tier {newLevel.Tier} " +
$"and received a bonus of {payout:N2} {moneySettings[BuiltIn.Keys.MoneySymbolSuffix].Value}", true);
}
private static bool HasAttribute<T>(ICommand command) where T : Attribute
@@ -105,4 +152,20 @@ internal class BotCommands
/// Keep in mind since commands are executed in a throwaway task and not awaited, they will run concurrently
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
internal class AllowAdditionalMatches : Attribute;
internal class AllowAdditionalMatches : Attribute;
/// <summary>
/// Use this on commands where a wager is taking place.
/// This will cause the bot to check total wagered and see if the gambler has leveled up.
/// It'll also check whether the gambler is currently temp excluded before running the command.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
internal class WagerCommand : Attribute;
/// <summary>
/// Use this on all commands that interact with the gambling / monetary system
/// When used, this will check if the system is globally enabled before running the command.
/// It'll also check whether the user is permanently banned before running the command.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
internal class KasinoCommand : Attribute;

View File

@@ -1,5 +1,6 @@
using System.Text.Json;
using Humanizer;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Settings;

View File

@@ -0,0 +1,199 @@
using KfChatDotNetBot.Models;
namespace KfChatDotNetBot.Services;
public static class Money
{
/// <summary>
/// This is the list of available VIP levels for gamblers to ascend
/// The order of this array is important, it begins with the loest level VIP, and ascends IN ORDER to the max level
/// </summary>
public static List<MoneyVipLevel> VipLevels =
[
new MoneyVipLevel
{
Name = "Rip City",
Tiers = 5,
Icon = ":ross:",
BaseWagerRequirement = 10_000,
BonusPayout = 250
},
new MoneyVipLevel
{
Name = "Chinesium",
Tiers = 5,
Icon = ":gold:",
BaseWagerRequirement = 100_000,
BonusPayout = 500
},
new MoneyVipLevel
{
Name = "Juice Fiend",
Tiers = 5,
Icon = ":juice:",
BaseWagerRequirement = 1_000_000,
BonusPayout = 1000
},
new MoneyVipLevel
{
Name = "Unemployment Line",
Tiers = 5,
Icon = ":tugboat:",
BaseWagerRequirement = 5_000_000,
BonusPayout = 2500
},
new MoneyVipLevel
{
Name = "Down Immensely",
Tiers = 5,
Icon = ":felted:",
BaseWagerRequirement = 10_000_000,
BonusPayout = 5000
},
new MoneyVipLevel
{
Name = "Glutton for Punishment",
Tiers = 5,
Icon = ":ow:",
BaseWagerRequirement = 25_000_000,
BonusPayout = 7500
},
new MoneyVipLevel
{
Name = "Targeted by Evil Eddie",
Tiers = 5,
Icon = ":bogged:",
BaseWagerRequirement = 50_000_000,
BonusPayout = 10_000
},
new MoneyVipLevel
{
Name = "Epic High Roller",
Tiers = 5,
Icon = ":wow:",
BaseWagerRequirement = 100_000_000,
BonusPayout = 15_000
},
new MoneyVipLevel
{
Name = "99% of Gamblers",
Tiers = 5,
Icon = ":wall:",
BaseWagerRequirement = 500_000_000,
BonusPayout = 25_000
},
new MoneyVipLevel
{
Name = "Billionaire Club",
Tiers = 5,
Icon = ":drink:",
BaseWagerRequirement = 1_000_000_000,
BonusPayout = 50_000
},
new MoneyVipLevel
{
Name = "Upfag",
Tiers = 5,
Icon = ":gay:",
BaseWagerRequirement = 5_000_000_000,
BonusPayout = 75_000
},
new MoneyVipLevel
{
Name = "No Regrets",
Tiers = 5,
Icon = ":woo:",
BaseWagerRequirement = 50_000_000_000,
BonusPayout = 100_000
},
new MoneyVipLevel
{
Name = "A Small Juicer of a Million Dollars",
Tiers = 5,
Icon = ":trump:",
BaseWagerRequirement = 250_000_000_000,
BonusPayout = 1_000_000
},
new MoneyVipLevel
{
Name = "Wannabe Bossman",
Tiers = 5,
Icon = ":lossmanjack:",
BaseWagerRequirement = 500_000_000_000,
BonusPayout = 2_000_000
},
new MoneyVipLevel
{
Name = "TRILLION DOLLAR COIN FLIP",
Tiers = 5,
Icon = ":winner:",
BaseWagerRequirement = 1_000_000_000_000,
BonusPayout = 4_000_000
},
new MoneyVipLevel
{
Name = "Madness",
Tiers = 5,
Icon = ":lunacy:",
BaseWagerRequirement = 5_000_000_000_000,
BonusPayout = 10_000_000
},
new MoneyVipLevel
{
Name = "Nowhere to go from here",
Tiers = 5,
Icon = ":achievement:",
BaseWagerRequirement = 50_000_000_000_000,
// Fuck you pussy
BonusPayout = 1
}
];
public static List<decimal> CalculateTiers(MoneyVipLevel vipLevel)
{
// The list is in ascending order
var nextLevel = VipLevels.FirstOrDefault(v => v.BaseWagerRequirement > vipLevel.BaseWagerRequirement);
// Max level has no tiers
if (nextLevel == null) return [vipLevel.BaseWagerRequirement];
var wagerRequirement = vipLevel.BaseWagerRequirement;
var step = (nextLevel.BaseWagerRequirement - vipLevel.BaseWagerRequirement) / vipLevel.Tiers;
var tiers = new List<decimal>();
while (wagerRequirement < nextLevel.BaseWagerRequirement)
{
tiers.Add(wagerRequirement);
wagerRequirement += step;
}
return tiers;
}
/// <summary>
/// Get the next VIP level based on the wager amount given
/// </summary>
/// <param name="wagered">Wager amount to calculate the next level</param>
/// <returns>null if the user is at the max level</returns>
public static NextVipLevelModel? GetNextVipLevel(decimal wagered)
{
var level = VipLevels.LastOrDefault(v => v.BaseWagerRequirement < wagered);
if (level == null) return null;
var tiers = CalculateTiers(level);
var nextTier = tiers.FirstOrDefault(t => wagered < t);
// default(decimal) is 0
// This happens if the user is between tier 5 and their next level
if (nextTier == 0)
{
var nextLevel = VipLevels[VipLevels.IndexOf(level) + 1];
return new NextVipLevelModel
{
VipLevel = nextLevel,
Tier = 1,
WagerRequirement = nextLevel.BaseWagerRequirement
};
}
return new NextVipLevelModel
{
VipLevel = level,
Tier = tiers.IndexOf(nextTier) + 1,
WagerRequirement = nextTier
};
}
}

View File

@@ -936,9 +936,38 @@ public static class BuiltIn
Description = "Whether to respond to Bossman impersonations",
Default = "true",
ValueType = SettingValueType.Boolean,
Regex = "(true|false)"
Regex = BooleanRegex
},
new BuiltInSettingsModel
{
Key = Keys.MoneySymbolSuffix,
Description = "What is the symbol of the bot's currency when used as a suffix for an amount",
Default = "KKK",
ValueType = SettingValueType.Text
},
new BuiltInSettingsModel
{
Key = Keys.MoneySymbolPrefix,
Description = "What is the symbol of the bot's currency when used as a prefix for an amount",
Default = "KKK$",
ValueType = SettingValueType.Text
},
new BuiltInSettingsModel
{
Key = Keys.MoneyEnabled,
Description = "Whether the monetary system is enabled at all. " +
"If disabled, the bot won't answer any commands related to balance, transactions, gambling, etc.",
Default = "false",
ValueType = SettingValueType.Boolean,
Regex = BooleanRegex
},
new BuiltInSettingsModel
{
Key = Keys.MoneyInitialBalance,
Description = "Gambler's initial balance on creation",
Default = "100",
ValueType = SettingValueType.Text,
Regex = WholeNumberRegex
}
];
@@ -1048,5 +1077,6 @@ public static class BuiltIn
public static string MoneySymbolSuffix = "Money.SymbolSuffix";
public static string MoneySymbolPrefix = "Money.SymbolPrefix";
public static string MoneyEnabled = "Money.Enabled";
public static string MoneyInitialBalance = "Money.InitialBalance";
}
}