Files
KfChatDotNet/KfChatDotNetBot/Commands/Kasino/LambchopCommand.cs

456 lines
20 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.RegularExpressions;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
namespace KfChatDotNetBot.Commands.Kasino;
[KasinoCommand]
[WagerCommand]
public class LambchopCommand : ICommand
{
public List<Regex> Patterns =>
[
new Regex(@"lambchop (?<amount>\d+)$", RegexOptions.IgnoreCase),
new Regex(@"lambchop (?<amount>\d+\.\d+)$", RegexOptions.IgnoreCase),
new Regex(@"lambchop (?<amount>\d+) (?<targetTile>\d+)$", RegexOptions.IgnoreCase),
new Regex(@"lambchop (?<amount>\d+\.\d+) (?<targetTile>\d+)$", RegexOptions.IgnoreCase)
];
public string? HelpText =>
"Tread treacherous terrain towards terrific treasures. Play using !lambchop bet, amount of tiles you want to move";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(12);
public RateLimitOptionsModel? RateLimitOptions => new()
{
MaxInvocations = 3,
Window = TimeSpan.FromSeconds(15)
};
public bool WhisperCanInvoke => false;
private static double _houseEdge = 0.015; // house edge hack?
// game assets
private const string HAIRSPACE = "";
private const string SHEEP = "🐑";
private const string YELLOW_TILE = "🟡";
private const string PURPLE_TILE = "🟣";
private const string GREEN_TILE = "🟢";
private const string RED_TILE = "🔴";
private const string FORREST_TILE = "🌳";
private const string DESERT_TILE = "🏜️";
private const string WOLF = "🐺";
private const string ALIEN = "🛸";
private const string LIGHTNING = "⚡";
private const string BLOOD = HAIRSPACE + "🩸" + HAIRSPACE;
private const string SKULL = "☠";
private const string MEDAL = "🏅";
private const string MONEYBAG = "💰";
private const string CELEBRATION = "🏆🪩✨";
private const string CASTLE = "🏯";
private const string WOOSH = "💨";
private const string FIST = HAIRSPACE + "✊" + HAIRSPACE;
private const string TILE_SPACING = "[color=#36393f]......[/color]";
private const string HAZARD_SPACING = "[color=#36393f].......[/color]";
// game settings
private const int FRAME_DELAY = 200; // time between lambchop frames in milliseconds
private const int FIELD_LENGTH = 16; // indicates how many tiles the lamb can cross. default is 16
// WARNING: do NOT change without first implementing dynamic payout logic in LambchopPayoutMultiplier()
// has to be an EVEN number > 1
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay, BuiltIn.Keys.KasinoLambchopCleanupDelay,
BuiltIn.Keys.KasinoLambchopEnabled
]);
// Check if lambchop is enabled
var lambchopEnabled = (settings[BuiltIn.Keys.KasinoLambchopEnabled]).ToBoolean();
if (!lambchopEnabled)
{
var gameDisabledCleanupDelay= TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay].ToType<int>());
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, lambchop is currently disabled.",
true, autoDeleteAfter: gameDisabledCleanupDelay);
return;
}
// await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, fuck you", true);
// return;
var cleanupDelay = TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoLambchopCleanupDelay].ToType<int>());
if (!arguments.TryGetValue("amount", out var amount))
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, not enough arguments. !lambchop <wager> <number between 1 and {FIELD_LENGTH}>", true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var targetTile = arguments["targetTile"].Success ? Convert.ToInt32(arguments["targetTile"].Value) : FIELD_LENGTH;
if (targetTile is < 1 or > FIELD_LENGTH)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, Please choose a target tile between 1 and {FIELD_LENGTH}", true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var wager = Convert.ToDecimal(amount.Value);
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}");
if (gambler.Balance < wager)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your balance of {await gambler.Balance.FormatKasinoCurrencyAsync()} isn't enough for this wager.",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
if (wager == 0)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you have to wager more than {await wager.FormatKasinoCurrencyAsync()}", true,
autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var colors =
await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor
]);
List<string> tiles = Enumerable.Repeat(YELLOW_TILE, FIELD_LENGTH / 2).ToList();
tiles.AddRange(Enumerable.Repeat(PURPLE_TILE, FIELD_LENGTH / 2));
List<string> hazards = Enumerable.Repeat(FORREST_TILE, FIELD_LENGTH / 2).ToList();
hazards.AddRange(Enumerable.Repeat(DESERT_TILE, FIELD_LENGTH / 2));
// calculate death tile, death tile = -1 means no death tile
int deathTile = CalculateDeathTile(targetTile, gambler);
bool win;
int steps;
if (deathTile == -1) // no death tile on field
{
win = true; // if there's is no deathTile then automatic win!
steps = targetTile - 1;
}
else
{
win = (targetTile - 1) < deathTile; // if your targetTile is less then the death tile then you win!
steps = win ? targetTile - 1 : deathTile;
}
// first game state
var lambChopDisplayMessage =
await botInstance.SendChatMessageAsync(ConvertLambchopFieldToString(tiles, hazards, true), true,
autoDeleteAfter: cleanupDelay);
while (lambChopDisplayMessage.ChatMessageUuid == null)
{
await Task.Delay(50, ctx); // wait until first message is fully sent
if (lambChopDisplayMessage.Status is SentMessageTrackerStatus.Lost or SentMessageTrackerStatus.NotSending)
return;
}
for (int i = -1; i <= steps;) // main game loop, if/else "state machine"
{
if (i == -1)
{
// first state, print empty tileset and sheep placeholder
await Task.Delay(TimeSpan.FromMilliseconds(FRAME_DELAY), ctx);
tiles = MoveSheep(tiles); // move the sheep onto the first tile
i++; // increase step counter by 1
continue;
}
// boundary check for sheep movement
if (i >= tiles.Count)
{
break; // exit if we've gone past the field
}
// normal "move" state
// let alien follow player
if (i > FIELD_LENGTH / 2 - 1)
{
hazards[i] = ALIEN; // alien follows you in later part of the map
if (i > 0 && hazards[i - 1] == ALIEN)
{
// update previous hazard tile back to desert
hazards[i - 1] = DESERT_TILE;
}
}
if (i == deathTile) // trigger hazard death?
{
// player dies on this step
if (i > FIELD_LENGTH / 2 - 1)
{
// death by alien
await UpdateGameAsync();
tiles[i] = LIGHTNING; // strike player with lightning
await UpdateGameAsync();
tiles[i] = SKULL; // skull
await UpdateGameAsync();
break;
// i++;
//continue;
}
// death by wolf
await UpdateGameAsync();
hazards[i] = WOLF; // add wolf
await UpdateGameAsync();
tiles[i] = BLOOD; // blood
await UpdateGameAsync();
tiles[i] = SKULL; // skull
await UpdateGameAsync();
break;
//i++;
//continue;
}
if (i == (targetTile - 1) && win) // trigger win animation
{
await UpdateGameAsync(); //arrive at targetTile
if (targetTile == FIELD_LENGTH)
{
// mega win, end of the line
string lambChopFieldEndState = ConvertLambchopFieldToString(tiles, hazards, false);
lambChopFieldEndState = lambChopFieldEndState.Replace(SHEEP, GREEN_TILE);
lambChopFieldEndState += SHEEP;
await UpdateGameAsync(lambChopFieldEndState);
lambChopFieldEndState += CELEBRATION;
await UpdateGameAsync(lambChopFieldEndState);
break;
//i++;
//continue;
}
if (i > FIELD_LENGTH / 2 - 1)
{
// win in the tundra, moneybags
hazards[i] = MONEYBAG; // add moneybag
if (deathTile >= 0 && deathTile < tiles.Count)
{
tiles[deathTile] = RED_TILE; // add deathTile indicator
}
await UpdateGameAsync();
break;
//i++;
//continue;
}
// win in the forrest, medal
hazards[i] = MEDAL; // add medal
if (deathTile != -1 && deathTile < tiles.Count)
{
tiles[deathTile] = RED_TILE; // add deathTile indicator
}
await UpdateGameAsync();
break;
//i++;
//continue;
}
if (Money.GetRandomDouble(gambler) <= 0.15)
{
//fakeouts
// forrest or desert
if (i > FIELD_LENGTH / 2 - 1)
{
// desert fakeout
await UpdateGameAsync();
tiles[i] = LIGHTNING; // strike player with lightning
string leftTile = tiles[i - 1];
tiles[i - 1] = WOOSH; // add woosh fakeout
await UpdateGameAsync();
tiles[i - 1] = leftTile; // return left tile to normal
tiles[i] = SHEEP; // change back to sheep
}
else
{
// forrest fakeout
await UpdateGameAsync();
string forrestTile = hazards[i];
hazards[i] = WOLF; // add wolf
await UpdateGameAsync();
tiles[i] = FIST; // add fist
await UpdateGameAsync();
hazards[i] = forrestTile;
tiles[i] = SHEEP; // change back to sheep
}
}
await UpdateGameAsync();
tiles = MoveSheep(tiles);
i++;
}
// payout logic
string lambchopResultMessage;
decimal newBalance;
if (win)
{
var multi = LambchopPayoutMultiplier(targetTile);
var lambchopPayout = Math.Round(wager * multi - wager, 2);
newBalance = await Money.NewWagerAsync(gambler.Id, wager, lambchopPayout, WagerGame.LambChop, ct: ctx);
lambchopResultMessage = $"{user.FormatUsername()}, you [B][COLOR={colors[BuiltIn.Keys.KiwiFarmsGreenColor].Value}]WON[/COLOR][/B]" +
$" | Multi {multi} | Balance {await newBalance.FormatKasinoCurrencyAsync()}";
}
else
{
newBalance = await Money.NewWagerAsync(gambler.Id, wager, -wager, WagerGame.LambChop, ct: ctx);
lambchopResultMessage = $"{user.FormatUsername()}, you [B][COLOR={colors[BuiltIn.Keys.KiwiFarmsRedColor].Value}]LOST[/COLOR][/B]" +
$", better luck next time | Balance {await newBalance.FormatKasinoCurrencyAsync()}";
}
await botInstance.SendChatMessageAsync(lambchopResultMessage, true, autoDeleteAfter: cleanupDelay);
return;
// hacky local helper function to quickly print the current state of the game field and trigger the frame delay
async Task UpdateGameAsync(string? updateText = null)
{
updateText ??= ConvertLambchopFieldToString(tiles, hazards, false);
await botInstance.KfClient.EditMessageAsync(lambChopDisplayMessage.ChatMessageUuid, updateText);
await Task.Delay(TimeSpan.FromMilliseconds(FRAME_DELAY), ctx);
}
}
// return -1 if player can proceed trough entire field
private static int CalculateDeathTile(int targetTile, GamblerDbModel gambler)
{
// CHECK: does player want to move all tiles?
if (targetTile == FIELD_LENGTH)
{
// PLAYER WANTS TO MOVE ALL TILES
// normal success chance
double successChance = 1.0 / (FIELD_LENGTH + 1); // +1 because "winning" means you dont die on the last tile
if (_houseEdge > 0)
{
// Decrease success chance based on houseEdge (linearly)
successChance *= (1.0 - _houseEdge);
}
// Determine if player can walk all tiles
if (Money.GetRandomDouble(gambler) <= successChance)
{
return -1; // No death tile (player succeeds)
}
// Player fails - calculate where the death tile appears
double riggingFactor = Money.GetRandomDouble(gambler);
if (_houseEdge > 0 && riggingFactor < _houseEdge * 2) // shitty hack because I made the decision to clamp houseEdge to max 50%
{
// More rigging means death tile is more likely near the end
int minDeathTile = Math.Max(0, FIELD_LENGTH - 3);
return Money.GetRandomNumber(gambler, minDeathTile, FIELD_LENGTH, incrementMaxParam:false); // return 15 means dying on the last tile xd
}
else
{
// Player fail, random tile in the path becomes death tile
return Money.GetRandomNumber(gambler,0, FIELD_LENGTH, incrementMaxParam:false);
}
}
// Tiles 1 - 15
if (_houseEdge < 0.015)
{
int deathTile = Money.GetRandomNumber(gambler,-1, FIELD_LENGTH, incrementMaxParam:false); // can be any tile, including no tile! (result -1 to FIELD_LENGTH (-1 - 15))
return deathTile;
}
// game is rigged, manipulate tile placement
int fairDeathTile = Money.GetRandomNumber(gambler,-1, FIELD_LENGTH, incrementMaxParam:false);
fairDeathTile = fairDeathTile == -1 ? FIELD_LENGTH + 1 : fairDeathTile; // shit hack, -1 means no death tile, change it to FIELD_LENGTH + 1 to compensate for next check.
bool wouldSucceedFairly = fairDeathTile > targetTile;
fairDeathTile = fairDeathTile == FIELD_LENGTH + 1 ? -1 : fairDeathTile;
if (wouldSucceedFairly)
{
// are we gonna rig it
double riggedFailChance = _houseEdge * 2;
if (Money.GetRandomDouble(gambler) <= riggedFailChance)
{
double cruelnessLevel = Money.GetRandomDouble(gambler);
if (cruelnessLevel < _houseEdge * 2)
{
// extra rigged fail, choose tile just before target tile
return targetTile > 1 ? targetTile - 1 : 1;
}
else
{
// rigging failed, normal tile return
return Money.GetRandomNumber(gambler,-1, targetTile, incrementMaxParam:false);
}
}
return fairDeathTile;
}
{
// Player would fail in fair game
double riggingFactor = Money.GetRandomDouble(gambler);
if (riggingFactor < _houseEdge)
{
// Place death tile closer to target
// higher house edge = more likely to place closer
int minTile = Math.Max(0, targetTile - 3);
return Money.GetRandomNumber(gambler,minTile, targetTile, incrementMaxParam:false);
}
return fairDeathTile;
}
}
private static string ConvertLambchopFieldToString(List<string> tiles, List<string> hazards, bool first)
{
// This function takes the current state of the lambchop field and transforms it into a print ready string.
// Its very hacky as it uses weird hairspaces to evenly space out some of the game elements for aesthetic reasons.
// The game is optimized to display best on windows machines running a mostly default webbrowser.
// This comes at the aesthetic expense of other platforms using different sets of emoji.
// In case this is the first game state (bool first) print the sheep emoji in front of the tiles as to indicate
// that the game is about to start, this prevents the game starting on a fail state on tile 0 which would look silly.
string lambchopFieldState = "";
int hazardSplitIndex = hazards.Count / 2; // first half of the field uses forrest emoji which need to be alternated with hairspaces for good spacing.
string forrestHazards = string.Join(HAIRSPACE, hazards.GetRange(0, hazardSplitIndex)); // alternate forrest emoji and hairspaces
string desertHazards = string.Concat(hazards.GetRange(hazardSplitIndex, hazards.Count - hazardSplitIndex)); // add desert emojis without spacing
lambchopFieldState += HAZARD_SPACING + forrestHazards + desertHazards + "\n"; // glue it all together with the tiles
lambchopFieldState += first ? SHEEP : TILE_SPACING; // first state uses sheep in front of tiles, every other state uses custom spacer string.
lambchopFieldState += string.Join("", tiles);
lambchopFieldState += CASTLE;
return lambchopFieldState;
}
private static List<string> MoveSheep(List<string> tiles)
{
int index = tiles.IndexOf(SHEEP);
if (index == -1)
{
// no sheep on tiles? Second game state, move sheep to first tile.
tiles.RemoveAt(0);
tiles.Insert(0, SHEEP);
}
else
{
if (index < tiles.Count - 1)
{
//tiles[index] = index < tiles.Count / 2 ? yellow_tile : purple_tile;
tiles[index] = GREEN_TILE;
tiles[index + 1] = SHEEP;
}
// sheep is already at end position
}
return tiles;
}
private static decimal LambchopPayoutMultiplier(int targetTile)
{
targetTile -= 1; // make it 0 indexed xd
List<double> lambChopMultis =
[
1.062, 1.138, 1.228, 1.318, 1.426, 1.561, 1.714, 1.912, 2.142,
2.442, 2.871, 3.425, 4.272, 5.702, 8.539, 16.861
];
if (FIELD_LENGTH != lambChopMultis.Count)
{
throw new InvalidOperationException("FIELD_LENGTH doesn't match lambChopMultis array size. " +
"Update the multees for the new field length");
}
return (decimal)lambChopMultis[targetTile];
}
}