From 93c99a2c88b0962d610ef8c6d306fa762f3844a8 Mon Sep 17 00:00:00 2001 From: Leonid Kozarin Date: Sun, 25 Feb 2024 13:46:46 +0300 Subject: [PATCH] WIP from the laptop --- Cargo.lock | 5 +- Cargo.toml | 3 +- locales/en.yml | 4 + locales/ru.yml | 4 + src/config.rs | 44 +++--- src/handlers/dick.rs | 75 ++-------- src/handlers/dod.rs | 17 +-- src/handlers/inline.rs | 22 +-- src/handlers/mod.rs | 3 +- src/handlers/perks.rs | 95 ++++++++++++ src/handlers/pvp.rs | 5 +- src/handlers/utils/incrementor.rs | 230 ++++++++++++++++++++++++++++++ src/handlers/utils/mod.rs | 2 + src/main.rs | 10 +- src/repo/chats.rs | 10 +- src/repo/dicks.rs | 12 ++ src/repo/loans.rs | 84 +++-------- src/repo/mod.rs | 10 ++ 18 files changed, 441 insertions(+), 194 deletions(-) create mode 100644 src/handlers/perks.rs create mode 100644 src/handlers/utils/incrementor.rs diff --git a/Cargo.lock b/Cargo.lock index 9b1fdf6..b2faba4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -509,6 +509,7 @@ dependencies = [ "futures", "hyper 1.0.1", "log", + "num-traits", "once_cell", "pretty_env_logger", "prometheus", @@ -1462,9 +1463,9 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" +checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" dependencies = [ "autocfg", "libm", diff --git a/Cargo.toml b/Cargo.toml index 6757be0..c1f0a42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,8 @@ chrono = { version = "0.4.31", features = [ "serde" ] } tinytemplate = "1.2.1" base64 = { package = "simple-base64", version = "0.23.2" } byteorder = "1.5.0" -derive_more = { version = "1.0.0-beta.6", features = ["display", "error"] } +derive_more = { version = "1.0.0-beta.6", features = ["display", "error", "constructor", "add_assign"] } +num-traits = "0.2.18" [dev-dependencies] testcontainers = "0.15.0" diff --git a/locales/en.yml b/locales/en.yml index 14d4ad7..92aeed6 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -83,6 +83,10 @@ titles: time_till_next_day: none: " Come back tomorrow!" some: "\n\nNext attempt in %{hours}h %{minutes}m." + perks: + top_line: "The following perks affected the result" + help-pussies: "Deep hole" + loan-payout: "Micro-loaner" errors: not_group_chat: "This bot is supposed to do its mission in group chats only!" feature_disabled: "This feature is currently temporarily disabled." diff --git a/locales/ru.yml b/locales/ru.yml index fd3337c..c51c95a 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -83,6 +83,10 @@ titles: time_till_next_day: none: " Возвращайся завтра!" some: "\n\nСледующая попытка через %{hours} ч. %{minutes} мин." + perks: + top_line: "На результат повлияли следующие перки" + help-pussies: "Глубокая нора" + loan-payout: "Микрозаймер" errors: not_group_chat: "Бот выполняет свою миссию только в групповых чатах!" feature_disabled: "Данная функция пока временно отключена." diff --git a/src/config.rs b/src/config.rs index 275b88a..2818cdf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,19 +1,15 @@ use std::error::Error; use std::fmt::Display; -use std::ops::RangeInclusive; use std::str::FromStr; use anyhow::anyhow; use reqwest::Url; use teloxide::types::Me; +use crate::handlers::utils::Incrementor; use crate::help; #[derive(Clone)] pub struct AppConfig { pub features: FeatureToggles, - pub growth_range: RangeInclusive, - pub grow_shrink_ratio: f32, - pub dod_bonus_range: RangeInclusive, - pub newcomers_grace_days: u32, pub top_limit: u32, } @@ -48,15 +44,10 @@ pub struct BattlesFeatureToggles { impl AppConfig { pub fn from_env() -> Self { - let min = get_value_or_default("GROWTH_MIN", -5); - let max = get_value_or_default("GROWTH_MAX", 10); - let grow_shrink_ratio = get_value_or_default("GROW_SHRINK_RATIO", 0.5); - let max_dod_bonus = get_value_or_default("GROWTH_DOD_BONUS_MAX", 5); - let newcomers_grace_days = get_value_or_default("NEWCOMERS_GRACE_DAYS", 7); - let top_limit = get_value_or_default("TOP_LIMIT", 10); - let chats_merging = get_value_or_default("CHATS_MERGING_ENABLED", false); - let top_unlimited = get_value_or_default("TOP_UNLIMITED_ENABLED", false); - let check_acceptor_length = get_value_or_default("PVP_CHECK_ACCEPTOR_LENGTH", false); + let top_limit = get_env_value_or_default("TOP_LIMIT", 10); + let chats_merging = get_env_value_or_default("CHATS_MERGING_ENABLED", false); + let top_unlimited = get_env_value_or_default("TOP_UNLIMITED_ENABLED", false); + let check_acceptor_length = get_env_value_or_default("PVP_CHECK_ACCEPTOR_LENGTH", false); Self { features: FeatureToggles { chats_merging, @@ -65,10 +56,6 @@ impl AppConfig { check_acceptor_length, } }, - growth_range: min..=max, - grow_shrink_ratio, - dod_bonus_range: 1..=max_dod_bonus, - newcomers_grace_days, top_limit, } } @@ -77,31 +64,32 @@ impl AppConfig { impl DatabaseConfig { pub fn from_env() -> anyhow::Result { Ok(Self { - url: get_mandatory_value("DATABASE_URL")?, - max_connections: get_value_or_default("DATABASE_MAX_CONNECTIONS", 10) + url: get_env_mandatory_value("DATABASE_URL")?, + max_connections: get_env_value_or_default("DATABASE_MAX_CONNECTIONS", 10) }) } } -pub fn build_context_for_help_messages(me: Me, app_config: &AppConfig, competitor_bots: &[&str]) -> anyhow::Result { +pub fn build_context_for_help_messages(me: Me, incr: &Incrementor, competitor_bots: &[&str]) -> anyhow::Result { let other_bots = competitor_bots .iter() .map(|username| ensure_starts_with_at_sign(username.to_string())) .collect::>() .join(", "); + let incr_cfg = incr.get_config(); Ok(help::Context { bot_name: me.username().to_owned(), - grow_min: app_config.growth_range.clone().min().ok_or(anyhow!("growth_range must have min"))?.to_string(), - grow_max: app_config.growth_range.clone().max().ok_or(anyhow!("growth_range must have max"))?.to_string(), + grow_min: incr_cfg.growth_range_min().to_string(), + grow_max: incr_cfg.growth_range_max().to_string(), other_bots, - admin_username: ensure_starts_with_at_sign(get_mandatory_value("HELP_ADMIN_USERNAME")?), - admin_channel: ensure_starts_with_at_sign(get_mandatory_value("HELP_ADMIN_CHANNEL")?), - git_repo: get_mandatory_value("HELP_GIT_REPO")?, + admin_username: ensure_starts_with_at_sign(get_env_mandatory_value("HELP_ADMIN_USERNAME")?), + admin_channel: ensure_starts_with_at_sign(get_env_mandatory_value("HELP_ADMIN_CHANNEL")?), + git_repo: get_env_mandatory_value("HELP_GIT_REPO")?, }) } -fn get_mandatory_value(key: &str) -> anyhow::Result +pub(crate) fn get_env_mandatory_value(key: &str) -> anyhow::Result where T: FromStr, E: Error + Send + Sync + 'static @@ -111,7 +99,7 @@ where .map_err(|e: E| anyhow!(e)) } -fn get_value_or_default(key: &str, default: T) -> T +pub(crate) fn get_env_value_or_default(key: &str, default: T) -> T where T: FromStr + Display, E: Error + Send + Sync + 'static diff --git a/src/handlers/dick.rs b/src/handlers/dick.rs index b8421cd..a6be0f5 100644 --- a/src/handlers/dick.rs +++ b/src/handlers/dick.rs @@ -1,11 +1,8 @@ use std::future::IntoFuture; -use std::ops::RangeInclusive; use anyhow::anyhow; use chrono::{Datelike, Utc}; use futures::future::join; use futures::TryFutureExt; -use rand::Rng; -use rand::rngs::OsRng; use rust_i18n::t; use teloxide::Bot; use teloxide::macros::BotCommands; @@ -14,7 +11,7 @@ use teloxide::types::{CallbackQuery, ChatId, InlineKeyboardButton, InlineKeyboar use page::{InvalidPage, Page}; use crate::handlers::{ensure_lang_code, HandlerResult, reply_html, utils}; use crate::{config, metrics, repo}; -use crate::handlers::utils::page; +use crate::handlers::utils::{Incrementor, page}; use crate::repo::{ChatIdKind, ChatIdPartiality}; const TOMORROW_SQL_CODE: &str = "GD0E1"; @@ -31,14 +28,15 @@ pub enum DickCommands { } pub async fn dick_cmd_handler(bot: Bot, msg: Message, cmd: DickCommands, - repos: repo::Repositories, config: config::AppConfig) -> HandlerResult { + repos: repo::Repositories, incr: Incrementor, + config: config::AppConfig) -> HandlerResult { let from = msg.from().ok_or(anyhow!("unexpected absence of a FROM field"))?; let chat_id = msg.chat.id.into(); let from_refs = FromRefs(from, &chat_id); match cmd { DickCommands::Grow => { metrics::CMD_GROW_COUNTER.chat.inc(); - let answer = grow_impl(&repos, config, from_refs).await?; + let answer = grow_impl(&repos, incr, from_refs).await?; reply_html(bot, msg, answer) }, DickCommands::Top => { @@ -57,26 +55,21 @@ pub async fn dick_cmd_handler(bot: Bot, msg: Message, cmd: DickCommands, pub struct FromRefs<'a>(pub &'a User, pub &'a ChatIdPartiality); -pub(crate) async fn grow_impl(repos: &repo::Repositories, config: config::AppConfig, from_refs: FromRefs<'_>) -> anyhow::Result { +pub(crate) async fn grow_impl(repos: &repo::Repositories, incr: Incrementor, from_refs: FromRefs<'_>) -> anyhow::Result { let (from, chat_id) = (from_refs.0, from_refs.1); let name = utils::get_full_name(from); let user = repos.users.create_or_update(from.id, &name).await?; let days_since_registration = (Utc::now() - user.created_at).num_days() as u32; - let grow_shrink_ratio = if days_since_registration > config.newcomers_grace_days { - config.grow_shrink_ratio - } else { - 1.0 - }; - let increment = gen_increment(config.growth_range, grow_shrink_ratio); - let grow_result = repos.dicks.create_or_grow(from.id, chat_id, increment).await; + let increment = incr.growth_increment(from.id, chat_id.kind(), days_since_registration).await; + let grow_result = repos.dicks.create_or_grow(from.id, chat_id, increment.total).await; let lang_code = ensure_lang_code(Some(from)); let main_part = match grow_result { Ok(repo::GrowthResult { new_length, pos_in_top }) => { - let event_key = if increment.is_negative() { "shrunk" } else { "grown" }; + let event_key = if increment.total.is_negative() { "shrunk" } else { "grown" }; let event = t!(&format!("commands.grow.direction.{event_key}"), locale = &lang_code); let answer = t!("commands.grow.result", locale = &lang_code, - event = event, incr = increment.abs(), length = new_length); + event = event, incr = increment.total.abs(), length = new_length); if let Some(pos) = pos_in_top { let position = t!("commands.grow.position", locale = &lang_code, pos = pos); format!("{answer}\n{position}") @@ -96,8 +89,9 @@ pub(crate) async fn grow_impl(repos: &repo::Repositories, config: config::AppCon } } }; + let perks_part = increment.perks_part_of_answer(&lang_code); let time_left_part = utils::date::get_time_till_next_day_string(&lang_code); - Ok(format!("{main_part}{time_left_part}")) + Ok(format!("{main_part}{perks_part}{time_left_part}")) } pub(crate) struct Top { @@ -249,27 +243,6 @@ pub fn build_pagination_keyboard(page: Page, has_more_pages: bool) -> InlineKeyb InlineKeyboardMarkup::new(vec![buttons]) } -fn gen_increment(range: RangeInclusive, sign_ratio: f32) -> i32 { - let sign_ratio_percent = match (sign_ratio * 100.0).round() as u32 { - ..=0 => 0, - 100.. => 100, - x => x - }; - let mut rng = OsRng; - if range.start() > &0 { - return rng.gen_range(range) - } - let positive = rng.gen_ratio(sign_ratio_percent, 100); - if positive { - let end = *range.end(); - rng.gen_range(1..=end) - } else { - let start = *range.start(); - rng.gen_range(start..=-1) - } - -} - async fn answer_callback_feature_disabled(bot: Bot, q: CallbackQuery, edit_msg_req_params: EditMessageReqParamsKind) -> HandlerResult { let lang_code = ensure_lang_code(Some(&q.from)); @@ -288,29 +261,3 @@ async fn answer_callback_feature_disabled(bot: Bot, q: CallbackQuery, edit_msg_r }; Ok(()) } - -#[cfg(test)] -mod test { - use super::gen_increment; - - #[test] - fn test_gen_increment() { - let increments: Vec = (0..100) - .map(|_| gen_increment(-5..=10, 0.5)) - .collect(); - assert!(increments.iter().any(|n| n > &0)); - assert!(increments.iter().any(|n| n < &0)); - assert!(increments.iter().all(|n| n != &0)); - assert!(increments.iter().all(|n| n <= &10)); - assert!(increments.iter().all(|n| n >= &-5)); - } - - #[test] - fn test_gen_increment_with_positive_range() { - let increments: Vec = (0..100) - .map(|_| gen_increment(5..=10, 0.5)) - .collect(); - assert!(increments.iter().all(|n| n <= &10)); - assert!(increments.iter().all(|n| n >= &5)); - } -} diff --git a/src/handlers/dod.rs b/src/handlers/dod.rs index 7d6210b..a343621 100644 --- a/src/handlers/dod.rs +++ b/src/handlers/dod.rs @@ -1,13 +1,12 @@ use std::borrow::Cow; use anyhow::anyhow; -use rand::Rng; -use rand::rngs::OsRng; use rust_i18n::t; use teloxide::Bot; use teloxide::macros::BotCommands; use teloxide::types::{Message, UserId}; -use crate::{config, metrics, repo}; +use crate::{metrics, repo}; use crate::handlers::{ensure_lang_code, FromRefs, HandlerResult, reply_html, utils}; +use crate::handlers::utils::Incrementor; const DOD_ALREADY_CHOSEN_SQL_CODE: &str = "GD0E2"; @@ -20,23 +19,24 @@ pub enum DickOfDayCommands { } pub async fn dod_cmd_handler(bot: Bot, msg: Message, - repos: repo::Repositories, config: config::AppConfig) -> HandlerResult { + repos: repo::Repositories, incr: Incrementor) -> HandlerResult { metrics::CMD_DOD_COUNTER.chat.inc(); let from = msg.from().ok_or(anyhow!("unexpected absence of a FROM field"))?; let chat_id = msg.chat.id.into(); let from_refs = FromRefs(from, &chat_id); - let answer = dick_of_day_impl(&repos, config, from_refs).await?; + let answer = dick_of_day_impl(&repos, incr, from_refs).await?; reply_html(bot, msg, answer).await?; Ok(()) } -pub(crate) async fn dick_of_day_impl(repos: &repo::Repositories, config: config::AppConfig, from_refs: FromRefs<'_>) -> anyhow::Result { +pub(crate) async fn dick_of_day_impl(repos: &repo::Repositories, incr: Incrementor, from_refs: FromRefs<'_>) -> anyhow::Result { let (from, chat_id) = (from_refs.0, from_refs.1); let lang_code = ensure_lang_code(Some(from)); let winner = repos.users.get_random_active_member(&chat_id.kind()).await?; let answer = match winner { Some(winner) => { - let bonus: u32 = OsRng.gen_range(config.dod_bonus_range); + let increment = incr.dod_increment(from.id, chat_id.kind()).await; + let bonus = increment.total as u32; let dod_result = repos.dicks.set_dod_winner(chat_id, UserId(winner.uid as u64), bonus).await; let main_part = match dod_result { Ok(Some(repo::GrowthResult{ new_length, pos_in_top })) => { @@ -64,8 +64,9 @@ pub(crate) async fn dick_of_day_impl(repos: &repo::Repositories, config: config: } } }; + let perks_part = increment.perks_part_of_answer(&lang_code); let time_left_part = utils::date::get_time_till_next_day_string(&lang_code); - format!("{main_part}{time_left_part}") + format!("{main_part}{perks_part}{time_left_part}") }, None => t!("commands.dod.no_candidates", locale = &lang_code) }; diff --git a/src/handlers/inline.rs b/src/handlers/inline.rs index 70fd40c..d36e368 100644 --- a/src/handlers/inline.rs +++ b/src/handlers/inline.rs @@ -12,6 +12,7 @@ use teloxide::types::*; use teloxide::types::ParseMode::Html; use crate::config::AppConfig; use crate::handlers::{build_pagination_keyboard, dick, dod, ensure_lang_code, FromRefs, HandlerResult, utils}; +use crate::handlers::utils::Incrementor; use crate::handlers::utils::page::Page; use crate::metrics; use crate::repo::{ChatIdFull, NoChatIdError, ChatIdSource, Repositories}; @@ -39,11 +40,11 @@ impl InlineResult { } impl InlineCommand { - async fn execute(&self, repos: &Repositories, config: AppConfig, from_refs: FromRefs<'_>) -> anyhow::Result { + async fn execute(&self, repos: &Repositories, config: AppConfig, incr: Incrementor, from_refs: FromRefs<'_>) -> anyhow::Result { match self { InlineCommand::Grow => { metrics::CMD_GROW_COUNTER.inline.inc(); - dick::grow_impl(repos, config, from_refs) + dick::grow_impl(repos, incr, from_refs) .await .map(InlineResult::text) }, @@ -60,7 +61,7 @@ impl InlineCommand { }, InlineCommand::DickOfDay => { metrics::CMD_DOD_COUNTER.inline.inc(); - dod::dick_of_day_impl(repos, config, from_refs) + dod::dick_of_day_impl(repos, incr, from_refs) .await .map(InlineResult::text) }, @@ -104,7 +105,8 @@ pub async fn inline_handler(bot: Bot, query: InlineQuery, repos: Repositories) - } pub async fn inline_chosen_handler(bot: Bot, result: ChosenInlineResult, - repos: Repositories, config: AppConfig) -> HandlerResult { + repos: Repositories, config: AppConfig, + incr: Incrementor) -> HandlerResult { metrics::INLINE_COUNTER.finished(); let maybe_chat_in_sync = result.inline_message_id.as_ref() @@ -120,7 +122,7 @@ pub async fn inline_chosen_handler(bot: Bot, result: ChosenInlineResult, let cmd = InlineCommand::from_str(&result.result_id)?; let chat_id = chat.try_into().map_err(|e: NoChatIdError| anyhow!(e))?; let from_refs = FromRefs(&result.from, &chat_id); - let inline_result = cmd.execute(&repos, config, from_refs).await?; + let inline_result = cmd.execute(&repos, config, incr, from_refs).await?; let inline_message_id = result.inline_message_id .ok_or("inline_message_id must be set if the chat_in_sync_future exists")?; @@ -135,7 +137,8 @@ pub async fn inline_chosen_handler(bot: Bot, result: ChosenInlineResult, } pub async fn callback_handler(bot: Bot, query: CallbackQuery, - repos: Repositories, config: AppConfig) -> HandlerResult { + repos: Repositories, config: AppConfig, + incr: Incrementor) -> HandlerResult { let lang_code = ensure_lang_code(Some(&query.from)); let mut answer = bot.answer_callback_query(&query.id); @@ -158,7 +161,7 @@ pub async fn callback_handler(bot: Bot, query: CallbackQuery, let parse_res = parse_callback_data(&data, query.from.id); if let Ok(CallbackDataParseResult::Ok(cmd)) = parse_res { let from_refs = FromRefs(&query.from, &chat_id); - let inline_result = cmd.execute(&repos, config, from_refs).await?; + let inline_result = cmd.execute(&repos, config, incr, from_refs).await?; let mut edit = bot.edit_message_text_inline(inline_msg_id, inline_result.text); edit.reply_markup = inline_result.keyboard; edit.parse_mode.replace(Html); @@ -209,10 +212,7 @@ fn parse_callback_data(data: &str, user_id: UserId) -> Result Option { utils::resolve_inline_message_id(msg_id) - .map_err(|e| { - log::error!("couldn't resolve inline_message_id: {e}"); - e - }) + .inspect_err(|e| log::error!("couldn't resolve inline_message_id: {e}")) .ok() .map(|info| ChatId(info.chat_id)) } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 27ff571..49d6eef 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -4,8 +4,9 @@ mod dod; mod import; mod promo; mod inline; -mod utils; +pub mod utils; pub mod pvp; +pub mod perks; use std::borrow::ToOwned; use teloxide::Bot; diff --git a/src/handlers/perks.rs b/src/handlers/perks.rs new file mode 100644 index 0000000..d6cfdca --- /dev/null +++ b/src/handlers/perks.rs @@ -0,0 +1,95 @@ +use async_trait::async_trait; +use derive_more::Constructor; +use num_traits::ToPrimitive; +use sqlx::{Pool, Postgres}; +use crate::handlers::utils::{AdditionalChange, ChangeIntent, DickId, Perk}; +use crate::{config, repo}; +use crate::config::FeatureToggles; + +pub fn all(pool: &Pool, features: FeatureToggles) -> Vec> { + let help_pussies_coeff = config::get_env_value_or_default("HELP_PUSSIES_COEFF", 0.0); + let payout_coefficient = config::get_env_value_or_default("LOAN_WRITEOFF_COEFF", 0.0); + let loans = repo::Loans::new(pool.clone(), features); + + vec![ + Box::new(HelpPussiesPerk { + coefficient: help_pussies_coeff, + }), + Box::new(LoanPayoutPerk { + payout_coefficient, + loans, + }) + ] +} + +#[derive(Clone, Constructor)] +struct HelpPussiesPerk { + coefficient: f64 +} + +#[async_trait] +impl Perk for HelpPussiesPerk { + fn name(&self) -> &'static str { + "help-pussies" + } + + fn enabled(&self) -> bool { + self.coefficient > 0.0 + } + + async fn active(&self, _: &DickId, change_intent: ChangeIntent) -> bool { + change_intent.current_length < 0 + } + + async fn apply(&self, _: &DickId, change_intent: ChangeIntent) -> AdditionalChange { + let current_length = change_intent.current_length.to_f64().expect("conversion is always Some"); + let change = (self.coefficient * current_length).ceil() as i32; + let ac = if change_intent.base_increment.is_positive() { + change + } else { + -change + }; + AdditionalChange(ac) + } +} + +#[derive(Clone, Constructor)] +struct LoanPayoutPerk { + payout_coefficient: f64, + loans: repo::Loans, +} + +#[async_trait] +impl Perk for LoanPayoutPerk { + fn name(&self) -> &'static str { + "loan-payout" + } + + fn enabled(&self) -> bool { + (0.0..=1.0).contains(&self.payout_coefficient) + } + + async fn active(&self, dick_id: &DickId, _: ChangeIntent) -> bool { + self.loans.get_active_loan(dick_id.0, &dick_id.1) + .await + .map(|debt| debt > 0) + .inspect_err(|e| log::error!("couldn't check if a perk is active: {e}")) + .unwrap_or(false) + } + + async fn apply(&self, dick_id: &DickId, change_intent: ChangeIntent) -> AdditionalChange { + let payout = if change_intent.base_increment.is_positive() { + let base_increment = change_intent.base_increment.to_f64().expect("conversion gives always Some"); + (base_increment * self.payout_coefficient).floor() as u16 + } else { + 0 + }; + match self.loans.pay(dick_id.0, dick_id.1.clone(), payout.into()).await { + Ok(()) => AdditionalChange(change_intent.base_increment - i32::from(payout)), + Err(e) => { + log::error!("couldn't pay for the loan ({dick_id}): {e}"); + AdditionalChange(0) + } + } + } +} diff --git a/src/handlers/pvp.rs b/src/handlers/pvp.rs index 2468d68..4657085 100644 --- a/src/handlers/pvp.rs +++ b/src/handlers/pvp.rs @@ -128,10 +128,7 @@ pub async fn callback_handler(bot: Bot, query: CallbackQuery, repos: Repositorie .then_some(query.inline_message_id.as_ref()) .flatten() .and_then(|msg_id| utils::resolve_inline_message_id(msg_id) - .map_err(|e| { - log::error!("couldn't resolve inline_message_id: {e}"); - e - }) + .inspect_err(|e| log::error!("couldn't resolve inline_message_id: {e}")) .ok() ) .map(|info| ChatId(info.chat_id)) diff --git a/src/handlers/utils/incrementor.rs b/src/handlers/utils/incrementor.rs new file mode 100644 index 0000000..2365097 --- /dev/null +++ b/src/handlers/utils/incrementor.rs @@ -0,0 +1,230 @@ +use std::collections::HashMap; +use std::ops::RangeInclusive; +use std::sync::Arc; +use async_trait::async_trait; +use derive_more::{AddAssign, Display}; +use num_traits::{Num}; +use rand::distributions::uniform::SampleUniform; +use rand::Rng; +use rand::rngs::OsRng; +use rust_i18n::t; +use teloxide::types::UserId; +use crate::{config, repo}; +use crate::repo::ChatIdKind; + +#[derive(Clone)] +pub struct Incrementor { + config: Config, + perks: Vec>>, + dicks: repo::Dicks, +} + +#[derive(Clone)] +pub struct Config { + growth_range: RangeInclusive, + grow_shrink_ratio: f32, + newcomers_grace_days: u32, + dod_bonus_range: RangeInclusive, +} + +#[async_trait] +pub trait Perk: Send + Sync { + fn name(&self) -> &'static str; + fn enabled(&self) -> bool; + async fn active(&self, dick_id: &DickId, change_intent: ChangeIntent) -> bool; + async fn apply(&self, dick_id: &DickId, change_intent: ChangeIntent) -> AdditionalChange; +} + +#[derive(Display)] +#[display("(user_id={_0}, chat_id={_1}")] +pub struct DickId(pub(crate) UserId, pub(crate) ChatIdKind); + +#[derive(Copy, Clone)] +pub struct ChangeIntent { + pub current_length: i32, + pub base_increment: i32, +} + +#[derive(Copy, Clone, AddAssign)] +pub struct AdditionalChange(pub i32); + +pub struct Increment { + pub base: T, + pub by_perks: HashMap<&'static str, T>, + pub total: T, +} + +pub type SignedIncrement = Increment; +pub type UnsignedIncrement = Increment; + +impl Config { + pub fn growth_range_min(&self) -> i32 { + self.growth_range.clone() + .min() + .unwrap_or(0) + } + + pub fn growth_range_max(&self) -> i32 { + self.growth_range.clone() + .max() + .unwrap_or(0) + } +} + +impl Incrementor { + pub fn from_env(dicks: &repo::Dicks, perks: Vec>) -> Self { + let growth_range_min = config::get_env_value_or_default("GROWTH_MIN", -5); + let growth_range_max = config::get_env_value_or_default("GROWTH_MAX", 10); + let dod_max_bonus = config::get_env_value_or_default("GROWTH_DOD_BONUS_MAX", 5); + + let perks = perks + .into_iter() + .filter(|perk| perk.enabled()) + .map(Arc::new) + .collect(); + + Self { + config: Config { + growth_range: growth_range_min..=growth_range_max, + grow_shrink_ratio: config::get_env_value_or_default("GROW_SHRINK_RATIO", 0.5), + newcomers_grace_days: config::get_env_value_or_default("NEWCOMERS_GRACE_DAYS", 7), + dod_bonus_range: 1..=dod_max_bonus, + }, + perks, + dicks: dicks.clone(), + } + } + + pub fn get_config(&self) -> Config { + self.config.clone() + } + + pub async fn growth_increment(&self, user_id: UserId, chat_id: ChatIdKind, days_since_registration: u32) -> SignedIncrement { + let dick_id = DickId(user_id, chat_id); + let grow_shrink_ratio = if days_since_registration > self.config.newcomers_grace_days { + self.config.grow_shrink_ratio + } else { + 1.0 + }; + let base_incr = get_base_increment(self.config.growth_range.clone(), grow_shrink_ratio); + self.add_additional_incr(dick_id, base_incr).await + } + + pub async fn dod_increment(&self, user_id: UserId, chat_id: ChatIdKind) -> UnsignedIncrement { + let dick_id = DickId(user_id, chat_id); + let base_incr = OsRng.gen_range(self.config.dod_bonus_range.clone()); + self.add_additional_incr(dick_id, base_incr).await + } + + async fn add_additional_incr(&self, dick: DickId, base_increment: T) -> Increment + where + T: Num + Copy + std::fmt::Display + Into + TryFrom, + >::Error: std::fmt::Debug + { + let current_length = match self.dicks.fetch_length(dick.0, &dick.1).await { + Ok(length) => length, + Err(e) => { + log::error!("couldn't fetch the length of a dick: {e}"); + return Increment::base_only(base_increment) + } + }; + let change_intent = ChangeIntent { + base_increment: base_increment.into(), + current_length + }; + + let mut additional_change = AdditionalChange(0); + let mut by_perks = HashMap::new(); + for perk in self.perks.iter() { + if perk.active(&dick, change_intent).await { + let ac = perk.apply(&dick, change_intent).await; + let v = T::try_from(ac.0).expect("TODO: fix numeric types!"); // TODO: fix numeric types! + by_perks.insert(perk.name(), v); + additional_change += ac + } + } + Increment { + base: base_increment, + by_perks, + total: T::try_from(change_intent.base_increment + additional_change.0).expect("TODO: fix numeric types!") + } + } +} + +impl Increment { + fn base_only(base: T) -> Self { + Self { + base, + total: base, + by_perks: HashMap::default(), + } + } + + pub fn perks_part_of_answer(&self, lang_code: &str) -> String { + if self.base != self.total { + let top_line = t!("titles.perks.top_line", locale = lang_code); + let perks = self.by_perks.iter() + .map(|(perk, value)| { + let name = t!(perk, locale = lang_code); + format!("— {name} ({value})") + }) + .collect::>() + .join("\n"); + format!("\n{top_line}:\n{perks}") + } else { + String::default() + } + } +} + +fn get_base_increment(range: RangeInclusive, sign_ratio: f32) -> T +where + T: Num + Copy + PartialOrd + SampleUniform + From +{ + let sign_ratio_percent = match (sign_ratio * 100.0).round() as u32 { + ..=0 => 0, + 100.. => 100, + x => x + }; + let mut rng = OsRng; + let zero = T::from(0); + if range.start() > &zero { + return rng.gen_range(range) + } + let positive = rng.gen_ratio(sign_ratio_percent, 100); + if positive { + let end = *range.end(); + let one = T::from(1); + rng.gen_range(one..=end) + } else { + let start = *range.start(); + let minus_one = T::from(-1); + rng.gen_range(start..=minus_one) + } +} + +#[cfg(test)] +mod test { + use super::get_base_increment; + + #[test] + fn test_gen_increment() { + let increments: Vec = (0..100) + .map(|_| get_base_increment(-5..=10, 0.5)) + .collect(); + assert!(increments.iter().any(|n| n > &0)); + assert!(increments.iter().any(|n| n < &0)); + assert!(increments.iter().all(|n| n != &0)); + assert!(increments.iter().all(|n| n <= &10)); + assert!(increments.iter().all(|n| n >= &-5)); + } + + #[test] + fn test_gen_increment_with_positive_range() { + let increments: Vec = (0..100) + .map(|_| get_base_increment(5..=10, 0.5)) + .collect(); + assert!(increments.iter().all(|n| n <= &10)); + assert!(increments.iter().all(|n| n >= &5)); + } +} diff --git a/src/handlers/utils/mod.rs b/src/handlers/utils/mod.rs index 34a4191..10603e7 100644 --- a/src/handlers/utils/mod.rs +++ b/src/handlers/utils/mod.rs @@ -1,7 +1,9 @@ pub mod page; mod tghack; +mod incrementor; pub use tghack::*; +pub use incrementor::*; use teloxide::types::User; diff --git a/src/main.rs b/src/main.rs index e888cd0..5172625 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ async fn main() -> Result<(), Box> { let app_config = config::AppConfig::from_env(); let database_config = config::DatabaseConfig::from_env()?; + let db_conn = repo::establish_database_connection(&database_config).await?; let handler = dptree::entry() .branch(Update::filter_message().filter_command::().endpoint(handlers::help_cmd_handler)) @@ -65,7 +66,10 @@ async fn main() -> Result<(), Box> { } let me = bot.get_me().await?; - let help_context = config::build_context_for_help_messages(me, &app_config, &handlers::ORIGINAL_BOT_USERNAMES)?; + let repos = repo::Repositories::new(&db_conn, app_config.features); + let perks = handlers::perks::all(&db_conn, app_config.features); + let incrementor = handlers::utils::Incrementor::from_env(&repos.dicks, perks); + let help_context = config::build_context_for_help_messages(me, &incrementor, &handlers::ORIGINAL_BOT_USERNAMES)?; let help_container = help::render_help_messages(help_context)?; let webhook_url: Option = match std::env::var(ENV_WEBHOOK_URL) { @@ -77,10 +81,10 @@ async fn main() -> Result<(), Box> { let addr = SocketAddr::from(([0, 0, 0, 0], 8080)); let metrics_router = metrics::init(); - let db_conn = repo::establish_database_connection(&database_config).await?; let ignore_unknown_updates = |_| Box::pin(async {}); let deps = deps![ - repo::Repositories::new(&db_conn, app_config.features), + repos, + incrementor, app_config, help_container ]; diff --git a/src/repo/chats.rs b/src/repo/chats.rs index 84afa76..d9c471e 100644 --- a/src/repo/chats.rs +++ b/src/repo/chats.rs @@ -1,9 +1,8 @@ use std::fmt::Formatter; use anyhow::anyhow; use sqlx::{Postgres, Transaction}; -use sqlx::postgres::PgQueryResult; use teloxide::types::ChatId; -use super::{ChatIdFull, ChatIdKind, ChatIdPartiality, ChatIdSource}; +use super::{ChatIdFull, ChatIdKind, ChatIdPartiality, ChatIdSource, ensure_only_one_row_updated}; use crate::repository; #[derive(sqlx::FromRow, Debug, Clone)] @@ -165,13 +164,6 @@ fn merge_chat_objects<'a>(chats: &'a [&Chat; 2]) -> Result, } } -fn ensure_only_one_row_updated(res: PgQueryResult) -> Result { - match res.rows_affected() { - 1 => Ok(res), - x => Err(anyhow!("not only one row was updated but {x}")) - } -} - #[cfg(test)] mod tests { use super::{Chat, merge_chat_objects}; diff --git a/src/repo/dicks.rs b/src/repo/dicks.rs index 3badd48..3aae979 100644 --- a/src/repo/dicks.rs +++ b/src/repo/dicks.rs @@ -47,6 +47,18 @@ impl Dicks { Ok(GrowthResult { new_length, pos_in_top }) } + pub async fn fetch_length(&self, uid: UserId, chat_id: &ChatIdKind) -> anyhow::Result { + sqlx::query_scalar!("SELECT d.length FROM Dicks d \ + JOIN Chats c ON d.chat_id = c.id \ + WHERE uid = $1 AND \ + c.chat_id = $2::bigint OR c.chat_instance = $2::text", + uid.0 as i64, chat_id.value() as String) + .fetch_optional(&self.pool) + .await + .map(Option::unwrap_or_default) + .map_err(Into::into) + } + pub async fn get_top(&self, chat_id: &ChatIdKind, offset: u32, limit: u32) -> anyhow::Result, sqlx::Error> { sqlx::query_as!(Dick, r#"SELECT length, name as owner_name, updated_at as grown_at, diff --git a/src/repo/loans.rs b/src/repo/loans.rs index bcd16e4..8bf138b 100644 --- a/src/repo/loans.rs +++ b/src/repo/loans.rs @@ -1,73 +1,31 @@ -use std::collections::HashMap; -use std::sync::Arc; +use anyhow::anyhow; use teloxide::types::UserId; -use tokio::sync::RwLock; -use crate::config::FeatureToggles; -use crate::repo; -use crate::repo::{ChatIdKind, ChatIdPartiality}; +use crate::repository; +use crate::repo::{ChatIdKind, ensure_only_one_row_updated}; -#[derive(Clone)] -pub struct Loans { - pool: sqlx::Pool, - features: FeatureToggles, - chat_id_cache: Arc>>, - chats_repo: repo::Chats, -} - -impl Loans { - pub fn new(pool: sqlx::Pool, features: FeatureToggles) -> Self { - Self { - chats_repo: repo::Chats::new(pool.clone(), features), - pool, - features, - chat_id_cache: Arc::new(RwLock::new(HashMap::new())), - } - } - - pub async fn get_active_loan(&self, uid: UserId, chat_id: ChatIdPartiality) -> anyhow::Result { - let internal_chat_id = match self.get_internal_chat_id(chat_id.kind()).await? { - Some(id) => id, - None => return Ok(Default::default()) - }; - sqlx::query_scalar!("SELECT left_to_pay FROM loans WHERE uid = $1 AND chat_id = $2 AND repaid_at IS NULL", - uid.0 as i64, internal_chat_id) +repository!(Loans, + pub async fn get_active_loan(&self, uid: UserId, chat_id: &ChatIdKind) -> anyhow::Result { + sqlx::query_scalar!("SELECT left_to_pay FROM loans \ + WHERE uid = $1 AND \ + chat_id = (SELECT id FROM Chats WHERE chat_id = $2::bigint OR chat_instance = $2::text) \ + AND repaid_at IS NULL", + uid.0 as i64, chat_id.value() as String) .fetch_optional(&self.pool) .await .map(|maybe_loan| maybe_loan.map(|value| value as u32).unwrap_or_default()) .map_err(|e| e.into()) } - - pub async fn pay(&self, uid: UserId, chat_id: ChatIdPartiality, value: u32) -> anyhow::Result<()> { - let internal_chat_id = match self.get_internal_chat_id(chat_id.kind()).await? { - Some(id) => id, - None => return Ok(()) // TODO: check or logging? - }; - sqlx::query!("UPDATE Loans SET left_to_pay = left_to_pay - $3 WHERE uid = $1 AND chat_id = $2 AND repaid_at IS NULL", - uid.0 as i64, internal_chat_id, value as i64) +, + pub async fn pay(&self, uid: UserId, chat_id: ChatIdKind, value: u32) -> anyhow::Result<()> { + sqlx::query!("UPDATE Loans SET left_to_pay = left_to_pay - $3 \ + WHERE uid = $1 AND \ + chat_id = (SELECT id FROM Chats WHERE chat_id = $2::bigint OR chat_instance = $2::text) \ + AND repaid_at IS NULL", + uid.0 as i64, chat_id.value() as String, value as i64) .execute(&self.pool) .await - .map(|_| ()) - .map_err(|e| e.into()) - } - - async fn get_internal_chat_id(&self, chat_id: ChatIdKind) -> anyhow::Result> { - let maybe_internal_id = self.chat_id_cache - .read().await - .get(&chat_id).copied(); - let internal_id = match maybe_internal_id { - None => { - let maybe_id = self.chats_repo.get_chat(chat_id.clone()) - .await? - .map(|chat| chat.internal_id); - if let Some(id) = maybe_id { - self.chat_id_cache - .write().await - .insert(chat_id, id); - } - maybe_id - } - Some(id) => Some(id) - }; - Ok(internal_id) + .map_err(|e| anyhow!(e)) + .and_then(ensure_only_one_row_updated)?; + Ok(()) } -} +); diff --git a/src/repo/mod.rs b/src/repo/mod.rs index e94ca70..b42fd7b 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -8,13 +8,16 @@ mod loans; #[cfg(test)] pub(crate) mod test; +use anyhow::anyhow; use sqlx::{Pool, Postgres}; +use sqlx::postgres::PgQueryResult; use teloxide::types::ChatId; pub use users::*; pub use dicks::*; pub use chats::*; pub use import::*; pub use promo::*; +pub use loans::*; use crate::config::{DatabaseConfig, FeatureToggles}; #[derive(Clone)] @@ -168,3 +171,10 @@ macro_rules! repository { } }; } + +fn ensure_only_one_row_updated(res: PgQueryResult) -> Result { + match res.rows_affected() { + 1 => Ok(res), + x => Err(anyhow!("not only one row was updated but {x}")) + } +}