casino 0.3.0

A casino built right into your terminal
Documentation
use core::fmt;
use std::{
    io::{stdout, Write},
    thread::sleep,
    time::Duration,
};

use anyhow::Result;
use colored::*;
use crossterm::{cursor, terminal, QueueableCommand};
use inquire::Select;
use itertools::Itertools;
use rand::{
    distr::{weighted::WeightedIndex, Distribution},
    rng, Rng,
};
use rust_decimal::Decimal;

use crate::{money::Money, Casino};

pub fn play_slots() -> Result<()> {
    let mut casino = Casino::from_filesystem()?;

    let options = [
        Money::from_major(1),
        Money::from_major(5),
        Money::from_major(10),
        Money::from_major(25),
        Money::from_major(100),
        Money::from_major(500),
        Money::from_major(1_000),
        Money::from_major(5_000),
        Money::from_major(25_000),
        Money::from_major(100_000),
    ]
    .iter()
    .filter(|&m| *m <= casino.bankroll)
    .map(|m| PriceTier::new(*m))
    .collect();

    let bet_selection = Select::new(
        format!("Which slot machine to use? (you have {}) ", casino.bankroll).as_str(),
        options,
    )
    .prompt()
    .unwrap();
    let bet_amount = bet_selection.cost;

    casino.bankroll -= bet_amount;
    casino.stats.slots.record_pull(bet_amount);

    casino.save();
    println!(
        "{}",
        format!("* You insert your money into the {bet_amount} slot machine.").dimmed()
    );
    println!("You now have {} in the bank", casino.bankroll);
    sleep(Duration::from_millis(600));
    println!("{}", "* You pull the arm of the slot machine.".dimmed());
    sleep(Duration::from_millis(600));
    println!("{}", "* The wheels start spinning.".dimmed());

    let slot_machine = SlotMachine::new_with_default_symbols(bet_selection.multiplier);

    let mut rng = rng();
    let mut position = 0.0;
    let mut velocity = rng.random_range(20.0..40.0);
    let accel = rng.random_range(-10.0..-5.0);

    let mut stdout = stdout();

    let mut selected: Vec<&Symbol> = slot_machine.pull();

    while velocity > 0.0 {
        if position >= 1.0 {
            selected = slot_machine.pull();
            position -= 1.0;
        }

        stdout.queue(cursor::SavePosition).unwrap();

        stdout
            .write_all(
                format!(
                    "{}{}{}{}{}",
                    selected[0], selected[1], selected[2], selected[3], selected[4]
                )
                .as_bytes(),
            )
            .unwrap();

        stdout.queue(cursor::RestorePosition).unwrap();
        stdout.flush().unwrap();

        sleep(Duration::from_millis(16));

        velocity += accel * (16.0 / 1000.0);
        position += velocity * (16.0 / 1000.0);

        stdout.queue(cursor::RestorePosition).unwrap();
        stdout
            .queue(terminal::Clear(terminal::ClearType::FromCursorDown))
            .unwrap();
    }

    stdout
        .write_all(
            format!(
                "{}{}{}{}{}",
                selected[0], selected[1], selected[2], selected[3], selected[4]
            )
            .as_bytes(),
        )
        .unwrap();
    println!();

    let mut total_payout = Money::ZERO;

    let pay_table = slot_machine.payout(selected);

    if !pay_table.is_empty() {
        println!();
    }

    for entry in pay_table.iter() {
        println!("  {} × {} = {}", entry.symbol, entry.count, entry.payout);
        total_payout += entry.payout;
    }

    println!();
    println!("Payout: {total_payout}");

    casino.bankroll += total_payout;
    casino.stats.update_bankroll(casino.bankroll);

    if total_payout > Money::ZERO {
        casino.stats.slots.record_win(total_payout);
    }

    casino.check_for_mister_green();

    casino.save();

    Ok(())
}

type Symbol = char;
type Weight = u32;

#[derive(Clone, Debug)]
pub struct SlotMachine {
    multiplier: f32,
    weights: Vec<(Symbol, Weight)>,
    distribution: WeightedIndex<Weight>,
}

impl SlotMachine {
    pub fn new_with_default_symbols(multiplier: f32) -> Self {
        let symbols = vec![
            ('🍋', 30),
            ('🍒', 30),
            ('🍊', 30),
            ('🍉', 30),
            ('🔔', 20),
            ('🍌', 20),
            ('🍫', 10),
            ('💰', 2),
            ('💎', 1),
        ];
        let weights: Vec<u32> = symbols.iter().map(|s| s.1).collect();
        Self {
            multiplier,
            weights: symbols,
            distribution: WeightedIndex::new(weights).unwrap(),
        }
    }

    pub fn add_symbol(&mut self, symbol: char, weight: Weight) {
        self.weights.push((symbol, weight));
        self.distribution = WeightedIndex::new(self.weights.iter().map(|i| i.1)).unwrap();
    }

    pub fn payout(&self, symbols: Vec<&Symbol>) -> Vec<PayTableEntry> {
        let mut entries = vec![];

        let counts = symbols.iter().counts();

        for (symbol, count) in counts.iter() {
            if *count >= 3 {
                let sym: char = ***symbol;
                let sym_weight = self.weights.iter().find(|(s, _w)| s == &sym).unwrap().1;
                let sym_value = (self.multiplier * 120.0 / sym_weight as f32) as i64;
                let sym_payout = Money::from_major(sym_value * (count - 2) as i64);

                entries.push(PayTableEntry::new(sym, *count, sym_payout));
            }
        }

        entries
    }

    pub fn pull(&self) -> Vec<&Symbol> {
        let mut rng = rng();
        let samples: Vec<usize> = self
            .distribution
            .clone()
            .sample_iter(&mut rng)
            .take(5)
            .collect();
        samples.iter().map(|i| &self.weights[*i].0).collect()
    }
}

pub struct SlotMachineOutput {
    pub entries: Vec<PayTableEntry>,
}

impl SlotMachineOutput {}

pub struct PayTableEntry {
    pub symbol: Symbol,
    pub count: usize,
    pub payout: Money,
}

impl PayTableEntry {
    pub fn new(symbol: Symbol, count: usize, payout: Money) -> Self {
        Self {
            symbol,
            count,
            payout,
        }
    }
}

struct PriceTier {
    pub cost: Money,
    pub multiplier: f32,
}

impl PriceTier {
    pub fn new(cost: Money) -> Self {
        let mult: Decimal = cost.into();
        Self {
            cost,
            multiplier: mult.try_into().unwrap(),
        }
    }
}

impl fmt::Display for PriceTier {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} per pull", self.cost)
    }
}

#[cfg(test)]
mod test {
    use rust_decimal::Decimal;

    use crate::{
        money::Money,
        slots::{PayTableEntry, SlotMachine},
    };

    #[test]
    fn test_symbols_return_to_player() {
        let slot_machine = SlotMachine::new_with_default_symbols(1.0);

        let mut total_player_payment = Money::ZERO;
        let mut total_player_return = Money::ZERO;

        for _i in 1..10_000 {
            total_player_payment += Money::from_major(1);

            let payout: Vec<PayTableEntry> = slot_machine.payout(slot_machine.pull());

            for pay_table_entry in payout.iter() {
                total_player_return += pay_table_entry.payout;
            }
        }

        let total_return: Decimal = total_player_return.into();
        let total_payment: Decimal = total_player_payment.into();
        let rtp_ratio: f32 = (total_return / total_payment).try_into().unwrap();

        assert!(
            rtp_ratio >= 0.80,
            "Return-to-player ratio is {rtp_ratio}, which should be higher than 0.80"
        );
        assert!(
            rtp_ratio < 1.0,
            "Return-to-player ratio is {rtp_ratio}, which should be less than 1.0"
        );
    }
}