From c9ca8f76bf28e52ef49af45346d0c5b3302829da Mon Sep 17 00:00:00 2001 From: Niall Coates <1349685+Niall-@users.noreply.github.com> Date: Sun, 7 Apr 2024 17:25:47 +0100 Subject: [PATCH] feature: dodgy hangman game feature: 1/3/5 year crypto data bugfix: crypto data with invalid candles will be ignored chore: cleanup --- Cargo.toml | 1 + src/bot.rs | 155 +++++++++++++++++++++++++---------- src/main.rs | 231 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 343 insertions(+), 44 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9b2eb06..de2afaa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,4 @@ toml = "0.5.8" rand = "0.8.5" urlencoding = "2.1.0" openweathermap = "0.2.4" +time = { version = "0.3.30", features = [] } diff --git a/src/bot.rs b/src/bot.rs index 4fe1d3a..cdcf775 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -10,7 +10,6 @@ use openweathermap::CurrentWeather; use serde::{Deserialize, Deserializer}; use std::cell::RefCell; use std::collections::HashMap; -use std::f32::MAX as f32_max; use std::str::FromStr; use std::time::Duration as STDDuration; use tokio::spawn; @@ -27,6 +26,9 @@ enum Task<'a> { Location(&'a str), Coins(&'a str, &'a str), Lastfm(&'a str), + Hang(&'a str), + HangGuess(&'a str), + HangStart(&'a str), } fn process_commands<'a>(nick: &'a str, msg: &'a str) -> Task<'a> { @@ -42,8 +44,8 @@ fn process_commands<'a>(nick: &'a str, msg: &'a str) -> Task<'a> { // some people like to say just '.' or '!' in irc so // we'll check the length to maker sure they're // actually trying to interact with the bot - c if (c.starts_with('.') && c.len() > 1) => c.strip_prefix('.'), - c if (c.starts_with('!') && c.len() > 1) => c.strip_prefix('!'), + c if c.starts_with('.') && c.len() > 1 => c.strip_prefix('.'), + c if c.starts_with('!') && c.len() > 1 => c.strip_prefix('!'), c if c.to_lowercase().starts_with(nick) => match tokens.next() { Some(n) => Some(n), None => Some("help"), @@ -55,7 +57,22 @@ fn process_commands<'a>(nick: &'a str, msg: &'a str) -> Task<'a> { // if there's no '`boot:` help' or '`.`help' there's nothing // left to do, so continue with our day if bot_prefix.is_none() { - return Task::Ignore; + // todo: it's accepting short/medium/long here when it shouldn't + return match next { + Some(t) if tokens.count() == 0 => { + let letter = match t.trim().chars().next() { + Some(x) if t.trim().len() == 1 && matches!(x, 'a'..='z') => true, + _ => false, + }; + + if letter { + Task::Hang(t.trim()) + } else { + Task::HangGuess(t.trim()) + } + } + _ => Task::Ignore, + }; } let coins = [ @@ -77,7 +94,8 @@ fn process_commands<'a>(nick: &'a str, msg: &'a str) -> Task<'a> { let response = "Commands: repo | seen | tell | weather \ | loc | \ - "; + \ + | hang "; Task::Message(response) } "repo" | "git" => Task::Message("https://github.com/niall-/boot"), @@ -120,6 +138,9 @@ fn process_commands<'a>(nick: &'a str, msg: &'a str) -> Task<'a> { "30d", "month", "year", + "1y", + "3y", + "5y", "spot", ]; let coin_time = match tokens.next() { @@ -129,6 +150,8 @@ fn process_commands<'a>(nick: &'a str, msg: &'a str) -> Task<'a> { "14d" | "2w" | "fortnight" | "fortnightly" => "14d", "31d" | "30d" | "month" => "31d", "year" => "1y", + "3y" => "3y", + "5y" => "5y", _ => "1d", } } @@ -141,6 +164,15 @@ fn process_commands<'a>(nick: &'a str, msg: &'a str) -> Task<'a> { Some(nick) => Task::Lastfm(nick.trim()), None => Task::Message("noob"), }, + "hang" => match tokens.next() { + Some(l) => match l.trim().to_lowercase().as_ref() { + "short" => Task::HangStart("short"), + "medium" => Task::HangStart("medium"), + "long" => Task::HangStart("long"), + _ => Task::HangStart(""), + }, + None => Task::HangStart(""), + }, _ => Task::Ignore, } } @@ -250,7 +282,7 @@ pub async fn process_messages( let tx2 = tx2.clone(); let ftarget = msg.target.clone(); - tokio::spawn(async move { + spawn(async move { let weather = get_weather(&coords, &key).await; match weather { Ok(weather) => { @@ -271,7 +303,7 @@ pub async fn process_messages( let ftarget = msg.target.clone(); let fsource = msg.source.clone(); - tokio::spawn(async move { + spawn(async move { let fetched_location = get_location(&location).await; #[allow(unused_assignments)] let mut coords: Option = None; @@ -330,7 +362,7 @@ pub async fn process_messages( let ftarget = msg.target.clone(); let response = format!("No coordinates found for {} in database", l); println!("{}", response); - tokio::spawn(async move { + spawn(async move { let fetched_location = get_location(&flocation).await; match fetched_location { Ok(Some(l)) => { @@ -397,7 +429,7 @@ pub async fn process_messages( let ftarget = msg.target.clone(); let tx2 = tx2.clone(); let time_frame = t.to_string(); - tokio::spawn(async move { + spawn(async move { let coins = get_coins(coin, &time_frame).await; match coins { Ok(coins) => { @@ -421,8 +453,27 @@ pub async fn process_messages( Ok(response) => client.send_privmsg(msg.target, response).unwrap(), Err(e) => client.send_privmsg(msg.target, e).unwrap(), }, + Task::Hang(l) if msg.target == "#games" => { + tx2.send(Bot::Hang(msg.target, l.to_string())) + .await + .unwrap(); + } + Task::HangGuess(w) if msg.target == "#games" => { + tx2.send(Bot::HangGuess(msg.target, w.to_string())) + .await + .unwrap(); + } + Task::HangStart(l) if msg.target == "#games" => { + let target = if l.len() == 0 { + "".to_string() + } else { + l.to_string() + }; + + tx2.send(Bot::HangGuess(msg.target, target)).await.unwrap(); + } Task::Ignore => (), - //_ => (), + _ => (), } } @@ -641,7 +692,7 @@ pub struct Coin { fn from_str<'de, D, T>(deserializer: D) -> Result where D: Deserializer<'de>, - T: std::str::FromStr, + T: FromStr, T::Err: std::fmt::Display, { let s: String = Deserialize::deserialize(deserializer)?; @@ -649,11 +700,11 @@ where } #[derive(Debug, Deserialize)] -struct OHLCData { +struct OhlcData { time: i64, _open: String, - _high: String, - _low: String, + high: String, + low: String, _close: String, #[serde(deserialize_with = "from_str")] vwap: f32, @@ -662,19 +713,18 @@ struct OHLCData { } #[derive(Debug, Deserialize)] -struct OHLCResult { +struct OhlcResult { #[serde(flatten)] - data: HashMap>, + data: HashMap>, #[serde(rename = "last")] _last: i64, } -#[allow(clippy::upper_case_acronyms)] #[derive(Debug, Deserialize)] -struct OHLC { +struct Ohlc { #[serde(rename = "error")] _error: Vec, - result: OHLCResult, + result: OhlcResult, } #[derive(Debug, Deserialize)] @@ -706,8 +756,7 @@ struct TickerResult { #[derive(Debug, Deserialize)] struct Ticker { - #[serde(rename = "error")] - _error: Vec, + //#[serde(rename = "error")] _error: Vec, result: TickerResult, } @@ -737,6 +786,8 @@ pub async fn get_coins(coin: &str, time_frame: &str) -> Result { "14d" => (240, Utc::now() - Duration::days(14)), "31d" => (1440, Utc::now() - Duration::days(31)), "1y" => (21600, Utc::now() - Duration::days(365)), + "3y" => (21600, Utc::now() - Duration::days(1095)), + "5y" => (21600, Utc::now() - Duration::days(1825)), _ => (60, Utc::now() - Duration::hours(24)), }; @@ -747,9 +798,12 @@ pub async fn get_coins(coin: &str, time_frame: &str) -> Result { ); let ticker_url = format!("https://api.kraken.com/0/public/Ticker?pair={coin}"); + println!("ohlc: {ohlc_url}"); + println!("ticker: {ticker_url}"); + let ohlc_page = Webpage::from_url(&ohlc_url, opt)?; let ticker_page = Webpage::from_url(&ticker_url, opt2)?; - let mut coin_json: OHLC = serde_json::from_str(&ohlc_page.html.text_content)?; + let mut coin_json: Ohlc = serde_json::from_str(&ohlc_page.html.text_content)?; let mut ticker_json: Ticker = serde_json::from_str(&ticker_page.html.text_content)?; let spot_time = Utc::now().timestamp(); @@ -790,6 +844,9 @@ pub async fn get_coins(coin: &str, time_frame: &str) -> Result { min = (c.vwap, count, c.time); max = (c.vwap, count, c.time); } else { + let high = c.high.parse::().unwrap_or(c.vwap); + let low = c.low.parse::().unwrap_or(c.vwap); + match time_frame { "14d" => { if count % 2 == 0 { @@ -801,10 +858,10 @@ pub async fn get_coins(coin: &str, time_frame: &str) -> Result { } _ => prices.push(c.vwap), } - if c.vwap > max.0 { - max = (c.vwap, count, c.time); - } else if c.vwap < min.0 { - min = (c.vwap, count, c.time); + if high > max.0 { + max = (high, count, c.time); + } else if low < min.0 { + min = (low, count, c.time); } } mean += c.vwap; @@ -822,7 +879,7 @@ pub async fn get_coins(coin: &str, time_frame: &str) -> Result { } mean += spot; - let len = coins.len(); + let len = coins.len() + 1; mean /= len as f32; let sign = match coin { @@ -830,16 +887,22 @@ pub async fn get_coins(coin: &str, time_frame: &str) -> Result { _ => "$", }; - let graph = graph(initial, prices, true); - let graph = format!( - "{coin} {sign}{} {} {graph} spot: {sign}{} {}", - coins[0].vwap, - print_date(coins[0].time, time_frame), - //coins[len - 1].vwap, - //print_date(coins[len - 1].time, time_frame), - spot, - print_date(spot_time, time_frame) - ); + let colour = matches!(time_frame, "3y" | "5y"); + + let graph = graph(initial, prices, !colour); + let graph = if time_frame != "3y" && time_frame != "5y" { + format!( + "{coin} {sign}{} {} {graph} spot: {sign}{} {}", + coins[0].vwap, + print_date(coins[0].time, time_frame), + //coins[len - 1].vwap, + //print_date(coins[len - 1].time, time_frame), + spot, + print_date(spot_time, time_frame) + ) + } else { + format!("{coin} {graph}") + }; let stats = format!( "{coin} high: {sign}{} {} // mean: {sign}{mean} // low: {sign}{} {}", @@ -864,7 +927,7 @@ fn print_date(date: i64, time_frame: &str) -> String { let time = NaiveDateTime::parse_from_str(&date.to_string(), "%s").unwrap(); match time_frame { // 29-Nov-2023 - "7d" | "14d" | "31d" | "1y" => time.format("(%d-%b-%Y)").to_string(), + "7d" | "14d" | "31d" | "1y" | "3y" | "5y" => time.format("(%d-%b-%Y)").to_string(), // Tue-05 02:00:00 UTC _ => time.format("(%a-%d %T UTC)").to_string(), } @@ -888,14 +951,14 @@ fn graph(initial: f32, prices: Vec, colour: bool) -> String { }; /* XXX: This doesn't feel like idiomatic Rust */ - let mut min: f32 = f32_max; + let mut min: f32 = f32::MAX; let mut max: f32 = 0.0; for &i in prices.iter() { if i > max { max = i; } - if i < min { + if i < min && i > 0.0 { min = i; } } @@ -911,7 +974,9 @@ fn graph(initial: f32, prices: Vec, colour: bool) -> String { let ratio = ((p - min) * ratio).round() as usize; if count == 0 { - if p > &initial { + if *p <= 0.001 { + v.push_str(" "); + } else if p > &initial { v.push_str(&format!( "{colour_green}{}{colour_esc}", ticks.chars().nth(ratio).unwrap() @@ -923,9 +988,11 @@ fn graph(initial: f32, prices: Vec, colour: bool) -> String { )); } } else { - // if the current price is higher than the previous price - // the bar should be green, else red - if p > &prices[count - 1] { + if *p <= 0.001 { + v.push_str(" "); + } else if p > &prices[count - 1] { + // if the current price is higher than the previous price + // the bar should be green, else red v.push_str(&format!( "{colour_green}{}{colour_esc}", ticks.chars().nth(ratio).unwrap() diff --git a/src/main.rs b/src/main.rs index 40275ee..9277298 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,12 @@ use crate::settings::Settings; use crate::sqlite::{Database, Location, Notification, Seen}; use irc::client::ClientStream; use messages::process_message; +use rand::prelude::IteratorRandom; +use rand::{thread_rng, Rng}; +use std::fmt::{Display, Error, Formatter, Write}; +use std::fs::File; +use std::io::BufRead; +use std::io::BufReader; use tokio::sync::mpsc; #[derive(Debug)] @@ -26,6 +32,73 @@ pub enum Bot { UpdateLocation(String, Location), UpdateCoins(Coin), Quit(String, String), + Hang(String, String), + HangGuess(String, String), +} + +struct Hang { + started: bool, + word: String, + state: String, + guesses: Vec, + attempts: u8, +} + +impl Default for Hang { + fn default() -> Hang { + Hang { + started: false, + word: "".to_string(), + state: "".to_string(), + guesses: Vec::new(), + attempts: 0, + } + } +} + +// credits: 99% dilflover69, 1% me +pub struct PrintCharsNicely<'a>(&'a Vec); + +impl Display for PrintCharsNicely<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + f.write_char('[')?; + + for (i, c) in self.0.iter().enumerate() { + if i != 0 { + f.write_str(", ")?; + } + f.write_str(c)?; + } + + f.write_char(']') + } +} + +enum WordType { + Short, + Medium, + Long, +} + +// https://stackoverflow.com/questions/50788009/how-do-i-get-a-random-line-from-a-file +const FILENAME: &str = "/usr/share/dict/british-english"; + +fn find_word(style: WordType) -> String { + let f = File::open(FILENAME) + .unwrap_or_else(|e| panic!("(;_;) file not found: {}: {}", FILENAME, e)); + let f = BufReader::new(f); + + let lines = f + .lines() + .map(|l| l.expect("readerror")) + .filter(|l| !l.ends_with("'s")) + .filter(|l| match style { + WordType::Short => l.len() < 6, + WordType::Medium => (4..9).contains(&l.len()), + WordType::Long => l.len() > 8, + }); + + lines.choose(&mut rand::thread_rng()).expect("emptyfile") } async fn run_bot( @@ -62,6 +135,9 @@ async fn main() -> Result<(), failure::Error> { let nick = client.current_nickname().to_string(); tokio::spawn(async move { run_bot(stream, &nick, tx.clone()).await }); + let mut rng = thread_rng(); + let mut hangman: Hang = Hang::default(); + while let Some(cmd) = rx.recv().await { match cmd { Bot::Message(msg) => { @@ -107,6 +183,161 @@ async fn main() -> Result<(), failure::Error> { break; } } + Bot::HangGuess(t, w) => { + let lengths: [&str; 4] = ["", "short", "medium", "long"]; + if lengths.contains(&&w[..]) { + if hangman.started { + client + .send_privmsg(t, "A game is already in progress!") + .unwrap(); + continue; + } else { + hangman.started = true; + let style = match w.as_ref() { + "short" => WordType::Short, + "medium" => WordType::Medium, + "long" => WordType::Long, + _ => WordType::Medium, + }; + hangman.word = find_word(style).to_lowercase(); + let replaced: String = hangman + .word + .chars() + .map(|x| match x { + 'a'..='z' => '-', + 'A'..='Z' => '-', + _ => x, + }) + .collect(); + hangman.state = replaced; + client + .send_privmsg( + t, + format!( + "{} {}/7 {}", + &hangman.state, + &hangman.attempts, + PrintCharsNicely(&hangman.guesses) + ), + ) + .unwrap(); + continue; + } + } else if w == hangman.word { + client + .send_privmsg( + t, + format!("A winner is you! The word was {}.", &hangman.word), + ) + .unwrap(); + hangman = Hang::default(); + } + } + Bot::Hang(t, l) => { + if !hangman.started { + continue; + } + + if !hangman.word.contains(&l) { + if hangman.guesses.contains(&l) { + client + .send_privmsg( + t, + format!( + "{} {}/7 {}", + &hangman.state, + &hangman.attempts, + PrintCharsNicely(&hangman.guesses) + ), + ) + .unwrap(); + continue; + } + + hangman.guesses.push(l); + hangman.attempts += 1; + + if hangman.attempts >= 7 { + let n = rng.gen_range(1..100) > 50; + let o: u32 = rng.gen_range(1..100); + + let mut dead: Vec = vec![ + " +---+".to_string(), + " | |".to_string(), + " O |".to_string(), + " /|\\ |".to_string(), + " /`\\ |".to_string(), + " |".to_string(), + "=======".to_string(), + ]; + + if n { + dead[4] = " / \\ |".to_string(); + } + + if o > 95 { + for i in dead { + client.send_privmsg(&t, i).unwrap(); + } + } + + client + .send_privmsg( + t, + format!( + "{} dead, jim! The word was {}.", + if n { "She's" } else { "He's" }, + hangman.word + ), + ) + .unwrap(); + + hangman = Hang::default(); + continue; + } + + client + .send_privmsg( + t, + format!( + "{} {}/7 {}", + &hangman.state, + &hangman.attempts, + PrintCharsNicely(&hangman.guesses) + ), + ) + .unwrap(); + continue; + } + + let indices: Vec<_> = hangman.word.match_indices(&l).collect(); + for i in indices { + hangman.state.replace_range(i.0..i.0 + 1, i.1); + } + + if hangman.state == hangman.word { + client + .send_privmsg( + t, + format!("A winner is you! The word was {}.", &hangman.word), + ) + .unwrap(); + hangman = Hang::default(); + continue; + } + + client + .send_privmsg( + t, + format!( + "{} {}/7 {}", + &hangman.state, + &hangman.attempts, + PrintCharsNicely(&hangman.guesses) + ), + ) + .unwrap(); + } } }