using System.Globalization; using System.Text.RegularExpressions; using KfChatDotNetBot.Extensions; using KfChatDotNetBot.Models; using KfChatDotNetBot.Models.DbModels; using KfChatDotNetBot.Services; using KfChatDotNetBot.Settings; using Microsoft.EntityFrameworkCore; using NLog; using RandN; using RandN.Compat; namespace KfChatDotNetBot.Commands.Kasino; // The avelloon insists every great rap battle needs a mediator skimming off the top. // This is that mediator. It is the Kasino, and it is nefarious. // // Persistence model: all battle state lives in Redis so the bot can be restarted at any time without // losing an in-flight battle. Timeouts are durable too - each battle carries a deadline and a background // sweeper (BotServices) processes anything that has expired, instead of a fragile in-memory timer. // The one thing that stays in memory is the Gate: it only serialises concurrent operations *within* the // running process (there's a single bot instance), and on restart there is nothing in flight to serialise. [KasinoCommand] [WagerCommand] public class RapBattleCommand : ICommand { // One broad pattern; the meaning of the trailing text depends entirely on game state, // so all routing (challenge / accept / decline / verse) is decided inside RunCommand. public List Patterns => [ new Regex(@"^rapbattle(?:\s+(?.+))?\s*$", RegexOptions.IgnoreCase | RegexOptions.Singleline) ]; public string? HelpText => "!rapbattle to challenge someone, !rapbattle accept|decline to respond, !rapbattle to spit your verse"; public UserRight RequiredRight => UserRight.Loser; // Generous so the fake "evaluating…" suspense delay always fits inside one command invocation. public TimeSpan Timeout => TimeSpan.FromSeconds(60); public RateLimitOptionsModel? RateLimitOptions => new() { MaxInvocations = 5, Window = TimeSpan.FromSeconds(20) }; public bool WhisperCanInvoke => false; private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); // In-process mutual exclusion for all state transitions (and the money moves that go with them) so a // user can't double-accept, double-submit, or have the sweeper race a resolution. State itself is in // Redis; this lock does NOT need to survive a restart because there are no in-flight ops after one. private static readonly SemaphoreSlim Gate = new(1, 1); // FD is a big Aussie Rigger private const string StateKeyPrefix = "rapbattle:state:"; // -> RapBattleState JSON private const string UserKeyPrefix = "rapbattle:user:"; // kfId -> battle id (both participants) private const string ActiveKey = "rapbattle:active"; // JSON list of in-progress battle ids // Anti-leak backstop only. Battles normally resolve within seconds of their (<=90s) deadline via the // sweeper, long before this; it just stops keys lingering forever if something goes badly wrong. private static readonly TimeSpan KeyTtl = TimeSpan.FromHours(24); private static string StateKey(string id) => $"{StateKeyPrefix}{id}"; private static string UserKey(int kfId) => $"{UserKeyPrefix}{kfId}"; private enum RapBattlePhase { PendingAccept, CollectingVerses } // Plain, JSON-serialisable DTO (System.Text.Json round-trips it through Redis). No behaviour, no // in-memory-only references (e.g. message trackers) - only the announcement's UUID string is kept. private sealed class RapBattleState { public string Id { get; set; } = string.Empty; public RapBattlePhase Phase { get; set; } // Unix ms after which the current phase has expired. The sweeper refunds anything past this. public long DeadlineUnixMs { get; set; } public decimal Bet { get; set; } public int ChallengerKfId { get; set; } public int ChallengerUserDbId { get; set; } public int ChallengerGamblerId { get; set; } public string ChallengerUsername { get; set; } = string.Empty; public string? ChallengerVerse { get; set; } public bool ChallengerUsedCheat { get; set; } public int OpponentKfId { get; set; } public int OpponentUserDbId { get; set; } public int OpponentGamblerId { get; set; } public string OpponentUsername { get; set; } = string.Empty; public string? OpponentVerse { get; set; } public bool OpponentUsedCheat { get; set; } // Chat message UUIDs so we can tidy up the challenge clutter once the battle is accepted. public string? ChallengeCommandMessageUuid { get; set; } // challenger's "!rapbattle " public string? ChallengeAnnouncementUuid { get; set; } // the bot's "you've been challenged" post } private sealed record RapBattleConfig( int TimeoutSeconds, decimal CasinoCutPercent, decimal RefundFeePercent, decimal MinimumBet, int MinimumVerseLength, int MinimumBars, int EvaluatingDelayMs, TimeSpan CleanupDelay, string FeedbackFile, string ChallengeFile, bool CheatEnabled, string GreenColor, string RedColor); // Secret cheat code. If a rapper drops this exact phrase anywhere in their verse, they auto-win — // unless BOTH rappers do it in the same battle, in which case the cheat is burned server-side forever // and the house keeps both buy-ins. Stored lowercased + single-spaced to match the normalised verse. private const string CheatPhrase = "like an avelloon on the moon, you will never make me swoon. you a troon!"; public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx) { var settings = await SettingsProvider.GetMultipleValuesAsync([ BuiltIn.Keys.KasinoRapBattleEnabled, BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay, BuiltIn.Keys.KiwiFarmsUsername ]); var disabledDelay = TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay].ToType()); if (!settings[BuiltIn.Keys.KasinoRapBattleEnabled].ToBoolean()) { await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, rap battles are currently disabled.", true, autoDeleteAfter: disabledDelay); return; } // Rap battles are Redis-backed so they survive restarts. If Redis isn't configured/reachable, the // game can't run - fail clearly instead of throwing deep inside a handler. if (!RedisReady()) { await botInstance.SendChatMessageAsync( $"{user.FormatUsername()}, rap battles are unavailable right now (the Kasino's Redis backend isn't configured).", true, autoDeleteAfter: disabledDelay); return; } var cfg = await LoadConfigAsync(); var argsRaw = arguments["args"].Success ? arguments["args"].Value.Trim() : string.Empty; var myKfId = message.Author.Id; // Explicit help works in any state and doesn't touch the game, so handle it before the gate. if (argsRaw.Equals("help", StringComparison.OrdinalIgnoreCase)) { await botInstance.SendChatMessageAsync(BuildHelp(cfg), true, autoDeleteAfter: cfg.CleanupDelay); RateLimitService.RemoveMostRecentEntry(user, this); return; } await Gate.WaitAsync(ctx); try { var battle = await FindBattleForUserAsync(myKfId); if (battle != null) { if (battle.Phase == RapBattlePhase.PendingAccept) { if (battle.OpponentKfId == myKfId) { if (argsRaw.Equals("accept", StringComparison.OrdinalIgnoreCase)) { await HandleAcceptAsync(botInstance, user, battle, cfg, message.MessageUuid, ctx); return; } if (argsRaw.Equals("decline", StringComparison.OrdinalIgnoreCase)) { await HandleDeclineAsync(botInstance, battle, cfg); return; } await botInstance.SendChatMessageAsync( $"{user.FormatUsername()}, {battle.ChallengerUsername} challenged you to a rap battle for " + $"{await battle.Bet.FormatKasinoCurrencyAsync()}. Reply !rapbattle accept or !rapbattle decline.", true, autoDeleteAfter: cfg.CleanupDelay); return; } // The challenger is poking the bot while still waiting on their opponent. await botInstance.SendChatMessageAsync( $"{user.FormatUsername()}, your challenge to {battle.OpponentUsername} is still pending. " + "Wait for them to accept or decline.", true, autoDeleteAfter: cfg.CleanupDelay); return; } // CollectingVerses: any !rapbattle text from a participant is treated as their verse. var isChallenger = battle.ChallengerKfId == myKfId; var alreadySubmitted = isChallenger ? battle.ChallengerVerse != null : battle.OpponentVerse != null; if (alreadySubmitted) { var otherName = isChallenger ? battle.OpponentUsername : battle.ChallengerUsername; await botInstance.SendChatMessageAsync( $"{user.FormatUsername()}, you've already dropped your verse. Waiting on {otherName}…", true, autoDeleteAfter: cfg.CleanupDelay); return; } if (string.IsNullOrWhiteSpace(argsRaw)) { await botInstance.SendChatMessageAsync( $"{user.FormatUsername()}, the beat's running! Drop your bars with !rapbattle .", true, autoDeleteAfter: cfg.CleanupDelay); return; } await HandleVerseAsync(botInstance, user, battle, isChallenger, argsRaw, cfg, ctx); return; } // Not currently in a battle -> the only valid action is starting one. The target is either a // numeric KfId or a username; usernames can contain spaces, so capture everything after the bet. var challengeMatch = Regex.Match(argsRaw, @"^(?\d+(?:\.\d+)?)\s+(?.+?)\s*$"); if (challengeMatch.Success) { var bet = decimal.Parse(challengeMatch.Groups["bet"].Value, NumberStyles.Number, CultureInfo.InvariantCulture); // Mirror !whois input handling: tolerate a leading @ and trailing punctuation/space. var target = challengeMatch.Groups["target"].Value.TrimStart('@').TrimEnd(',').TrimEnd(); int opponentKfId; if (Regex.IsMatch(target, @"^\d+$")) { // All-digits is treated as a KfId, consistent with !whois / !juice / !pocketwatch. opponentKfId = int.Parse(target); } else { // Resolve the username to a KfId ourselves (exact match, case-insensitive, no fuzzy guessing). var resolved = await FindOpponentKfIdByUsernameAsync(target, ctx); if (resolved == null) { await botInstance.SendChatMessageAsync( $"{user.FormatUsername()}, I couldn't find anyone called '{target}'. Check the spelling, " + $"or challenge them by numeric ID (get it with !whois {target}).", true, autoDeleteAfter: cfg.CleanupDelay); RateLimitService.RemoveMostRecentEntry(user, this); return; } opponentKfId = resolved.Value; } await HandleChallengeAsync(botInstance, user, bet, opponentKfId, cfg, settings[BuiltIn.Keys.KiwiFarmsUsername].Value, message.MessageUuid, ctx); return; } await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, that's not valid rap battle syntax.[br]" + BuildHelp(cfg), true, autoDeleteAfter: cfg.CleanupDelay); RateLimitService.RemoveMostRecentEntry(user, this); } finally { Gate.Release(); } } /// /// Resolve a KiwiFarms username to a KfId the way !whois does its exact lookup: prefer an exact-case /// match, then fall back to case-insensitive. There is deliberately NO fuzzy guessing, so a typo can /// never lock the challenger's buy-in against the wrong person. Returns null if no such user is known. /// private static async Task FindOpponentKfIdByUsernameAsync(string username, CancellationToken ct) { await using var db = new ApplicationDbContext(); var match = await db.Users.FirstOrDefaultAsync(u => u.KfUsername == username, ct); if (match == null) { var lower = username.ToLower(); match = await db.Users.FirstOrDefaultAsync(u => u.KfUsername.ToLower() == lower, ct); } return match?.KfId; } private async Task HandleChallengeAsync(ChatBot botInstance, UserDbModel challenger, decimal bet, int opponentKfId, RapBattleConfig cfg, string? botUsername, string? challengerMessageUuid, CancellationToken ctx) { if (bet < cfg.MinimumBet) { await botInstance.SendChatMessageAsync( $"{challenger.FormatUsername()}, the minimum rap battle bet is {await cfg.MinimumBet.FormatKasinoCurrencyAsync()}.", true, autoDeleteAfter: cfg.CleanupDelay); RateLimitService.RemoveMostRecentEntry(challenger, this); return; } if (opponentKfId == challenger.KfId) { await botInstance.SendChatMessageAsync( $"{challenger.FormatUsername()}, you can't rap battle yourself. Find an opponent.", true, autoDeleteAfter: cfg.CleanupDelay); RateLimitService.RemoveMostRecentEntry(challenger, this); return; } await using var db = new ApplicationDbContext(); var opponentUser = await db.Users.FirstOrDefaultAsync(u => u.KfId == opponentKfId, ctx); if (opponentUser == null) { await botInstance.SendChatMessageAsync( $"{challenger.FormatUsername()}, I've never seen a user with ID {opponentKfId}.", true, autoDeleteAfter: cfg.CleanupDelay); RateLimitService.RemoveMostRecentEntry(challenger, this); return; } if (botUsername != null && opponentUser.KfUsername.Equals(botUsername, StringComparison.OrdinalIgnoreCase)) { await botInstance.SendChatMessageAsync( $"{challenger.FormatUsername()}, you can't rap battle the house.", true, autoDeleteAfter: cfg.CleanupDelay); RateLimitService.RemoveMostRecentEntry(challenger, this); return; } if (await FindBattleForUserAsync(opponentKfId) != null) { await botInstance.SendChatMessageAsync( $"{challenger.FormatUsername()}, {opponentUser.FormatUsername()} is already in a rap battle.", true, autoDeleteAfter: cfg.CleanupDelay); RateLimitService.RemoveMostRecentEntry(challenger, this); return; } var challengerGambler = await Money.GetGamblerEntityAsync(challenger.Id, ct: ctx); if (challengerGambler == null) throw new InvalidOperationException($"Caught a null when retrieving gambler for {challenger.KfUsername}"); if (challengerGambler.Balance < bet) { await botInstance.SendChatMessageAsync( $"{challenger.FormatUsername()}, your balance of {await challengerGambler.Balance.FormatKasinoCurrencyAsync()} " + $"isn't enough to put up {await bet.FormatKasinoCurrencyAsync()}.", true, autoDeleteAfter: cfg.CleanupDelay); RateLimitService.RemoveMostRecentEntry(challenger, this); return; } var opponentGambler = await Money.GetGamblerEntityAsync(opponentUser.Id, ct: ctx); if (opponentGambler == null) { await botInstance.SendChatMessageAsync( $"{challenger.FormatUsername()}, {opponentUser.FormatUsername()} is banned from the Kasino and can't battle.", true, autoDeleteAfter: cfg.CleanupDelay); RateLimitService.RemoveMostRecentEntry(challenger, this); return; } var opponentExclusion = await Money.GetActiveExclusionAsync(opponentGambler.Id, ct: ctx); if (opponentExclusion != null) { await botInstance.SendChatMessageAsync( $"{challenger.FormatUsername()}, {opponentUser.FormatUsername()} is self-excluded and can't be dragged into a battle.", true, autoDeleteAfter: cfg.CleanupDelay); RateLimitService.RemoveMostRecentEntry(challenger, this); return; } // Lock the challenger's buy-in immediately so it can't be spent elsewhere while the opponent decides. // CancellationToken.None: a money move must never be torn half-way by the command timeout. await Money.ModifyBalanceAsync(challengerGambler.Id, -bet, TransactionSourceEventType.Gambling, $"Rap battle buy-in vs {opponentUser.KfUsername}", ct: CancellationToken.None); var battle = new RapBattleState { Id = Money.GenerateEventId(), Phase = RapBattlePhase.PendingAccept, DeadlineUnixMs = DeadlineFromNow(cfg), Bet = bet, ChallengerKfId = challenger.KfId, ChallengerUserDbId = challenger.Id, ChallengerGamblerId = challengerGambler.Id, ChallengerUsername = challenger.FormatUsername(), OpponentKfId = opponentUser.KfId, OpponentUserDbId = opponentUser.Id, OpponentGamblerId = opponentGambler.Id, OpponentUsername = opponentUser.FormatUsername(), ChallengeCommandMessageUuid = challengerMessageUuid }; // Persist BEFORE announcing so a crash right here leaves a valid battle the sweeper can refund, // rather than a charged challenger with no record. await SaveBattleAsync(battle); // Append a random flourish from the editable challenge pool, e.g. "GET ON STAGE!". var flourish = PickChallengeFlourish(cfg.ChallengeFile, challenger.FormatUsername(), $"{opponentUser.FormatUsername()}"); // Lead with the opponent's @-mention so Sneedchat pings them and they realise they've been challenged. var announcement = await botInstance.SendChatMessageAsync( $"🎤 {opponentUser.FormatUsername()}, you've been challenged to a RAP BATTLE by {challenger.FormatUsername()} for " + $"{await bet.FormatKasinoCurrencyAsync()} a head. {flourish} Reply [b]!rapbattle accept[/b] to throw down or " + $"[b]!rapbattle decline[/b] to back out — you've got {cfg.TimeoutSeconds} seconds. " + $"({challenger.FormatUsername()}'s buy-in is already locked up.)", true); // Record the announcement's UUID (once echoed) so it can be deleted on accept, even across a restart. await botInstance.WaitForChatMessageAsync(announcement, TimeSpan.FromSeconds(3), ctx); if (!string.IsNullOrEmpty(announcement.ChatMessageUuid)) { battle.ChallengeAnnouncementUuid = announcement.ChatMessageUuid; await SaveBattleAsync(battle); } } private async Task HandleAcceptAsync(ChatBot botInstance, UserDbModel opponentUser, RapBattleState battle, RapBattleConfig cfg, string? accepterMessageUuid, CancellationToken ctx) { // Re-fetch the opponent's gambler to validate funds at the moment of acceptance. var opponentGambler = await Money.GetGamblerEntityAsync(opponentUser.Id, ct: ctx); if (opponentGambler == null) { await botInstance.SendChatMessageAsync( $"{opponentUser.FormatUsername()}, you can't accept, you're banned from the Kasino.", true, autoDeleteAfter: cfg.CleanupDelay); return; } if (opponentGambler.Balance < battle.Bet) { await botInstance.SendChatMessageAsync( $"{opponentUser.FormatUsername()}, your balance of {await opponentGambler.Balance.FormatKasinoCurrencyAsync()} " + $"isn't enough to match the {await battle.Bet.FormatKasinoCurrencyAsync()} buy-in. Juice up and try again before the clock runs out.", true, autoDeleteAfter: cfg.CleanupDelay); return; } battle.OpponentGamblerId = opponentGambler.Id; // Both buy-ins are now locked. Per the spec, the opponent is charged the instant they accept. // CancellationToken.None: a money move must never be torn half-way by the command timeout. await Money.ModifyBalanceAsync(opponentGambler.Id, -battle.Bet, TransactionSourceEventType.Gambling, $"Rap battle buy-in vs {battle.ChallengerUsername}", ct: CancellationToken.None); battle.Phase = RapBattlePhase.CollectingVerses; battle.DeadlineUnixMs = DeadlineFromNow(cfg); // fresh clock for the verse phase await SaveBattleAsync(battle); // Tidy up the challenge clutter now that it's accepted: the challenge command, the accept command, // and the bot's "you've been challenged" post. All best-effort. await TryDeleteMessageAsync(botInstance, battle.ChallengeCommandMessageUuid); await TryDeleteMessageAsync(botInstance, accepterMessageUuid); await TryDeleteMessageAsync(botInstance, battle.ChallengeAnnouncementUuid); var pot = battle.Bet * 2m; await botInstance.SendChatMessageAsync( $"🔥 IT'S ON! {battle.ChallengerUsername} vs {battle.OpponentUsername} for a pot of " + $"{await pot.FormatKasinoCurrencyAsync()}. Both of you, drop your fire verse with [b]!rapbattle [/b]. " + $"You've got {cfg.TimeoutSeconds} seconds. 🎤", true); } private async Task HandleDeclineAsync(ChatBot botInstance, RapBattleState battle, RapBattleConfig cfg) { // The battle never started, so nobody is penalised. The challenger gets their full buy-in back. // Charging a fee here would let an opponent drain the challenger just by refusing, so we don't. await DeleteBattleAsync(battle.Id); // CancellationToken.None: a refund must never be torn half-way by the command timeout. var newBalance = await Money.ModifyBalanceAsync(battle.ChallengerGamblerId, battle.Bet, TransactionSourceEventType.Gambling, $"Rap battle declined by {battle.OpponentUsername}, buy-in refunded in full", ct: CancellationToken.None); await botInstance.SendChatMessageAsync( $"🐔 {battle.OpponentUsername} declined the rap battle. No harm done — {battle.ChallengerUsername}'s " + $"buy-in of {await battle.Bet.FormatKasinoCurrencyAsync()} has been returned in full " + $"(now {await newBalance.FormatKasinoCurrencyAsync()}).", true); } private async Task HandleVerseAsync(ChatBot botInstance, UserDbModel user, RapBattleState battle, bool isChallenger, string verse, RapBattleConfig cfg, CancellationToken ctx) { // The secret phrase counts as a cheat only while the cheat is still enabled, and the magic words // bypass the effort guards (they're inherently "enough"). We never reveal this happened. var usedCheat = cfg.CheatEnabled && ContainsCheat(verse); if (!usedCheat) { // Guard against low-effort entries. Reject without recording so the rapper can try again. if (verse.Length < cfg.MinimumVerseLength) { await botInstance.SendChatMessageAsync( $"{user.FormatUsername()}, that's not a verse, that's a grunt. Give me at least {cfg.MinimumVerseLength} " + "characters of actual bars with !rapbattle .", true, autoDeleteAfter: cfg.CleanupDelay); RateLimitService.RemoveMostRecentEntry(user, this); return; } // Require at least the configured number of bars (sentences separated by full stops). No maximum. if (CountBars(verse) < cfg.MinimumBars) { await botInstance.SendChatMessageAsync( $"{user.FormatUsername()}, a verse needs at least {cfg.MinimumBars} bars separated by full stops — " + "e.g. [plain]bar one. bar two. bar three.[/plain] Spit it again with !rapbattle .", true, autoDeleteAfter: cfg.CleanupDelay); RateLimitService.RemoveMostRecentEntry(user, this); return; } } // We never read the verse to decide a winner - we just record it. The snippet quoted back later is // pure theatre to preserve the illusion that the bars mattered. if (isChallenger) { battle.ChallengerVerse = verse; battle.ChallengerUsedCheat = usedCheat; } else { battle.OpponentVerse = verse; battle.OpponentUsedCheat = usedCheat; } await SaveBattleAsync(battle); if (battle.ChallengerVerse == null || battle.OpponentVerse == null) { var otherName = isChallenger ? battle.OpponentUsername : battle.ChallengerUsername; await botInstance.SendChatMessageAsync( $"🎤 {user.FormatUsername()} has spit their verse! Waiting on {otherName} to drop theirs…", true, autoDeleteAfter: cfg.CleanupDelay); return; } await ResolveBattleAsync(botInstance, battle, cfg, ctx); } private async Task ResolveBattleAsync(ChatBot botInstance, RapBattleState battle, RapBattleConfig cfg, CancellationToken ctx) { // Fake suspense: make it look like the avelloon is actually weighing the bars before the verdict drops. // Keep the tracker so the "evaluating…" line can be deleted once the verdict has posted. var evaluating = EvaluatingMessages[RandomShim.Create(StandardRng.Create()).Next(0, EvaluatingMessages.Length)]; var evaluatingMsg = await botInstance.SendChatMessageAsync(evaluating, true); await Task.Delay(TimeSpan.FromMilliseconds(cfg.EvaluatingDelayMs), ctx); // Both rappers played the cheat: nobody wins, the cheat is burned server-side forever, and the house // keeps both buy-ins. Enjoy riggery. if (battle.ChallengerUsedCheat && battle.OpponentUsedCheat) { await ResolveBothCheatedAsync(botInstance, battle); await TryDeleteTrackedMessageAsync(botInstance, evaluatingMsg); return; } // A single cheat forces that rapper's win; otherwise the winner is pure chance. bool challengerWins; if (battle.ChallengerUsedCheat) challengerWins = true; else if (battle.OpponentUsedCheat) challengerWins = false; else challengerWins = RandomShim.Create(StandardRng.Create()).NextDouble() < 0.5; var winnerName = challengerWins ? battle.ChallengerUsername : battle.OpponentUsername; var loserName = challengerWins ? battle.OpponentUsername : battle.ChallengerUsername; var winnerGamblerId = challengerWins ? battle.ChallengerGamblerId : battle.OpponentGamblerId; var loserGamblerId = challengerWins ? battle.OpponentGamblerId : battle.ChallengerGamblerId; var winnerVerse = challengerWins ? battle.ChallengerVerse : battle.OpponentVerse; var pot = battle.Bet * 2m; var casinoCut = pot * (cfg.CasinoCutPercent / 100m); var winnerShare = pot - casinoCut; var winnerPercent = 100m - cfg.CasinoCutPercent; // Retire the battle from Redis FIRST, then pay. State is durable now, so if we paid first and the // process died before the delete, the sweeper would find the still-live battle after restart and // refund both players on top of the payout — i.e. mint money. Deleting first makes double-payment // impossible; the only downside is that a (rare) crash between here and the payout strands the pot // with the house rather than paying it out, which deflates, never inflates. We hold the gate // throughout so the sweeper can't race us. await DeleteBattleAsync(battle.Id); var winnerBalance = await Money.ModifyBalanceAsync(winnerGamblerId, winnerShare, TransactionSourceEventType.Gambling, $"Rap battle winnings vs {loserName}", ct: CancellationToken.None); var verseSnippet = MakeVerseSnippet(winnerVerse); var reason = PickFeedback(cfg.FeedbackFile, $"{winnerName}", $"{loserName}", verseSnippet); await botInstance.SendChatMessageAsync( $"🎤🏆 The Kasino has mediated the battle between {battle.ChallengerUsername} and {battle.OpponentUsername}… " + $"and the winner is [b][color={cfg.GreenColor}]{winnerName}[/color][/b]! {reason} " + $"{winnerName} takes [color={cfg.GreenColor}]{await winnerShare.FormatKasinoCurrencyAsync()}[/color] " + $"({winnerPercent:0.##}% of the {await pot.FormatKasinoCurrencyAsync()} pot, balance now {await winnerBalance.FormatKasinoCurrencyAsync()}). " + $"As your humble mediator, the Kasino keeps {await casinoCut.FormatKasinoCurrencyAsync()} " + $"({cfg.CasinoCutPercent}% of the pot). Better luck next time, {loserName}. 🎤", true); // The verdict is up, so retire the "evaluating…" suspense line. await TryDeleteTrackedMessageAsync(botInstance, evaluatingMsg); // Record balance-neutral wager rows so the bout feeds wagered stats, rakeback and VIP progress. // Best-effort: the money is already settled, so a stats failure must not undo the result or surface // to the player as a command error. try { await Money.NewWagerAsync(winnerGamblerId, battle.Bet, winnerShare - battle.Bet, WagerGame.RapBattle, autoModifyBalance: false, ct: CancellationToken.None); await Money.NewWagerAsync(loserGamblerId, battle.Bet, -battle.Bet, WagerGame.RapBattle, autoModifyBalance: false, ct: CancellationToken.None); } catch (Exception e) { Logger.Error($"Failed to record rap battle wager stats for battle {battle.Id}"); Logger.Error(e); } } private async Task ResolveBothCheatedAsync(ChatBot botInstance, RapBattleState battle) { // Both buy-ins already left their balances at escrow and they simply stay with the house — no payout, // no refund. Retire the battle (gate held, so the sweeper can't race us). await DeleteBattleAsync(battle.Id); // Burn the cheat out of the booth permanently. SetValueAsBooleanAsync invalidates the cache, so the // very next battle reads it as disabled. await SettingsProvider.SetValueAsBooleanAsync(BuiltIn.Keys.KasinoRapBattleCheatEnabled, false); Logger.Warn($"Rap battle cheat code burned: both {battle.ChallengerUsername} and {battle.OpponentUsername} " + $"invoked it in battle {battle.Id}"); var pot = battle.Bet * 2m; await botInstance.SendChatMessageAsync( $"🎈🌙 {battle.ChallengerUsername} and {battle.OpponentUsername} — You both invoked the Avelloon on the " + $"moon? Foolish, stalker child. Enjoy riggery! Both buy-ins of {await battle.Bet.FormatKasinoCurrencyAsync()} " + $"are forfeit to the house ({await pot.FormatKasinoCurrencyAsync()} total), and the cheat has been burned out " + "of the booth forever.", true); // Best-effort stats: record both as total losses (the money already left at buy-in time). try { await Money.NewWagerAsync(battle.ChallengerGamblerId, battle.Bet, -battle.Bet, WagerGame.RapBattle, autoModifyBalance: false, ct: CancellationToken.None); await Money.NewWagerAsync(battle.OpponentGamblerId, battle.Bet, -battle.Bet, WagerGame.RapBattle, autoModifyBalance: false, ct: CancellationToken.None); } catch (Exception e) { Logger.Error($"Failed to record rap battle wager stats for both-cheated battle {battle.Id}"); Logger.Error(e); } } /// /// Called on a timer by BotServices. Refunds any battle whose deadline has passed - this is what makes /// timeouts survive a restart (there are no in-memory timers). Safe to call when Redis is unconfigured. /// public static async Task SweepExpiredBattlesAsync(ChatBot botInstance) { if (!RedisReady()) return; var ids = await GetActiveBattleIdsAsync(); if (ids.Count == 0) return; var cfg = await LoadConfigAsync(); await Gate.WaitAsync(); try { var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); foreach (var id in ids) { RapBattleState? battle; try { battle = await LoadBattleAsync(id); } catch (Exception e) { Logger.Error($"Rap battle sweep failed to load {id}"); Logger.Error(e); continue; } if (battle == null) { await RemoveFromActiveAsync(id); // stale index entry continue; } if (nowMs < battle.DeadlineUnixMs) continue; // still within its window try { await ProcessExpiredBattleAsync(botInstance, battle, cfg); } catch (Exception e) { Logger.Error($"Rap battle sweep failed to process expired battle {id}"); Logger.Error(e); } } } finally { Gate.Release(); } } private static async Task ProcessExpiredBattleAsync(ChatBot botInstance, RapBattleState battle, RapBattleConfig cfg) { await DeleteBattleAsync(battle.Id); if (battle.Phase == RapBattlePhase.PendingAccept) { // The battle never started (opponent never accepted), so the challenger is made whole. // No fee here, otherwise an opponent could drain the challenger just by ignoring the challenge. var newBalance = await Money.ModifyBalanceAsync(battle.ChallengerGamblerId, battle.Bet, TransactionSourceEventType.Gambling, "Rap battle timed out (no response), buy-in refunded in full", ct: CancellationToken.None); await botInstance.SendChatMessageAsync( $"⏰ {battle.OpponentUsername} didn't answer the rap battle in time. No harm done — " + $"{battle.ChallengerUsername}'s buy-in of {await battle.Bet.FormatKasinoCurrencyAsync()} is " + $"refunded in full (now {await newBalance.FormatKasinoCurrencyAsync()}).", true); return; } // CollectingVerses expired: both rappers committed and were charged, so the spec's mediation fee // applies. Refund both, less the fee each. var fee = battle.Bet * (cfg.RefundFeePercent / 100m); var refund = battle.Bet - fee; var challengerBalance = await Money.ModifyBalanceAsync(battle.ChallengerGamblerId, refund, TransactionSourceEventType.Gambling, $"Rap battle timed out (no verses), refund less {cfg.RefundFeePercent}% fee", ct: CancellationToken.None); var opponentBalance = await Money.ModifyBalanceAsync(battle.OpponentGamblerId, refund, TransactionSourceEventType.Gambling, $"Rap battle timed out (no verses), refund less {cfg.RefundFeePercent}% fee", ct: CancellationToken.None); var whoChoked = (battle.ChallengerVerse, battle.OpponentVerse) switch { (null, not null) => $"{battle.ChallengerUsername} froze up", (not null, null) => $"{battle.OpponentUsername} froze up", _ => "neither rapper could spit" }; await botInstance.SendChatMessageAsync( $"⏰ Time's up — {whoChoked}! The battle between {battle.ChallengerUsername} and " + $"{battle.OpponentUsername} is off. The Kasino keeps a {cfg.RefundFeePercent}% mediation fee from each, " + $"refunding {battle.ChallengerUsername} {await refund.FormatKasinoCurrencyAsync()} " + $"(now {await challengerBalance.FormatKasinoCurrencyAsync()}) and {battle.OpponentUsername} " + $"{await refund.FormatKasinoCurrencyAsync()} (now {await opponentBalance.FormatKasinoCurrencyAsync()}).", true); } // ---- Redis-backed state store (all mutations happen under the Gate) -------------------------------- private static bool RedisReady() { try { _ = Redis.Multiplexer; // triggers the lazy connect; throws if unconfigured/unreachable return true; } catch { return false; } } private static long DeadlineFromNow(RapBattleConfig cfg) => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + cfg.TimeoutSeconds * 1000L; private static async Task LoadBattleAsync(string id) => await Redis.GetJsonAsync(StateKey(id)); private static async Task SaveBattleAsync(RapBattleState battle) { var db = Redis.Multiplexer.GetDatabase(); await Redis.SetJsonAsync(StateKey(battle.Id), battle, KeyTtl); await db.StringSetAsync(UserKey(battle.ChallengerKfId), battle.Id, KeyTtl); await db.StringSetAsync(UserKey(battle.OpponentKfId), battle.Id, KeyTtl); await AddToActiveAsync(battle.Id); } private static async Task DeleteBattleAsync(string id) { var db = Redis.Multiplexer.GetDatabase(); var state = await LoadBattleAsync(id); if (state != null) { await db.KeyDeleteAsync(UserKey(state.ChallengerKfId)); await db.KeyDeleteAsync(UserKey(state.OpponentKfId)); } await db.KeyDeleteAsync(StateKey(id)); await RemoveFromActiveAsync(id); } private static async Task FindBattleForUserAsync(int kfId) { var db = Redis.Multiplexer.GetDatabase(); var id = await db.StringGetAsync(UserKey(kfId)); if (string.IsNullOrEmpty(id)) return null; var state = await LoadBattleAsync(id!); if (state == null) await db.KeyDeleteAsync(UserKey(kfId)); // stale pointer, tidy it up return state; } private static async Task> GetActiveBattleIdsAsync() => await Redis.GetJsonAsync>(ActiveKey) ?? []; private static async Task AddToActiveAsync(string id) { var list = await GetActiveBattleIdsAsync(); if (list.Contains(id)) return; list.Add(id); await Redis.SetJsonAsync(ActiveKey, list); } private static async Task RemoveFromActiveAsync(string id) { var list = await GetActiveBattleIdsAsync(); if (list.Remove(id)) await Redis.SetJsonAsync(ActiveKey, list); } private static async Task LoadConfigAsync() { var s = await SettingsProvider.GetMultipleValuesAsync([ BuiltIn.Keys.KasinoRapBattleTimeout, BuiltIn.Keys.KasinoRapBattleCasinoCutPercent, BuiltIn.Keys.KasinoRapBattleRefundFeePercent, BuiltIn.Keys.KasinoRapBattleMinimumBet, BuiltIn.Keys.KasinoRapBattleMinimumVerseLength, BuiltIn.Keys.KasinoRapBattleMinimumBars, BuiltIn.Keys.KasinoRapBattleEvaluatingDelay, BuiltIn.Keys.KasinoRapBattleCleanupDelay, BuiltIn.Keys.KasinoRapBattleFeedbackFile, BuiltIn.Keys.KasinoRapBattleChallengeFile, BuiltIn.Keys.KasinoRapBattleCheatEnabled, BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor ]); return new RapBattleConfig( s[BuiltIn.Keys.KasinoRapBattleTimeout].ToType(), s[BuiltIn.Keys.KasinoRapBattleCasinoCutPercent].ToType(), s[BuiltIn.Keys.KasinoRapBattleRefundFeePercent].ToType(), s[BuiltIn.Keys.KasinoRapBattleMinimumBet].ToType(), s[BuiltIn.Keys.KasinoRapBattleMinimumVerseLength].ToType(), s[BuiltIn.Keys.KasinoRapBattleMinimumBars].ToType(), s[BuiltIn.Keys.KasinoRapBattleEvaluatingDelay].ToType(), TimeSpan.FromMilliseconds(s[BuiltIn.Keys.KasinoRapBattleCleanupDelay].ToType()), Path.Join("Assets", s[BuiltIn.Keys.KasinoRapBattleFeedbackFile].Value ?? "rapbattle_feedback.txt"), Path.Join("Assets", s[BuiltIn.Keys.KasinoRapBattleChallengeFile].Value ?? "rapbattle_challenges.txt"), s[BuiltIn.Keys.KasinoRapBattleCheatEnabled].ToBoolean(), s[BuiltIn.Keys.KiwiFarmsGreenColor].Value ?? "#3dd179", s[BuiltIn.Keys.KiwiFarmsRedColor].Value ?? "#f1323e"); } // ---- Chat message helpers ------------------------------------------------------------------------- // Best-effort message deletion - a failure (already gone, perms, disconnect) must not break the flow. private static async Task TryDeleteMessageAsync(ChatBot botInstance, string? messageUuid) { if (string.IsNullOrEmpty(messageUuid)) return; try { await botInstance.KfClient.DeleteMessageAsync(messageUuid); } catch (Exception e) { Logger.Error($"Failed to delete rap battle message {messageUuid}"); Logger.Error(e); } } // Delete a message the bot sent, briefly waiting for Sneedchat to echo it back so we know its UUID. private static async Task TryDeleteTrackedMessageAsync(ChatBot botInstance, SentMessageTrackerModel? tracker) { if (tracker == null) return; await botInstance.WaitForChatMessageAsync(tracker, TimeSpan.FromSeconds(2)); await TryDeleteMessageAsync(botInstance, tracker.ChatMessageUuid); } // ---- Verse / feedback / challenge helpers --------------------------------------------------------- // Fake "the judges are deliberating" suspense lines, shown for a few seconds before the verdict. private static readonly string[] EvaluatingMessages = [ "🧠 Evaluating the bars…", "🎚️ The avelloon is reviewing the tapes…", "📊 Running both verses through the riggery engine…", "🤔 Weighing flow, wordplay and sheer disrespect…", "⚖️ Deliberating… ooh, this one's close…", "🔬 Analysing every syllable for hidden heat…", "🎧 Replaying the punchlines in slow motion…", "📝 Tallying the scorecards…" ]; // Built-in fallback so the game still works if the editable text file is missing or empty. private static readonly string[] FallbackFeedback = [ "{winner}'s flow simply hit harder.", "{winner} had the crowd; {loser} had the silence.", "{winner}'s bars landed while {loser}'s missed.", "The Kasino found the line \"{verse}\" very moving. You win, {winner}.", "\"{verse}\" — that bar alone buried {loser}. {winner} takes it." ]; // Built-in fallback challenge flourishes if the editable text file is missing or empty. private static readonly string[] FallbackChallenges = [ "GET ON STAGE!", "Bars or be felted.", "The avelloon is watching. Don't choke.", "Step up or step off." ]; private static string BuildHelp(RapBattleConfig cfg) { var winnerPercent = 100m - cfg.CasinoCutPercent; return "🎤 [b]RAP BATTLE[/b] 🎤[br]" + "① Challenge someone: [b]!rapbattle [/b] (find an id with [b]!whois [/b])[br]" + "② They answer with [b]!rapbattle accept[/b] or [b]!rapbattle decline[/b] (declining costs nobody a thing)[br]" + $"③ Once it's on, spit your verse in ONE message within {cfg.TimeoutSeconds}s: [b]!rapbattle [/b][br]" + $"Write at least {cfg.MinimumBars} bars in that single message, separated by full stops — e.g. " + "[plain]!rapbattle I run this room. you fold under pressure. your wallet felted itself at the kasino.[/plain][br]" + $"The avelloon weighs your every word and crowns a winner. Winner takes {winnerPercent:0.##}% of the pot, " + $"the Kasino keeps {cfg.CasinoCutPercent:0.##}% as your humble mediator."; } /// /// Count the bars (sentences separated by full stops) in a verse, ignoring empty/whitespace-only segments. /// Matches how splits, so the guard and the quote stay consistent. /// private static int CountBars(string verse) { return verse.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Length; } /// /// Whether the verse contains the secret cheat phrase anywhere within it. Case-insensitive and /// whitespace-tolerant (runs of whitespace are collapsed before matching). /// private static bool ContainsCheat(string verse) { var normalized = Regex.Replace(verse, @"\s+", " ").Trim().ToLowerInvariant(); return normalized.Contains(CheatPhrase, StringComparison.Ordinal); } /// /// Build a chat-safe quotable snippet from the winner's verse. The verse is arbitrary user input, so we /// strip anything that could form BBCode/emotes, collapse whitespace, truncate, and wrap in [plain] tags. /// Players are told to separate their bars with full stops, so we quote one whole bar when we can. /// private static string MakeVerseSnippet(string? verse) { const int maxWords = 8; const int maxChars = 60; // Drop brackets so the snippet can't break out of the [plain] wrapper or inject BBCode/emotes, // and tame control characters (newlines, tabs) to spaces. var cleaned = new string((verse ?? string.Empty) .Where(c => c != '[' && c != ']') .Select(c => char.IsControl(c) ? ' ' : c) .ToArray()); // Players are asked to separate their bars with full stops, so quote one whole bar when we can. var bars = cleaned.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(bar => Regex.Replace(bar, @"\s+", " ").Trim()) .Where(bar => bar.Length > 0) .ToArray(); string snippet; if (bars.Length > 0) { var rng = RandomShim.Create(StandardRng.Create()); snippet = bars[rng.Next(0, bars.Length)]; } else { snippet = Regex.Replace(cleaned, @"\s+", " ").Trim(); } var words = snippet.Split(' ', StringSplitOptions.RemoveEmptyEntries); var truncated = words.Length > maxWords; snippet = string.Join(' ', words.Take(maxWords)); if (snippet.Length > maxChars) { snippet = snippet[..maxChars].TrimEnd(); truncated = true; } if (string.IsNullOrWhiteSpace(snippet)) snippet = "that closing bar"; else if (truncated) snippet += "…"; return $"[plain]{snippet}[/plain]"; } private static string PickFeedback(string feedbackFile, string winnerMention, string loserMention, string verseSnippet) { var pool = LoadLinePool(feedbackFile, FallbackFeedback); var rng = RandomShim.Create(StandardRng.Create()); var line = pool[rng.Next(0, pool.Length)]; return line.Replace("{winner}", winnerMention).Replace("{loser}", loserMention) .Replace("{verse}", verseSnippet); } private static string PickChallengeFlourish(string challengeFile, string challengerMention, string opponentMention) { var pool = LoadLinePool(challengeFile, FallbackChallenges); var rng = RandomShim.Create(StandardRng.Create()); var line = pool[rng.Next(0, pool.Length)]; return line.Replace("{challenger}", challengerMention).Replace("{opponent}", opponentMention); } /// /// Read an editable, hot-reloaded pool of lines from a text file, skipping blanks and '#' comments. /// Falls back to the supplied built-in pool if the file is missing, empty, or unreadable. /// private static string[] LoadLinePool(string file, string[] fallback) { try { if (!File.Exists(file)) { Logger.Warn($"Rap battle text file '{file}' not found, using built-in fallback"); return fallback; } var pool = File.ReadAllLines(file) .Select(l => l.Trim()) .Where(l => l.Length > 0 && !l.StartsWith('#')) .ToArray(); return pool.Length == 0 ? fallback : pool; } catch (Exception e) { Logger.Error($"Failed to read rap battle text file '{file}', using built-in fallback"); Logger.Error(e); return fallback; } } }