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

@@ -0,0 +1,133 @@
using System.Text;
using System.Text.RegularExpressions;
namespace KfChatDotNetBot.Extensions;
public static class Extensions
{
/// Emotes are encoded like [emote:161238:russW] which translates to -> https://files.kick.com/emotes/161238/fullsize
public static string TranslateKickEmotes(this string s)
{
Regex regex = new Regex(@"\[(.+?):(\d+):(\S+?)\]");
var matches = regex.Matches(s);
if (matches.Count == 0)
{
return s;
}
foreach (Match match in matches)
{
// First group is the whole matched string
// 0 -> [emote:161238:russW]
// 1 -> emote
// 2 -> 161238
// 3 -> russW
var emoteId = match.Groups[2];
s = s.Replace(match.Value, $"[img]https://files.kick.com/emotes/{emoteId}/fullsize[/img]");
}
return s;
}
public static IEnumerable<string> SplitMessage(this string s, int partLength = 500, int partLimit = 5)
{
if (s == null)
throw new ArgumentNullException(nameof(s));
if (partLength <= 0)
throw new ArgumentException("Part length has to be positive.", nameof(partLength));
var parts = 0;
for (var i = 0; i < s.Length; i += partLength)
{
parts++;
if (parts > partLimit) break;
yield return s.Substring(i, Math.Min(partLength, s.Length - i));
}
}
/// <summary>
/// Split messages to x number of bytes while avoiding splitting mid-word where possible
/// </summary>
/// <param name="s">String that should get split</param>
/// <param name="partLengthBytes">Length limit, no part should be > than the number of bytes specified</param>
/// <param name="partLimit">Limit for how many parts to return (returns first n elements). Set to 0 to disable.</param>
/// <param name="partSeparator">Separator to use when splitting up parts of the message</param>
/// <returns>List of string values which represents the split up message</returns>
public static List<string> FancySplitMessage(this string s, int partLengthBytes = 1023, int partLimit = 5, string partSeparator = " ")
{
var output = new List<string>();
var part = string.Empty;
foreach (var word in s.Split(partSeparator))
{
if (word.Utf8LengthBytes() > partLengthBytes)
{
// Add the part already in memory if there is one
if (part != string.Empty)
{
output.Add(part.TrimEnd());
part = string.Empty;
}
// Breaks into chunks of x size which will break really long URLs etc. but no other way really
output.AddRange(word.ChunkBytes(partLengthBytes));
continue;
}
if (part.Utf8LengthBytes() + word.Utf8LengthBytes() > partLengthBytes)
{
// TrimEnd() to remove trailing spaces
output.Add(part.TrimEnd());
part = word + partSeparator;
continue;
}
part += word + partSeparator;
}
// Add on whatever remains
if (part != string.Empty)
{
output.Add(part.TrimEnd());
}
if (partLimit != 0 && output.Count > partLimit)
{
return output.Take(partLimit).ToList();
}
return output;
}
public static int Utf8LengthBytes(this string s)
{
return Encoding.UTF8.GetByteCount(s);
}
public static IEnumerable<string> ChunkBytes(this string input, int bytesPerChunk)
{
var bytes = Encoding.UTF8.GetBytes(input);
for (var i = 0; i < bytes.Length; i += bytesPerChunk)
{
var chunkSize = Math.Min(bytesPerChunk, bytes.Length - i);
yield return Encoding.UTF8.GetString(bytes, i, chunkSize);
}
}
public static string TruncateBytes(this string s, int limitBytes)
{
if (string.IsNullOrEmpty(s) || limitBytes <= 0)
{
return string.Empty;
}
if (s.Utf8LengthBytes() <= limitBytes)
{
return s;
}
var bytes = Encoding.UTF8.GetBytes(s);
var charCount = Encoding.UTF8.GetCharCount(bytes, 0, limitBytes);
return s.Substring(0, charCount);
}
}

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;
}
}