From 0a5b9cc94e369d8bf132c50d01a3ea3ad2ec5142 Mon Sep 17 00:00:00 2001 From: Shagnor Date: Fri, 20 Jan 2023 23:08:40 +1100 Subject: [PATCH] Implement grinding. --- .../message_handler/user_commands/attack.rs | 21 +- .../message_handler/user_commands/movement.rs | 36 +-- blastmud_game/src/models/item.rs | 111 ++++++++- blastmud_game/src/models/user.rs | 7 +- blastmud_game/src/services.rs | 26 +-- blastmud_game/src/services/skills.rs | 219 ++++++++++++++++++ .../src/static_content/npc/statbot.rs | 52 +++-- blastmud_game/src/static_content/room.rs | 2 +- scripts/statgen.hs | 37 +++ 9 files changed, 435 insertions(+), 76 deletions(-) create mode 100644 blastmud_game/src/services/skills.rs create mode 100644 scripts/statgen.hs diff --git a/blastmud_game/src/message_handler/user_commands/attack.rs b/blastmud_game/src/message_handler/user_commands/attack.rs index 60cf3fd..d638f44 100644 --- a/blastmud_game/src/message_handler/user_commands/attack.rs +++ b/blastmud_game/src/message_handler/user_commands/attack.rs @@ -2,11 +2,24 @@ use super::{VerbContext, UserVerb, UserVerbRef, UResult, user_error, get_player_item_or_fail, search_item_for_user}; use async_trait::async_trait; use ansi::ansi; -use crate::services::broadcast_to_room; -use crate::db::{DBTrans, ItemSearchParams}; -use crate::models::{item::{Item, LocationActionType, Subattack}}; +use std::time; +use crate::{ + services::broadcast_to_room, + db::{DBTrans, ItemSearchParams}, + models::{item::{Item, LocationActionType, Subattack}}, + regular_tasks::{TaskRunContext, TaskHandler}, + DResult +}; use async_recursion::async_recursion; +pub struct AttackTaskHandler; +#[async_trait] +impl TaskHandler for AttackTaskHandler { + async fn do_task(&self, _ctx: &mut TaskRunContext) -> DResult> { + todo!("AttackTaskHandler"); + } +} + pub async fn stop_attacking(trans: &DBTrans, by_whom: &Item, to_whom: &Item) -> UResult<()> { let mut new_to_whom = (*to_whom).clone(); if let Some(ac) = new_to_whom.active_combat.as_mut() { @@ -62,7 +75,7 @@ pub async fn start_attack(trans: &DBTrans, by_whom: &Item, to_whom: &Item) -> UR verb, &to_whom.display_for_sentence(false, 1, false)) ); - broadcast_to_room(trans, &by_whom.location, None, &msg_exp, Some(msg_nonexp.as_str())).await?; + broadcast_to_room(trans, &by_whom.location, None::<&Item>, &msg_exp, Some(msg_nonexp.as_str())).await?; let mut by_whom_for_update = by_whom.clone(); by_whom_for_update.active_combat.get_or_insert_with(|| Default::default()).attacking = diff --git a/blastmud_game/src/message_handler/user_commands/movement.rs b/blastmud_game/src/message_handler/user_commands/movement.rs index 49de610..639063e 100644 --- a/blastmud_game/src/message_handler/user_commands/movement.rs +++ b/blastmud_game/src/message_handler/user_commands/movement.rs @@ -22,7 +22,7 @@ use crate::{ }, services::{ broadcast_to_room, - skill_check + skills::skill_check_and_grind } }; use std::time; @@ -54,11 +54,11 @@ pub async fn announce_move(trans: &DBTrans, character: &Item, leaving: &Item, ar pub async fn attempt_move_immediate( trans: &DBTrans, - mover: &Item, + orig_mover: &Item, direction: &Direction, mut player_ctx: Option<&mut VerbContext<'_>> ) -> UResult<()> { - let (heretype, herecode) = mover.location.split_once("/").unwrap_or(("room", "repro_xv_chargen")); + let (heretype, herecode) = orig_mover.location.split_once("/").unwrap_or(("room", "repro_xv_chargen")); if heretype != "room" { // Fix this when we have planes / boats / roomkits. user_error("Navigating outside rooms not yet supported.".to_owned())? @@ -68,11 +68,12 @@ pub async fn attempt_move_immediate( let exit = room.exits.iter().find(|ex| ex.direction == *direction) .ok_or_else(|| UserError("There is nothing in that direction".to_owned()))?; + let mut mover = (*orig_mover).clone(); match exit.exit_type { ExitType::Free => {} ExitType::Blocked(blocker) => { if let Some(ctx) = player_ctx.as_mut() { - if !blocker.attempt_exit(ctx, &mover, exit).await? { + if !blocker.attempt_exit(ctx, &mut mover, exit).await? { user_error("Stopping movement".to_owned())?; } } @@ -87,12 +88,12 @@ pub async fn attempt_move_immediate( Some(old_victim) => { if let Some((vcode, vtype)) = old_victim.split_once("/") { if let Some(vitem) = trans.find_item_by_type_code(vcode, vtype).await? { - stop_attacking(trans, mover, &vitem).await?; + stop_attacking(trans, &mover, &vitem).await?; } } } } - match mover.active_combat.as_ref().map(|ac| &ac.attacked_by[..]) { + match mover.active_combat.clone().as_ref().map(|ac| &ac.attacked_by[..]) { None | Some([]) => {} Some(attackers) => { let mut attacker_names = Vec::new(); @@ -109,26 +110,31 @@ pub async fn attempt_move_immediate( } let attacker_names_ref = attacker_names.iter().map(|n| n.as_str()).collect::>(); let attacker_names_str = language::join_words(&attacker_names_ref[..]); - if skill_check(mover, &SkillType::Dodge, attackers.len() as i64) >= 0.0 { + if skill_check_and_grind(trans, &mut mover, &SkillType::Dodge, attackers.len() as f64 + 8.0).await? >= 0.0 { if let Some(ctx) = player_ctx.as_ref() { trans.queue_for_session(ctx.session, Some(&format!("You successfully get away from {}\n", &attacker_names_str))).await?; for item in &attacker_items[..] { - stop_attacking(trans, &item, mover).await?; + stop_attacking(trans, &item, &mover).await?; } } } else { - user_error(format!("You try and fail to run past {}", &attacker_names_str))?; + if let Some(ctx) = player_ctx.as_ref() { + trans.queue_for_session(ctx.session, + Some(&format!("You try and fail to run past {}\n", + &attacker_names_str))).await?; + } + trans.save_item_model(&mover).await?; + return Ok(()); } } } - let mut new_mover = (*mover).clone(); - new_mover.location = format!("{}/{}", "room", new_room.code); - new_mover.action_type = LocationActionType::Normal; - new_mover.active_combat = None; - trans.save_item_model(&new_mover).await?; + mover.location = format!("{}/{}", "room", new_room.code); + mover.action_type = LocationActionType::Normal; + mover.active_combat = None; + trans.save_item_model(&mover).await?; if let Some(ctx) = player_ctx { look::VERB.handle(ctx, "look", "").await?; @@ -136,7 +142,7 @@ pub async fn attempt_move_immediate( if let Some(old_room_item) = trans.find_item_by_type_code("room", room.code).await? { if let Some(new_room_item) = trans.find_item_by_type_code("room", new_room.code).await? { - announce_move(&trans, &new_mover, &old_room_item, &new_room_item).await?; + announce_move(&trans, &mover, &old_room_item, &new_room_item).await?; } } diff --git a/blastmud_game/src/models/item.rs b/blastmud_game/src/models/item.rs index e429e52..9dbf2a7 100644 --- a/blastmud_game/src/models/item.rs +++ b/blastmud_game/src/models/item.rs @@ -12,19 +12,19 @@ pub enum BuffCause { #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] pub enum BuffImpact { ChangeStat { stat: StatType, magnitude: i16 }, - ChangeSkill { stat: StatType, magnitude: i16 } + ChangeSkill { skill: SkillType, magnitude: i16 } } #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] pub struct Buff { - description: String, - cause: BuffCause, - impacts: Vec + pub description: String, + pub cause: BuffCause, + pub impacts: Vec } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum SkillType { - Apraise, + Appraise, Blades, Bombs, Chemistry, @@ -35,6 +35,7 @@ pub enum SkillType { Fish, Fists, Flails, + Focus, Fuck, Hack, Locksmith, @@ -58,6 +59,86 @@ pub enum SkillType { Whips } +impl SkillType { + pub fn values() -> Vec { + use SkillType::*; + vec!( + Appraise, + Blades, + Bombs, + Chemistry, + Climb, + Clubs, + Craft, + Dodge, + Fish, + Fists, + Flails, + Focus, + Fuck, + Hack, + Locksmith, + Medic, + Persuade, + Pilot, + Pistols, + Quickdraw, + Repair, + Ride, + Rifles, + Scavenge, + Science, + Sneak, + Spears, + Swim, + Teach, + Throw, + Track, + Wrestle, + Whips + ) + } + pub fn display(&self) -> &'static str { + use SkillType::*; + match self { + Appraise => "appraise", + Blades => "blades", + Bombs => "bombs", + Chemistry => "chemistry", + Climb => "climb", + Clubs => "clubs", + Craft => "craft", + Dodge => "dodge", + Fish => "fish", + Fists => "fists", + Flails => "flails", + Focus => "focus", + Fuck => "fuck", + Hack => "hack", + Locksmith => "locksmith", + Medic => "medic", + Persuade => "persuade", + Pilot => "pilot", + Pistols => "pistols", + Quickdraw => "quickdraw", + Repair => "repair", + Ride => "ride", + Rifles => "rifles", + Scavenge => "scavenge", + Science => "science", + Sneak => "sneak", + Spears => "spears", + Swim => "swim", + Teach => "teach", + Throw => "throw", + Track => "track", + Wrestle => "wrestle", + Whips => "whips" + } + } +} + + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum StatType { Brains, @@ -68,6 +149,20 @@ pub enum StatType { Cool } +impl StatType { + pub fn values() -> Vec { + use StatType::*; + vec!( + Brains, + Senses, + Brawn, + Reflexes, + Endurance, + Cool + ) + } +} + #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] pub struct Pronouns { pub subject: String, @@ -175,7 +270,7 @@ impl Default for ActiveCombat { } } -#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, PartialOrd)] #[serde(default)] pub struct Item { pub item_code: String, @@ -192,8 +287,8 @@ pub struct Item { pub is_challenge_attack_only: bool, pub total_xp: u64, - pub total_stats: BTreeMap, - pub total_skills: BTreeMap, + pub total_stats: BTreeMap, + pub total_skills: BTreeMap, pub temporary_buffs: Vec, pub pronouns: Pronouns, pub flags: Vec, diff --git a/blastmud_game/src/models/user.rs b/blastmud_game/src/models/user.rs index 5fedcd7..c9f8988 100644 --- a/blastmud_game/src/models/user.rs +++ b/blastmud_game/src/models/user.rs @@ -19,6 +19,7 @@ pub struct UserExperienceData { } #[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(default)] pub struct User { pub username: String, pub password_hash: String, // bcrypted. @@ -31,8 +32,9 @@ pub struct User { pub terms: UserTermData, pub experience: UserExperienceData, - pub raw_skills: BTreeMap, - pub raw_stats: BTreeMap, + pub raw_skills: BTreeMap, + pub raw_stats: BTreeMap, + pub last_skill_improve: BTreeMap>, // Reminder: Consider backwards compatibility when updating this. New fields should generally // be an Option, or things will crash out for existing sessions. } @@ -74,6 +76,7 @@ impl Default for User { experience: UserExperienceData::default(), raw_skills: BTreeMap::new(), raw_stats: BTreeMap::new(), + last_skill_improve: BTreeMap::new(), } } } diff --git a/blastmud_game/src/services.rs b/blastmud_game/src/services.rs index 9b85c7a..2881f56 100644 --- a/blastmud_game/src/services.rs +++ b/blastmud_game/src/services.rs @@ -1,9 +1,11 @@ use crate::{ - models::item::{Item, SkillType}, db::DBTrans, - DResult + DResult, + models::item::Item }; -use rand::{self, Rng}; + +pub mod skills; + pub async fn broadcast_to_room(trans: &DBTrans, location: &str, from_item: Option<&Item>, message_explicit_ok: &str, message_nonexplicit: Option<&str>) -> DResult<()> { for item in trans.find_items_by_location(location).await? { @@ -22,21 +24,3 @@ pub async fn broadcast_to_room(trans: &DBTrans, location: &str, from_item: Optio } Ok(()) } - -// Rolls the die to determine if a player pulls off something that requires a skill. -// It is a number between -1 and 1. -// Non-negative numbers mean they pulled it off, positive mean they didn't, with -// more positive numbers meaning they did a better job, and more negative numbers -// meaning it went really badly. -// If level = raw skill, there is a 50% chance of succeeding. -// level = raw skill + 1, there is a 75% chance of succeeding. -// level = raw skill - 1, there is a 25% chance of succeeding. -// Past those differences, it follows the logistic function: -// Difference: -5 -4 -3 -2 -1 0 1 2 3 4 5 -// Probability: 0.4% 1.2% 3.5% 10% 25% 50% 75% 90% 96% 99% 99.6% -pub fn skill_check(who: &Item, skill: &SkillType, level: i64) -> f64 { - let user_level = who.total_skills.get(skill).unwrap_or(&0); - let level_gap = level - user_level.clone() as i64; - const K: f64 = 1.0986122886681098; // log 3 - rand::thread_rng().gen::() - 1.0 / (1.0 + (-K * (level_gap as f64)).exp()) -} diff --git a/blastmud_game/src/services/skills.rs b/blastmud_game/src/services/skills.rs new file mode 100644 index 0000000..0bb42c4 --- /dev/null +++ b/blastmud_game/src/services/skills.rs @@ -0,0 +1,219 @@ +use crate::{ + models::{ + item::{Item, SkillType, StatType, BuffImpact}, + user::User + }, + db::DBTrans, + DResult, +}; +use rand::{self, Rng}; +use chrono::Utc; +use std::collections::BTreeMap; + +pub fn calculate_total_stats_skills_for_user(target_item: &mut Item, user: &User) { + target_item.total_stats = BTreeMap::new(); + // 1: Start with total stats = raw stats + for stat_type in StatType::values() { + target_item.total_stats.insert(stat_type.clone(), + *user.raw_stats.get(&stat_type).unwrap_or(&0.0)); + } + // 2: Apply stat (de)buffs... + for buff in &target_item.temporary_buffs { + for impact in &buff.impacts { + match impact { + BuffImpact::ChangeStat { stat, magnitude } => { + target_item.total_stats.entry(stat.clone()) + .and_modify(|old_value| *old_value = (*old_value + magnitude.clone() as f64).max(0.0)) + .or_insert((*magnitude).max(0) as f64); + } + _ => {} + } + } + } + // 3: Total skills = raw skills + target_item.total_skills = BTreeMap::new(); + for skill_type in SkillType::values() { + target_item.total_skills.insert(skill_type.clone(), + *user.raw_skills.get(&skill_type).unwrap_or(&0.0)); + } + // 4: Adjust skills by stats + let brn = *target_item.total_stats.get(&StatType::Brains).unwrap_or(&0.0); + let sen = *target_item.total_stats.get(&StatType::Senses).unwrap_or(&0.0); + let brw = *target_item.total_stats.get(&StatType::Brawn).unwrap_or(&0.0); + let refl = *target_item.total_stats.get(&StatType::Reflexes).unwrap_or(&0.0); + let end = *target_item.total_stats.get(&StatType::Endurance).unwrap_or(&0.0); + let col = *target_item.total_stats.get(&StatType::Cool).unwrap_or(&0.0); + target_item.total_skills.entry(SkillType::Appraise) + .and_modify(|sk| *sk += brn * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Appraise) + .and_modify(|sk| *sk += sen * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Blades) + .and_modify(|sk| *sk += refl * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Blades) + .and_modify(|sk| *sk += col * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Bombs) + .and_modify(|sk| *sk += brn * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Bombs) + .and_modify(|sk| *sk += col * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Chemistry) + .and_modify(|sk| *sk += brn).or_insert(brn); + target_item.total_skills.entry(SkillType::Climb) + .and_modify(|sk| *sk += refl * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Climb) + .and_modify(|sk| *sk += end * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Clubs) + .and_modify(|sk| *sk += brw * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Clubs) + .and_modify(|sk| *sk += refl * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Craft) + .and_modify(|sk| *sk += brn).or_insert(brn); + target_item.total_skills.entry(SkillType::Dodge) + .and_modify(|sk| *sk += sen * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Dodge) + .and_modify(|sk| *sk += refl * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Fish) + .and_modify(|sk| *sk += end * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Fish) + .and_modify(|sk| *sk += col * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Fists) + .and_modify(|sk| *sk += brw * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Fists) + .and_modify(|sk| *sk += end * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Focus) + .and_modify(|sk| *sk += sen * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Focus) + .and_modify(|sk| *sk += end * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Fuck) + .and_modify(|sk| *sk += sen * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Fuck) + .and_modify(|sk| *sk += end * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Hack) + .and_modify(|sk| *sk += brn).or_insert(brn); + target_item.total_skills.entry(SkillType::Locksmith) + .and_modify(|sk| *sk += brn * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Locksmith) + .and_modify(|sk| *sk += refl * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Medic) + .and_modify(|sk| *sk += brn).or_insert(brn); + target_item.total_skills.entry(SkillType::Persuade) + .and_modify(|sk| *sk += brn * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Persuade) + .and_modify(|sk| *sk += col * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Pilot) + .and_modify(|sk| *sk += brn * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Pilot) + .and_modify(|sk| *sk += refl * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Pistols) + .and_modify(|sk| *sk += refl * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Pistols) + .and_modify(|sk| *sk += col * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Quickdraw) + .and_modify(|sk| *sk += refl * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Quickdraw) + .and_modify(|sk| *sk += col * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Repair) + .and_modify(|sk| *sk += brn).or_insert(brn); + target_item.total_skills.entry(SkillType::Rifles) + .and_modify(|sk| *sk += refl * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Rifles) + .and_modify(|sk| *sk += col * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Scavenge) + .and_modify(|sk| *sk += sen * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Scavenge) + .and_modify(|sk| *sk += end * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Science) + .and_modify(|sk| *sk += brn).or_insert(brn); + target_item.total_skills.entry(SkillType::Sneak) + .and_modify(|sk| *sk += sen * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Sneak) + .and_modify(|sk| *sk += col * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Spears) + .and_modify(|sk| *sk += refl * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Spears) + .and_modify(|sk| *sk += end * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Swim) + .and_modify(|sk| *sk += end).or_insert(brn); + target_item.total_skills.entry(SkillType::Teach) + .and_modify(|sk| *sk += brn).or_insert(brn); + target_item.total_skills.entry(SkillType::Throw) + .and_modify(|sk| *sk += sen * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Throw) + .and_modify(|sk| *sk += brw * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Track) + .and_modify(|sk| *sk += sen).or_insert(brn); + target_item.total_skills.entry(SkillType::Whips) + .and_modify(|sk| *sk += sen * 0.5).or_insert(brn * 0.5); + target_item.total_skills.entry(SkillType::Whips) + .and_modify(|sk| *sk += refl * 0.5).or_insert(brn * 0.5); + // 5: Apply skill (de)buffs... + for buff in &target_item.temporary_buffs { + for impact in &buff.impacts { + match impact { + BuffImpact::ChangeSkill { skill, magnitude } => { + target_item.total_skills.entry(skill.clone()) + .and_modify(|old_value| *old_value = (*old_value + magnitude.clone() as f64).max(0.0)) + .or_insert((*magnitude).max(0) as f64); + } + _ => {} + } + } + } +} + +pub fn calc_level_gap(who: &Item, skill: &SkillType, diff_level: f64) -> f64 { + let user_level = who.total_skills.get(skill).unwrap_or(&0.0); + diff_level - user_level.clone() +} + +pub fn skill_check_fn(level_gap: f64) -> f64 { + const K: f64 = 1.0986122886681098; // log 3 + rand::thread_rng().gen::() - 1.0 / (1.0 + (-K * (level_gap as f64)).exp()) +} + +// Rolls the die to determine if a player pulls off something that requires a skill. +// It is a number between -1 and 1. +// Non-negative numbers mean they pulled it off, positive mean they didn't, with +// more positive numbers meaning they did a better job, and more negative numbers +// meaning it went really badly. +// If level = raw skill, there is a 50% chance of succeeding. +// level = raw skill + 1, there is a 75% chance of succeeding. +// level = raw skill - 1, there is a 25% chance of succeeding. +// Past those differences, it follows the logistic function: +// Difference: -5 -4 -3 -2 -1 0 1 2 3 4 5 +// Probability: 0.4% 1.2% 3.5% 10% 25% 50% 75% 90% 96% 99% 99.6% +#[allow(unused)] +pub fn skill_check_only(who: &Item, skill: &SkillType, diff_level: f64) -> f64 { + skill_check_fn(calc_level_gap(who, skill, diff_level)) +} + +// Note: Caller must save who because skills might update. +// Don't return error if skillcheck fails, it can fail but still grind. +pub async fn skill_check_and_grind(trans: &DBTrans, who: &mut Item, skill: &SkillType, diff_level: f64) -> DResult { + let gap = calc_level_gap(who, skill, diff_level); + let result = skill_check_fn(gap); + + // If the skill gap is 0, probability of learning is 0.5 + // If the skill gap is 1, probability of learning is 0.25, and so on (exponential decrease). + const LAMBDA: f64 = -0.6931471805599453; // log 0.5 + if who.item_type == "player" && rand::thread_rng().gen::() < 0.5 * (-LAMBDA * (gap as f64)).exp() { + if let Some((sess, _sess_dat)) = trans.find_session_for_player(&who.item_code).await? { + if let Some(mut user) = trans.find_by_username(&who.item_code).await? { + if *user.raw_skills.get(skill).unwrap_or(&0.0) >= 15.0 || + !user.last_skill_improve.get(skill) + .map(|t| (Utc::now() - *t).num_seconds() > 60).unwrap_or(true) { + return Ok(result) + } + user.raw_skills.entry(skill.clone()).and_modify(|raw| *raw += 0.01).or_insert(0.01); + + trans.queue_for_session(&sess, + Some(&format!("Your raw {} is now {:2}\n", + skill.display(), user.raw_skills + .get(skill).unwrap_or(&0.0)))).await?; + trans.save_user_model(&user).await?; + calculate_total_stats_skills_for_user(who, &user); + } + } + } + + Ok(result) +} diff --git a/blastmud_game/src/static_content/npc/statbot.rs b/blastmud_game/src/static_content/npc/statbot.rs index f0a045d..921faa2 100644 --- a/blastmud_game/src/static_content/npc/statbot.rs +++ b/blastmud_game/src/static_content/npc/statbot.rs @@ -12,6 +12,7 @@ use crate::models::{ user::{User}, session::Session }; +use crate::services::skills::calculate_total_stats_skills_for_user; use ansi::ansi; use nom::character::complete::u8; @@ -68,7 +69,7 @@ fn work_out_state(user: &User, item: &Item) -> StatbotState { if !user.raw_stats.contains_key(&StatType::Cool) { return StatbotState::Cool; } - if points_left(user) != 0 { + if points_left(user) != 0.0 { return StatbotState::FixTotals; } if item.sex.is_none() { @@ -80,23 +81,23 @@ fn work_out_state(user: &User, item: &Item) -> StatbotState { StatbotState::Done } -fn points_left(user: &User) -> u16 { - let brn = user.raw_stats.get(&StatType::Brains).cloned().unwrap_or(8); - let sen = user.raw_stats.get(&StatType::Senses).cloned().unwrap_or(8); - let brw = user.raw_stats.get(&StatType::Brawn).cloned().unwrap_or(8); - let refl = user.raw_stats.get(&StatType::Reflexes).cloned().unwrap_or(8); - let end = user.raw_stats.get(&StatType::Endurance).cloned().unwrap_or(8); - let col = user.raw_stats.get(&StatType::Cool).cloned().unwrap_or(8); - (62 - (brn + sen + brw + refl + end + col) as i16).max(0) as u16 +fn points_left(user: &User) -> f64 { + let brn = user.raw_stats.get(&StatType::Brains).cloned().unwrap_or(8.0); + let sen = user.raw_stats.get(&StatType::Senses).cloned().unwrap_or(8.0); + let brw = user.raw_stats.get(&StatType::Brawn).cloned().unwrap_or(8.0); + let refl = user.raw_stats.get(&StatType::Reflexes).cloned().unwrap_or(8.0); + let end = user.raw_stats.get(&StatType::Endurance).cloned().unwrap_or(8.0); + let col = user.raw_stats.get(&StatType::Cool).cloned().unwrap_or(8.0); + (62 - (brn + sen + brw + refl + end + col) as i16).max(0) as f64 } fn next_action_text(session: &Session, user: &User, item: &Item) -> String { - let brn = user.raw_stats.get(&StatType::Brains).cloned().unwrap_or(8); - let sen = user.raw_stats.get(&StatType::Senses).cloned().unwrap_or(8); - let brw = user.raw_stats.get(&StatType::Brawn).cloned().unwrap_or(8); - let refl = user.raw_stats.get(&StatType::Reflexes).cloned().unwrap_or(8); - let end = user.raw_stats.get(&StatType::Endurance).cloned().unwrap_or(8); - let col = user.raw_stats.get(&StatType::Cool).cloned().unwrap_or(8); + let brn = user.raw_stats.get(&StatType::Brains).cloned().unwrap_or(8.0); + let sen = user.raw_stats.get(&StatType::Senses).cloned().unwrap_or(8.0); + let brw = user.raw_stats.get(&StatType::Brawn).cloned().unwrap_or(8.0); + let refl = user.raw_stats.get(&StatType::Reflexes).cloned().unwrap_or(8.0); + let end = user.raw_stats.get(&StatType::Endurance).cloned().unwrap_or(8.0); + let col = user.raw_stats.get(&StatType::Cool).cloned().unwrap_or(8.0); let summary = format!("Brains: {}, Senses: {}, Brawn: {}, Reflexes: {}, Endurance: {}, Cool: {}. To spend: {}", brn, sen, brw, refl, end, col, points_left(user)); let st = work_out_state(user, item); @@ -109,7 +110,7 @@ fn next_action_text(session: &Session, user: &User, item: &Item) -> String { brainpower you will have. If you choose 8, you don't spend any points. There \ is a maximum of 15 - if you choose 15, you will spend 7 points and have 7 \ left for other stats. Brains help your appraise, bombs, chemistry, craft, \ - hack, locksmith, medic, pursuade, pilot, repair, science and teach \ + hack, locksmith, medic, persuade, pilot, repair, science and teach \ skills.\n\ \tType -statbot brains 8 (or any other \ number) to set your brains to that number. You will be able to adjust your \ @@ -148,8 +149,8 @@ fn next_action_text(session: &Session, user: &User, item: &Item) -> String { ), if session.less_explicit_mode { "" } else { " fuck,"}, &summary), StatbotState::Cool => ansi!( "Your next job is to choose how much you keep your cool under pressure. \ - Cool helps your blades, bombs, fish, pistols, quickdraw, rifles and sneak \ - skills.\n\ + Cool helps your blades, bombs, fish, pistols, quickdraw, rifles, sneak \ + and persuade skills.\n\ \tType -statbot cool 8 (or any other number) to \ set your cool to that number. You will be able to adjust your stats by \ sending me the new value, up until you leave here. Your stats now are: " @@ -199,17 +200,17 @@ async fn stat_command(ctx: &mut VerbContext<'_>, item: &Item, Ok((_, statno)) => { let points = { let user = get_user_or_fail(ctx)?; - points_left(get_user_or_fail(ctx)?) + (user.raw_stats.get(stat).cloned().unwrap_or(8) - 8) + points_left(get_user_or_fail(ctx)?) + (user.raw_stats.get(stat).cloned().unwrap_or(8.0) - 8.0) }; - if (statno - 8) as u16 > points { - reply(ctx, &if points == 0 { "You have no points left".to_owned() } else { - format!("You only have {} point{} left", points, if points == 1 { "" } else { "s" }) + if (statno as f64 - 8.0) > points { + reply(ctx, &if points == 0.0 { "You have no points left".to_owned() } else { + format!("You only have {} point{} left", points, if points == 1.0 { "" } else { "s" }) }).await?; return Ok(()); } { let user_mut = get_user_or_fail_mut(ctx)?; - user_mut.raw_stats.insert(stat.clone(), statno as u16); + user_mut.raw_stats.insert(stat.clone(), statno as f64); } let user: &User = get_user_or_fail(ctx)?; ctx.trans.save_user_model(user).await?; @@ -280,11 +281,12 @@ impl ExitBlocker for ChoiceRoomBlocker { async fn attempt_exit( self: &Self, ctx: &mut VerbContext, - player: &Item, + player: &mut Item, _exit: &Exit ) -> UResult { let user = get_user_or_fail(ctx)?; - if work_out_state(user, player) == StatbotState::Done { + if work_out_state(user, player) == StatbotState::Done { + calculate_total_stats_skills_for_user(player, &user); Ok(true) } else { shout(ctx, &format!(ansi!("YOU SHALL NOT PASS UNTIL YOU DO AS I SAY! {}"), diff --git a/blastmud_game/src/static_content/room.rs b/blastmud_game/src/static_content/room.rs index 447ef8b..05c008d 100644 --- a/blastmud_game/src/static_content/room.rs +++ b/blastmud_game/src/static_content/room.rs @@ -65,7 +65,7 @@ pub trait ExitBlocker { async fn attempt_exit( self: &Self, ctx: &mut VerbContext, - player: &Item, + player: &mut Item, exit: &Exit ) -> UResult; } diff --git a/scripts/statgen.hs b/scripts/statgen.hs new file mode 100644 index 0000000..00945bc --- /dev/null +++ b/scripts/statgen.hs @@ -0,0 +1,37 @@ +{-# LANGUAGE OverloadedStrings #-} +import qualified Data.Text as T +import qualified Data.Text.IO as T +import qualified Data.Set as S +import Data.List +import Control.Monad + +stats :: [(T.Text, [T.Text])] +stats = [ + ("brn", ["appraise", "bombs", "chemistry", "craft", "hack", "locksmith", "medic", "persuade", "pilot", "repair", + "science", "teach"]), + ("sen", ["appraise", "dodge", "focus", "fuck", "scavenge", "sneak", "throw", "track", "whips"]), + ("brw", ["clubs", "fists", "throw"]), + ("refl", ["blades", "climb", "clubs", "dodge", "locksmith", "pilot", "pistols", "quickdraw", "rifles", "spears", + "whips"]), + ("end", ["climb", "fish", "fists", "focus", "fuck", "scavenge", "spears", "swim"]), + ("col", ["blades", "bombs", "fish", "pistols", "quickdraw", "rifles", "sneak", "persuade"]) + ] + +doubleValue :: S.Set T.Text +doubleValue = S.fromList [ + "chemistry", "craft", "hack", "medic", "repair", + "science", "swim", "teach", "track" + ] + +main :: IO () +main = + let skillSet = + sortOn snd $ + stats >>= (\(st, skills) -> map (\skill -> (st, skill)) skills) + in + forM_ skillSet $ + \(stat, skill) -> + let mup = if skill `S.member` doubleValue then "" else " * 0.5" + in + T.putStrLn $ " target_item.total_skills.entry(SkillType::" <> (T.toTitle skill) <> ")\n\ + \ .and_modify(|sk| *sk += " <> stat <> mup <> ").or_insert(brn" <> mup <> ");"