From 0c280711e86d025b61a38476cb7137e7c3983ceb Mon Sep 17 00:00:00 2001 From: Condorra Date: Wed, 27 Dec 2023 00:34:47 +1100 Subject: [PATCH] Add a mini-game around sharing knowledge It still needs to apply buffs at the end and a few other details! --- blastmud_game/src/language.rs | 21 + .../src/message_handler/user_commands.rs | 17 + .../message_handler/user_commands/allow.rs | 21 +- .../src/message_handler/user_commands/help.rs | 14 - .../user_commands/help/explicit.yaml | 20 - .../user_commands/help/registered.yaml | 2 +- .../message_handler/user_commands/movement.rs | 9 + .../message_handler/user_commands/parsing.rs | 14 +- .../message_handler/user_commands/share.rs | 112 ++ .../message_handler/user_commands/status.rs | 19 +- .../src/message_handler/user_commands/stop.rs | 15 + blastmud_game/src/models/consent.rs | 6 +- blastmud_game/src/models/item.rs | 181 ++- blastmud_game/src/models/task.rs | 2 + blastmud_game/src/regular_tasks.rs | 3 +- blastmud_game/src/services.rs | 2 + blastmud_game/src/services/combat.rs | 2 +- blastmud_game/src/services/display.rs | 13 + blastmud_game/src/services/sharing.rs | 1260 +++++++++++++++++ blastmud_game/src/services/skills.rs | 4 +- .../src/static_content/npc/melbs_citizen.rs | 2 +- .../src/static_content/npc/statbot.rs | 13 +- 22 files changed, 1662 insertions(+), 90 deletions(-) delete mode 100644 blastmud_game/src/message_handler/user_commands/help/explicit.yaml create mode 100644 blastmud_game/src/message_handler/user_commands/share.rs create mode 100644 blastmud_game/src/services/display.rs create mode 100644 blastmud_game/src/services/sharing.rs diff --git a/blastmud_game/src/language.rs b/blastmud_game/src/language.rs index 392427d..96ce6d5 100644 --- a/blastmud_game/src/language.rs +++ b/blastmud_game/src/language.rs @@ -281,6 +281,14 @@ pub fn join_words(words: &[&str]) -> String { } } +pub fn join_words_or(words: &[&str]) -> String { + match words.split_last() { + None => "".to_string(), + Some((last, [])) => last.to_string(), + Some((last, rest)) => rest.join(", ") + " or " + last, + } +} + pub fn weight(grams: u64) -> String { if grams > 999 { format!( @@ -380,6 +388,19 @@ mod test { } } + #[test] + fn join_words_or_works() { + for (inp, outp) in vec![ + (vec![], ""), + (vec!["cat"], "cat"), + (vec!["cat", "dog"], "cat or dog"), + (vec!["cat", "dog", "fish"], "cat, dog or fish"), + (vec!["wolf", "cat", "dog", "fish"], "wolf, cat, dog or fish"), + ] { + assert_eq!(super::join_words_or(&inp[..]), outp); + } + } + #[test] fn weight_works() { assert_eq!(super::weight(100), "100 g"); diff --git a/blastmud_game/src/message_handler/user_commands.rs b/blastmud_game/src/message_handler/user_commands.rs index 0a07ca5..0720cd0 100644 --- a/blastmud_game/src/message_handler/user_commands.rs +++ b/blastmud_game/src/message_handler/user_commands.rs @@ -68,6 +68,7 @@ mod reset_spawns; pub mod say; pub mod scavenge; mod score; +mod share; mod sign; pub mod sit; mod staff_show; @@ -241,6 +242,22 @@ static REGISTERED_COMMANDS: UserVerbRegistry = phf_map! { "sc" => score::VERB, "score" => score::VERB, + "share" => share::VERB, + "serious" => share::VERB, + "amicable" => share::VERB, + "joking" => share::VERB, + "parody" => share::VERB, + "play" => share::VERB, + "thoughts" => share::VERB, + "exploring" => share::VERB, + "roaming" => share::VERB, + "fishing" => share::VERB, + "good" => share::VERB, + "surviving" => share::VERB, + "slow" => share::VERB, + "normal" => share::VERB, + "intense" => share::VERB, + "sign" => sign::VERB, "sit" => sit::VERB, diff --git a/blastmud_game/src/message_handler/user_commands/allow.rs b/blastmud_game/src/message_handler/user_commands/allow.rs index 3bd9112..77e6806 100644 --- a/blastmud_game/src/message_handler/user_commands/allow.rs +++ b/blastmud_game/src/message_handler/user_commands/allow.rs @@ -373,8 +373,8 @@ mod tests { "Bar", "her", "from bar", - &ConsentType::Sex, - &ConsentDetails::default_for(&ConsentType::Sex), + &ConsentType::Share, + &ConsentDetails::default_for(&ConsentType::Share), &Some(Consent::default()), &None, false, @@ -527,8 +527,8 @@ mod tests { "Bar", "her", "from bar", - &ConsentType::Sex, - &ConsentDetails::default_for(&ConsentType::Sex), + &ConsentType::Share, + &ConsentDetails::default_for(&ConsentType::Share), &None, &None, true, @@ -820,12 +820,10 @@ async fn handle_user_consent( .await? { None => {} - Some((session, session_dat)) => { - if cmd.consent_type != ConsentType::Sex || !session_dat.less_explicit_mode { - ctx.trans - .queue_for_session(&session, Some(&(msg + "\n"))) - .await?; - } + Some((session, _session_dat)) => { + ctx.trans + .queue_for_session(&session, Some(&(msg + "\n"))) + .await?; } } } @@ -1030,8 +1028,7 @@ impl UserVerb for Verb { if remaining_trim == "" { handle_list_allows(ctx, &player_item).await?; } else { - let mut cmd = match parsing::parse_allow(remaining, !ctx.session_dat.less_explicit_mode) - { + let mut cmd = match parsing::parse_allow(remaining) { Err(msg) => user_error(msg)?, Ok(cmd) => cmd, }; diff --git a/blastmud_game/src/message_handler/user_commands/help.rs b/blastmud_game/src/message_handler/user_commands/help.rs index 6cda1e7..8fc0c40 100644 --- a/blastmud_game/src/message_handler/user_commands/help.rs +++ b/blastmud_game/src/message_handler/user_commands/help.rs @@ -32,12 +32,6 @@ fn unregistered_help_pages() -> &'static BTreeMap { CELL.get_or_init(|| load_help_yaml(include_str!("help/unregistered.yaml"))) } -fn explicit_help_pages() -> &'static BTreeMap { - static CELL: OnceCell> = OnceCell::new(); - - CELL.get_or_init(|| load_help_yaml(include_str!("help/explicit.yaml"))) -} - pub struct Verb; #[async_trait] impl UserVerb for Verb { @@ -57,9 +51,6 @@ impl UserVerb for Verb { help = help.or_else(|| unregistered_help_pages().get(remaining)); } else { help = help.or_else(|| registered_help_pages().get(remaining)); - if !ctx.session_dat.less_explicit_mode { - help = explicit_help_pages().get(remaining).or(help); - } } help = help.or_else(|| always_help_pages().get(remaining).map(|v| v)); let help_final = help.ok_or(UserError("No help available on that".to_string()))?; @@ -90,9 +81,4 @@ mod tests { fn always_help_ok() { always_help_pages(); } - - #[test] - fn explicit_help_ok() { - explicit_help_pages(); - } } diff --git a/blastmud_game/src/message_handler/user_commands/help/explicit.yaml b/blastmud_game/src/message_handler/user_commands/help/explicit.yaml deleted file mode 100644 index 6fc9882..0000000 --- a/blastmud_game/src/message_handler/user_commands/help/explicit.yaml +++ /dev/null @@ -1,20 +0,0 @@ -fuck: "Type fuck name> to fuck someone. It only works if they have consented." -allow: |- - allow is the corner-stone of Blastmud's consent system. Consents in Blastmud let you choose how you want to play with other players (it only affects players, not NPCs). There are 5 types of consent: fight (for hostile actions like attack or pick), medicine (for medical actions, including those that might crit fail and do harm), gifts (lets them give you things), visit (lets them on to a tile owned by you legally), and fuck (lets them do fun but dirty things to you). - - To allow, as an individual, use the syntax allow type from player options - As a corp, use the syntax allow type against corpname by corpname options - Options can be blank to use defaults, or can be one or more of the following, separated by spaces: - until n minutes - replace n with a number. You can use hours, days, or weeks instead of minutes. This makes the consent expire. Fight expires after a week if you don't give a shorter period, and all other consent types have no expiry unless you specify one. - until death - makes the consent valid only until you next die. - allow private - makes the consent valid even in privately owned places. This is the default for anything except fight. - disallow private - the opposite of allow private. - in place - limits where fighting can happen to selected public places. You can use here, or if you know the code, a place name. You can use this option more than once to allow any place, and if you don't use the option, it means anywhere (subject to allow private). - allow pick - fight only - include picking in your consent. - allow revoke - fight only - allows the player to revoke any time with disallow. - - Consents for anything except fight take effect immediately to let the other player do the action. - Consents for fight take effect when the other player executes a reciprocal allow command. - Consents for anything except than fight can be revoked instantly with: - disallow action from player - Consent for fight can be revoked similarly if the consent used the allow revoke option. Otherwise, attempting to revoke informs the other player, and it is revoked when they also issue a disallow command. diff --git a/blastmud_game/src/message_handler/user_commands/help/registered.yaml b/blastmud_game/src/message_handler/user_commands/help/registered.yaml index 58bbc60..2c0ef4b 100644 --- a/blastmud_game/src/message_handler/user_commands/help/registered.yaml +++ b/blastmud_game/src/message_handler/user_commands/help/registered.yaml @@ -93,7 +93,7 @@ list: *possessions wield: *possessions gear: *possessions allow: |- - allow is the corner-stone of Blastmud's consent system. Consents in Blastmud let you choose how you want to play with other players (it only affects players, not NPCs). There are 4 types of consent: fight (for hostile actions like attack or pick), medicine (for medical actions, including those that might crit fail and do harm), gifts (lets them give you things), and visit (lets them on to a tile owned by you legally). + allow is the corner-stone of Blastmud's consent system. Consents in Blastmud let you choose how you want to play with other players (it only affects players, not NPCs). There are 5 types of consent: fight (for hostile actions like attack or pick), medicine (for medical actions, including those that might crit fail and do harm), gifts (lets them give you things), visit (lets them on to a tile owned by you legally), and share (lets them local share knowledge with you, making both parties stronger). To allow, as an individual, use the syntax allow type from player options As a corp, use the syntax allow type against corpname by corpname options diff --git a/blastmud_game/src/message_handler/user_commands/movement.rs b/blastmud_game/src/message_handler/user_commands/movement.rs index b347d5d..faba900 100644 --- a/blastmud_game/src/message_handler/user_commands/movement.rs +++ b/blastmud_game/src/message_handler/user_commands/movement.rs @@ -24,6 +24,7 @@ use crate::{ check_consent, check_one_consent, combat::{change_health, handle_resurrect, stop_attacking_mut}, comms::broadcast_to_room, + sharing::stop_conversation_mut, skills::skill_check_and_grind, urges::{recalculate_urge_growth, thirst_changed}, }, @@ -892,6 +893,14 @@ impl UserVerb for Verb { }, ) .await?; + if player_item.active_conversation.is_some() { + stop_conversation_mut( + &ctx.trans, + &mut player_item, + "walks away from sharing knowledge with", + ) + .await?; + } ctx.trans.save_item_model(&player_item).await?; Ok(()) } diff --git a/blastmud_game/src/message_handler/user_commands/parsing.rs b/blastmud_game/src/message_handler/user_commands/parsing.rs index 9163d37..0fe2e8a 100644 --- a/blastmud_game/src/message_handler/user_commands/parsing.rs +++ b/blastmud_game/src/message_handler/user_commands/parsing.rs @@ -118,7 +118,7 @@ pub fn parse_duration_mins<'l>(input: &'l str) -> Result<(u64, &'l str), String> }, input)) } -pub fn parse_allow<'l>(input: &'l str, is_explicit: bool) -> Result { +pub fn parse_allow<'l>(input: &'l str) -> Result { let usage: &'static str = ansi!("Usage: allow action> from user> options> | allow action> against corp> by corp> options>. Try help allow for more."); let (consent_type_s, input) = match input.trim_start().split_once(" ") { @@ -126,11 +126,7 @@ pub fn parse_allow<'l>(input: &'l str, is_explicit: bool) -> Result Ok(v), }?; let consent_type = match ConsentType::from_str(&consent_type_s.trim().to_lowercase()) { - None => Err(if is_explicit { - "Invalid consent type - options are fight, medicine, gifts, visit and sex" - } else { - "Invalid consent type - options are fight, medicine, gifts and visit" - }), + None => Err("Invalid consent type - options are fight, medicine, gifts, visit and share"), Some(ct) => Ok(ct), }?; @@ -434,7 +430,7 @@ mod tests { #[test] fn parse_consent_works_default_options_user() { assert_eq!( - super::parse_allow("medicine From Athorina", false), + super::parse_allow("medicine From Athorina"), Ok(AllowCommand { consent_type: ConsentType::Medicine, consent_target: ConsentTarget::UserTarget { @@ -448,7 +444,7 @@ mod tests { #[test] fn parse_consent_works_default_options_corp() { assert_eq!( - super::parse_allow("Fight Against megacorp By supercorp", false), + super::parse_allow("Fight Against megacorp By supercorp"), Ok(AllowCommand { consent_type: ConsentType::Fight, consent_target: ConsentTarget::CorpTarget { @@ -462,7 +458,7 @@ mod tests { #[test] fn parse_consent_handles_options() { - assert_eq!(super::parse_allow("fighT fRom athorina For 2 hOurs unTil deAth allOw priVate Disallow pIck alLow revoKe iN here in pit", false), + assert_eq!(super::parse_allow("fighT fRom athorina For 2 hOurs unTil deAth allOw priVate Disallow pIck alLow revoKe iN here in pit"), Ok(AllowCommand { consent_type: ConsentType::Fight, consent_target: ConsentTarget::UserTarget { to_user: "athorina" }, diff --git a/blastmud_game/src/message_handler/user_commands/share.rs b/blastmud_game/src/message_handler/user_commands/share.rs new file mode 100644 index 0000000..bc90b9d --- /dev/null +++ b/blastmud_game/src/message_handler/user_commands/share.rs @@ -0,0 +1,112 @@ +use crate::{ + db::ItemSearchParams, + models::item::{ConversationIntensity, ConversationalStyle}, + services::sharing::{ + change_conversation_intensity, change_conversation_topic, change_conversational_style, + display_conversation_status, parse_conversation_topic, start_conversation, + }, +}; + +use super::{ + get_player_item_or_fail, search_item_for_user, user_error, UResult, UserError, UserVerb, + UserVerbRef, VerbContext, +}; +use ansi::ansi; +use async_trait::async_trait; + +pub struct Verb; +#[async_trait] +impl UserVerb for Verb { + async fn handle( + self: &Self, + ctx: &mut VerbContext, + verb: &str, + remaining: &str, + ) -> UResult<()> { + let mut player_item = (*(get_player_item_or_fail(ctx).await?)).clone(); + if player_item.death_data.is_some() { + user_error("You can't do that, you're dead!".to_string())?; + } + + let remaining = remaining.trim(); + if let Some(ac) = player_item.active_conversation.as_ref() { + match ConversationalStyle::from_name(verb) { + None => {} + Some(style) => { + change_conversational_style(ctx, &mut player_item, style).await?; + ctx.trans.save_item_model(&player_item).await?; + return Ok(()); + } + } + match parse_conversation_topic(&format!("{} {}", verb, remaining)) + .map_err(|e| UserError(e.to_owned()))? + { + None => {} + Some(topic) => { + change_conversation_topic(ctx, &mut player_item, topic).await?; + ctx.trans.save_item_model(&player_item).await?; + return Ok(()); + } + } + + if verb == "share" { + match ConversationIntensity::from_adverb(remaining) { + None => {} + Some(intensity) => { + change_conversation_intensity(ctx, &mut player_item, intensity).await?; + ctx.trans.save_item_model(&player_item).await?; + return Ok(()); + } + } + if remaining == "status" { + let (partner_type, partner_code) = ac + .partner_ref + .split_once("/") + .ok_or_else(|| UserError("Bad share partner".to_owned()))?; + if let Some(partner) = ctx + .trans + .find_item_by_type_code(partner_type, partner_code) + .await? + { + display_conversation_status(&ctx.trans, &player_item, &partner).await?; + return Ok(()); + } + } + } + + user_error("You're already sharing knowledge!".to_owned())?; + } + + let (word2, remaining) = remaining.split_once(" ").ok_or_else(|| { + UserError( + ansi!("Start your encounter with the share knowledge with command first.") + .to_owned(), + ) + })?; + let (word3, remaining) = remaining.trim().split_once(" ").ok_or_else(|| { + UserError(ansi!("share knowledge with command first.").to_owned()) + })?; + if verb != "share" || word2.trim() != "knowledge" || word3.trim() != "with" { + user_error( + ansi!("Start your encounter with the share knowledge with command first.") + .to_owned(), + )?; + } + + let with_whom = search_item_for_user( + ctx, + &ItemSearchParams { + include_loc_contents: true, + limit: 1, + ..ItemSearchParams::base(&player_item, remaining) + }, + ) + .await?; + + start_conversation(&ctx.trans, &player_item, &with_whom).await?; + + Ok(()) + } +} +static VERB_INT: Verb = Verb; +pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef; diff --git a/blastmud_game/src/message_handler/user_commands/status.rs b/blastmud_game/src/message_handler/user_commands/status.rs index 233ae68..ef5fcbc 100644 --- a/blastmud_game/src/message_handler/user_commands/status.rs +++ b/blastmud_game/src/message_handler/user_commands/status.rs @@ -1,22 +1,11 @@ use super::{get_player_item_or_fail, user_error, UResult, UserVerb, UserVerbRef, VerbContext}; -use crate::{models::item::Urges, services::combat::max_health}; +use crate::{ + models::item::Urges, + services::{combat::max_health, display::bar_n_of_m}, +}; use ansi::ansi; use async_trait::async_trait; -fn bar_n_of_m(mut actual: u64, max: u64) -> String { - if actual > max { - actual = max; - } - let mut r = String::new(); - for _i in 0..actual { - r += "|"; - } - for _i in actual..max { - r += " "; - } - r -} - pub struct Verb; #[async_trait] impl UserVerb for Verb { diff --git a/blastmud_game/src/message_handler/user_commands/stop.rs b/blastmud_game/src/message_handler/user_commands/stop.rs index 83935a8..47b3003 100644 --- a/blastmud_game/src/message_handler/user_commands/stop.rs +++ b/blastmud_game/src/message_handler/user_commands/stop.rs @@ -2,6 +2,7 @@ use super::{ get_player_item_or_fail, movement::reverse_climb, user_error, UResult, UserVerb, UserVerbRef, VerbContext, }; +use crate::services::sharing::stop_conversation_mut; use async_trait::async_trait; pub struct Verb; @@ -21,6 +22,20 @@ impl UserVerb for Verb { let mut player_item_mut = (*player_item).clone(); let mut queue_head = player_item_mut.queue.pop_front(); + if player_item_mut.active_conversation.is_some() { + stop_conversation_mut( + &ctx.trans, + &mut player_item_mut, + &format!( + "holds up {} hand to stop the conversation with", + &player_item.pronouns.possessive + ), + ) + .await?; + ctx.trans.save_item_model(&player_item_mut).await?; + return Ok(()); + } + if player_item.active_combat.is_some() { // Otherwise, we assume they wanted to stop escaping etc... if queue_head.is_none() { diff --git a/blastmud_game/src/models/consent.rs b/blastmud_game/src/models/consent.rs index fa6985d..56ff8f8 100644 --- a/blastmud_game/src/models/consent.rs +++ b/blastmud_game/src/models/consent.rs @@ -8,7 +8,7 @@ pub enum ConsentType { Medicine, Gifts, Visit, - Sex, + Share, } impl ConsentType { @@ -19,7 +19,7 @@ impl ConsentType { "medicine" => Some(Medicine), "gifts" => Some(Gifts), "visit" => Some(Visit), - "sex" => Some(Sex), + "share" => Some(Share), _ => None, } } @@ -31,7 +31,7 @@ impl ConsentType { Medicine => "medicine", Gifts => "gifts", Visit => "visit", - Sex => "sex", + Share => "share", } } } diff --git a/blastmud_game/src/models/item.rs b/blastmud_game/src/models/item.rs index 1b6f843..7bbf393 100644 --- a/blastmud_game/src/models/item.rs +++ b/blastmud_game/src/models/item.rs @@ -53,7 +53,6 @@ pub enum SkillType { Fists, Flails, Focus, - Fuck, Hack, Locksmith, Medic, @@ -66,6 +65,7 @@ pub enum SkillType { Rifles, Scavenge, Science, + Share, Sneak, Spears, Swim, @@ -81,8 +81,9 @@ impl SkillType { 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, + Focus, Hack, Locksmith, Medic, Persuade, Pilot, Pistols, Quickdraw, Repair, Ride, + Rifles, Scavenge, Science, Share, Sneak, Spears, Swim, Teach, Throw, Track, Wrestle, + Whips, ] } pub fn display(&self) -> &'static str { @@ -100,7 +101,6 @@ impl SkillType { Fists => "fists", Flails => "flails", Focus => "focus", - Fuck => "fuck", Hack => "hack", Locksmith => "locksmith", Medic => "medic", @@ -113,6 +113,7 @@ impl SkillType { Rifles => "rifles", Scavenge => "scavenge", Science => "science", + Share => "share", Sneak => "sneak", Spears => "spears", Swim => "swim", @@ -317,6 +318,7 @@ pub enum ItemFlag { HasUrges, NoUrgesHere, DontListInLook, + AllowShare, } #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] @@ -362,6 +364,175 @@ impl Default for ActiveClimb { } } +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum ConversationalInterestType { + Philosophy, + LocalGeography, + Threats, + Tactics, + Weather, + Politics, + Frivolity, +} + +impl ConversationalInterestType { + pub fn display(&self) -> &'static str { + use ConversationalInterestType::*; + match self { + Philosophy => "philosophy", + LocalGeography => "local geography", + Threats => "threats", + Tactics => "tactics", + Weather => "weather", + Politics => "politics", + Frivolity => "frivolity", + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum ConversationalStyle { + Joking, + Serious, + Amicable, +} + +impl ConversationalStyle { + pub fn display(&self) -> &str { + match self { + ConversationalStyle::Amicable => "amicable", + ConversationalStyle::Serious => "serious", + ConversationalStyle::Joking => "joking", + } + } + + pub fn transitions(&self) -> Vec { + use ConversationalStyle::*; + vec![Amicable, Serious, Joking] + .into_iter() + .filter(|v| v != self) + .collect() + } + + pub fn from_name(n: &str) -> Option { + use ConversationalStyle::*; + match n { + "amicable" => Some(Amicable), + "serious" => Some(Serious), + "joking" => Some(Joking), + _ => None, + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum ConversationTopic { + ParodyKingsOffice, + PlayFight, + ThoughtsOnSunTzu, + ThoughtsOnMachiavelli, + ExploringRuins, + RoamingEnemies, + FishingSpots, + GoodAmbushSpots, + SurvivingWeather, +} + +impl ConversationTopic { + pub fn display_command(&self) -> &'static str { + use ConversationTopic::*; + match self { + ParodyKingsOffice => "parody kings office", + PlayFight => "play fight", + ThoughtsOnSunTzu => "thoughts on sun tzu", + ThoughtsOnMachiavelli => "thoughts on machiavelli", + ExploringRuins => "exploring ruins", + RoamingEnemies => "roaming enemies", + FishingSpots => "fishing spots", + GoodAmbushSpots => "good ambush spots", + SurvivingWeather => "surviving weather", + } + } + pub fn display_readable(&self) -> &'static str { + use ConversationTopic::*; + match self { + ParodyKingsOffice => "parodying the kings office", + PlayFight => "proposing a play fight", + ThoughtsOnSunTzu => "sharing thoughts on Sun Tzu", + ThoughtsOnMachiavelli => "sharing thoughts on Machiavelli", + ExploringRuins => "comparing notes on exploring ruins", + RoamingEnemies => "complaining about roaming enemies", + FishingSpots => "sharing the best fishing spots", + GoodAmbushSpots => "discussing good ambush spots", + SurvivingWeather => "describing how to survive weather", + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum ConversationIntensity { + Slow, + Normal, + Fast, +} + +impl ConversationIntensity { + pub fn to_command(&self) -> &'static str { + match self { + Self::Slow => "share slowly", + Self::Normal => "share normally", + Self::Fast => "share quickly", + } + } + + pub fn display_readable(&self) -> &'static str { + match self { + Self::Slow => "slowly", + Self::Normal => "normally", + Self::Fast => "quickly", + } + } + + pub fn from_adverb(input: &str) -> Option { + let input = input.to_lowercase(); + if input == "slowly" { + Some(ConversationIntensity::Slow) + } else if input == "normally" { + Some(ConversationIntensity::Normal) + } else if input == "quickly" { + Some(ConversationIntensity::Fast) + } else { + None + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +#[serde(default)] +pub struct ActiveConversation { + pub interest_levels: BTreeMap, + pub partner_ref: String, + pub style: ConversationalStyle, + pub current_topic: ConversationTopic, + pub current_intensity: ConversationIntensity, + pub peak_total_interest: u64, + pub last_change: DateTime, +} + +impl Default for ActiveConversation { + fn default() -> Self { + Self { + interest_levels: BTreeMap::new(), + partner_ref: "unset".to_owned(), + style: ConversationalStyle::Serious, + current_topic: ConversationTopic::RoamingEnemies, + current_intensity: ConversationIntensity::Normal, + peak_total_interest: 0, + last_change: DateTime::UNIX_EPOCH, + } + } +} + #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] #[serde(default)] pub struct Urge { @@ -495,6 +666,7 @@ pub struct Item { pub active_climb: Option, pub active_combat: Option, pub active_effects: Vec<(EffectType, i64)>, + pub active_conversation: Option, pub aliases: Vec, pub charges: u8, pub death_data: Option, @@ -620,6 +792,7 @@ impl Default for Item { active_climb: None, active_combat: Some(Default::default()), active_effects: vec![], + active_conversation: None, aliases: vec![], charges: 0, death_data: None, diff --git a/blastmud_game/src/models/task.rs b/blastmud_game/src/models/task.rs index d70163a..b065882 100644 --- a/blastmud_game/src/models/task.rs +++ b/blastmud_game/src/models/task.rs @@ -27,6 +27,7 @@ pub enum TaskDetails { npc_code: String, }, AttackTick, + ShareTick, RecloneNPC { npc_code: String, }, @@ -78,6 +79,7 @@ impl TaskDetails { NPCWander { .. } => "NPCWander", NPCAggro { .. } => "NPCAggro", AttackTick => "AttackTick", + ShareTick => "ShareTick", RecloneNPC { .. } => "RecloneNPC", RotCorpse { .. } => "RotCorpse", DelayedHealth { .. } => "DelayedHealth", diff --git a/blastmud_game/src/regular_tasks.rs b/blastmud_game/src/regular_tasks.rs index 2e11822..24ca3f7 100644 --- a/blastmud_game/src/regular_tasks.rs +++ b/blastmud_game/src/regular_tasks.rs @@ -9,7 +9,7 @@ use crate::{ listener::{ListenerMap, ListenerSend}, message_handler::user_commands::{delete, drop, hire, open, rent}, models::task::Task, - services::{combat, effect, spawn, urges}, + services::{combat, effect, sharing, spawn, urges}, static_content::{ npc::{self, computer_museum_npcs}, room::general_hospital, @@ -52,6 +52,7 @@ fn task_handler_registry( ("NPCWander", npc::WANDER_HANDLER), ("NPCAggro", npc::AGGRO_HANDLER), ("AttackTick", combat::TASK_HANDLER), + ("ShareTick", sharing::TASK_HANDLER), ("RecloneNPC", npc::RECLONE_HANDLER), ("RotCorpse", combat::ROT_CORPSE_HANDLER), ("DelayedHealth", effect::DELAYED_HEALTH_HANDLER), diff --git a/blastmud_game/src/services.rs b/blastmud_game/src/services.rs index f9af789..ee3d8ef 100644 --- a/blastmud_game/src/services.rs +++ b/blastmud_game/src/services.rs @@ -19,7 +19,9 @@ use mockall_double::double; pub mod capacity; pub mod combat; pub mod comms; +pub mod display; pub mod effect; +pub mod sharing; pub mod skills; pub mod spawn; pub mod urges; diff --git a/blastmud_game/src/services/combat.rs b/blastmud_game/src/services/combat.rs index 8e68014..6ce9067 100644 --- a/blastmud_game/src/services/combat.rs +++ b/blastmud_game/src/services/combat.rs @@ -1361,7 +1361,7 @@ impl TaskHandler for RotCorpseTaskHandler { pub static ROT_CORPSE_HANDLER: &'static (dyn TaskHandler + Sync + Send) = &RotCorpseTaskHandler; #[cfg(test)] -mod Tests { +mod tests { use crate::{models::effect::EffectType, services::effect::default_effects_for_type}; #[test] diff --git a/blastmud_game/src/services/display.rs b/blastmud_game/src/services/display.rs new file mode 100644 index 0000000..f905b1b --- /dev/null +++ b/blastmud_game/src/services/display.rs @@ -0,0 +1,13 @@ +pub fn bar_n_of_m(mut actual: u64, max: u64) -> String { + if actual > max { + actual = max; + } + let mut r = String::new(); + for _i in 0..actual { + r += "|"; + } + for _i in actual..max { + r += " "; + } + r +} diff --git a/blastmud_game/src/services/sharing.rs b/blastmud_game/src/services/sharing.rs new file mode 100644 index 0000000..7fe6209 --- /dev/null +++ b/blastmud_game/src/services/sharing.rs @@ -0,0 +1,1260 @@ +use core::time; +use std::sync::Arc; + +#[double] +use crate::db::DBTrans; +use crate::{ + language::{self, indefinite_article}, + message_handler::{ + user_commands::{user_error, UResult, UserError, VerbContext}, + ListenerSession, + }, + models::{ + consent::ConsentType, + item::{ + ActiveConversation, ConversationIntensity, ConversationTopic, + ConversationalInterestType, ConversationalStyle, Item, ItemFlag, SkillType, + }, + task::{Task, TaskDetails, TaskMeta}, + }, + regular_tasks::{TaskHandler, TaskRunContext}, + DResult, +}; +use ansi::ansi; +use async_trait::async_trait; +use chrono::Utc; +use itertools::Itertools; +use mockall_double::double; +use nom::{ + branch::alt, + bytes::complete::tag, + character::complete::space1, + combinator::{cut, map, success}, + error::{context, VerboseError, VerboseErrorKind}, + sequence::{preceded, tuple}, +}; +use rand::{prelude::SliceRandom, Rng}; + +use super::{comms::broadcast_to_room, display::bar_n_of_m, skills::skill_check_and_grind}; + +struct ConversationResult { + pub my_total_interest: u64, + pub their_total_interest: u64, + pub my_direct_interest: u64, + pub my_skill_level: f64, + pub my_transition_time: u64, + pub their_skill_level: f64, + pub their_transition_time: u64, +} + +#[derive(Clone)] +pub struct ShareTaskHandler; +#[async_trait] +impl TaskHandler for ShareTaskHandler { + async fn do_task(&self, ctx: &mut TaskRunContext) -> DResult> { + let (p1_type, p1_code) = ctx + .task + .meta + .task_code + .split_once("/") + .ok_or("Bad share task code")?; + let p1 = match ctx.trans.find_item_by_type_code(p1_type, p1_code).await? { + None => return Ok(None), + Some(v) => v, + }; + let p1_conv = match p1.active_conversation.as_ref() { + None => return Ok(None), + Some(v) => v, + }; + let (p2_type, p2_code) = p1_conv + .partner_ref + .split_once("/") + .ok_or("Bad share partner")?; + let p2 = match ctx.trans.find_item_by_type_code(p2_type, p2_code).await? { + None => return Ok(None), + Some(v) => v, + }; + let p2_conv = match p2.active_conversation.as_ref() { + None => return Ok(None), + Some(v) => v, + }; + + let intensity_word = match p1_conv.current_intensity { + ConversationIntensity::Fast => "quickly ", + ConversationIntensity::Slow => "slowly ", + ConversationIntensity::Normal => "", + }; + + let (pa, pb) = if rand::thread_rng().gen::() < 0.5 { + (&p1, &p2) + } else { + (&p2, &p1) + }; + + let msg = match p1_conv.current_topic { + ConversationTopic::ParodyKingsOffice => { + let act = (&vec![ + "mocks the existence of a self-styled king".to_owned(), + format!("declares {} king in a mocking tone", &pa.pronouns.intensive), + format!("puts on {} best Queens English and asks if {} would like some wasteland tea from {} finest rusty mug", &pa.pronouns.possessive, &pb.display_for_sentence(false, 1, false), &pa.pronouns.possessive), + format!("utters, with no hint of fear, a phrase that would have {} up for les majeste if the king had any real power", &pa.pronouns.object) + ]).choose(&mut rand::thread_rng()).unwrap().clone(); + let reaction = (&vec![ + format!("can barely contain {} laughter", &pb.pronouns.possessive), + "thinks it is hilarious".to_owned(), + "finds it rather funny".to_owned(), + "is quite amused".to_owned(), + "snorts to suppress a laugh".to_owned(), + ]) + .choose(&mut rand::thread_rng()) + .unwrap() + .clone(); + format!( + "{} {}{} and {} {}", + pa.display_for_sentence(false, 1, true), + intensity_word, + &act, + &pb.display_for_sentence(false, 1, false), + &reaction, + ) + } + ConversationTopic::PlayFight => { + let act = *(&vec![ + "pretends to throw a punch", + "engages in a playful wrestling match", + "swings an imaginary sword", + "throws a fake kick", + ]) + .choose(&mut rand::thread_rng()) + .unwrap(); + let reaction = *(&vec![ + "laughs it off", + "plays along with a grin", + "dodges the imaginary attack", + "feigns a dramatic injury", + ]) + .choose(&mut rand::thread_rng()) + .unwrap(); + format!( + "{} {}{} and {} {}", + pa.display_for_sentence(false, 1, true), + intensity_word, + &act, + &pb.display_for_sentence(false, 1, false), + &reaction, + ) + } + ConversationTopic::ThoughtsOnSunTzu => { + let thought = *(&vec![ + "reflects on Sun Tzu's strategic brilliance", + "ponders Sun Tzu's timeless wisdom", + "recalls a favorite Sun Tzu quote", + "shares a strategic insight inspired by Sun Tzu", + ]) + .choose(&mut rand::thread_rng()) + .unwrap(); + format!( + "{} {}{} and {} listens attentively", + pa.display_for_sentence(false, 1, true), + intensity_word, + &thought, + &pb.display_for_sentence(false, 1, false), + ) + } + ConversationTopic::ThoughtsOnMachiavelli => { + let thought = *(&vec![ + "discusses Machiavelli's political theories", + "evaluates the relevance of Machiavellian principles", + "analyzes Machiavelli's views on power", + "ponders the ethics of Machiavellian strategies", + ]) + .choose(&mut rand::thread_rng()) + .unwrap(); + format!( + "{} {}{} and {} nods in agreement", + pa.display_for_sentence(false, 1, true), + intensity_word, + &thought, + &pb.display_for_sentence(false, 1, false), + ) + } + ConversationTopic::ExploringRuins => { + let exploration = *(&vec![ + "describes the eerie atmosphere of ruined buildings", + "shares interesting findings from recent explorations", + "reminisces about a thrilling encounter in a ruin", + "suggests the best ruins for scavenging", + ]) + .choose(&mut rand::thread_rng()) + .unwrap(); + format!( + "{} {}{} and {} listens intently", + pa.display_for_sentence(false, 1, true), + intensity_word, + &exploration, + &pb.display_for_sentence(false, 1, false), + ) + } + ConversationTopic::RoamingEnemies => { + let enemy_tales = *(&vec![ + "recounts a close encounter with a formidable enemy", + "discusses strategies for dealing with roaming threats", + "shares tips on identifying dangerous foes", + "warns about the most treacherous areas for enemies", + ]) + .choose(&mut rand::thread_rng()) + .unwrap(); + format!( + "{} {}{} and {} nods with newfound caution", + pa.display_for_sentence(false, 1, true), + intensity_word, + &enemy_tales, + &pb.display_for_sentence(false, 1, false), + ) + } + ConversationTopic::FishingSpots => { + let fishing_info = *(&vec![ + "reveals secret fishing spots with the best catches", + "discusses the ideal bait for different fishing spots", + "shares amusing anecdotes from fishing adventures", + "boasts about the biggest fish caught in a particular spot", + ]) + .choose(&mut rand::thread_rng()) + .unwrap(); + format!( + "{} {}{} and {} expresses interest", + pa.display_for_sentence(false, 1, true), + intensity_word, + &fishing_info, + &pb.display_for_sentence(false, 1, false), + ) + } + ConversationTopic::GoodAmbushSpots => { + let ambush_strategy = *(&vec![ + "reveals the best spots for surprise ambushes", + "discusses tactics for setting up successful ambushes", + "describes the terrain for effective surprise attacks", + "shares personal experiences with ambush strategies", + ]) + .choose(&mut rand::thread_rng()) + .unwrap(); + format!( + "{} {}{} and {} listens with a thoughtful expression", + pa.display_for_sentence(false, 1, true), + intensity_word, + &ambush_strategy, + &pb.display_for_sentence(false, 1, false), + ) + } + ConversationTopic::SurvivingWeather => { + let weather_survival = *(&vec![ + "shares tips on surviving harsh weather conditions", + "discusses the best clothing for different climates", + "recounts a daring adventure in extreme weather", + "offers advice on weather-related challenges", + ]) + .choose(&mut rand::thread_rng()) + .unwrap(); + format!( + "{} {}{} and {} nods appreciatively", + pa.display_for_sentence(false, 1, true), + intensity_word, + &weather_survival, + &pb.display_for_sentence(false, 1, false), + ) + } + }; + let msg = format!(ansi!("{}\n"), &msg); + broadcast_to_room(&ctx.trans, &p1.location, None, &msg, Some(&msg)).await?; + + let mut p1_mut = (*p1).clone(); + let mut p2_mut = (*p2).clone(); + + for (interest, mut growth) in topic_to_interest_growth(&p1_conv.current_topic) { + growth *= 100; + if growth > 0 { + match p1_conv.current_intensity { + ConversationIntensity::Fast => { + growth *= 6; + growth /= 2; + } + ConversationIntensity::Slow => { + growth *= 4; + growth /= 3; + } + ConversationIntensity::Normal => { + growth *= 2; + } + } + } else { + growth *= 2; + } + p1_mut.active_conversation.as_mut().map(|ac| { + ac.interest_levels + .entry(interest.clone()) + .and_modify(|v| (*v) = ((*v) as i64 + growth).max(0).min(10000) as u64) + .or_insert(growth.max(0) as u64) + }); + p2_mut.active_conversation.as_mut().map(|ac| { + ac.interest_levels + .entry(interest.clone()) + .and_modify(|v| (*v) = ((*v) as i64 + growth).max(0).min(10000) as u64) + .or_insert(growth.max(0) as u64) + }); + } + let res = compute_conversation_result(&p1, &p1_conv, &p2, &p2_conv); + p1_mut.active_conversation.as_mut().map(|ac| { + ac.peak_total_interest = res.my_total_interest.max(ac.peak_total_interest); + }); + p2_mut.active_conversation.as_mut().map(|ac| { + ac.peak_total_interest = res.their_total_interest.max(ac.peak_total_interest); + }); + let peak_reached_interest: Option = + p1_mut.active_conversation.as_ref().and_then(|ac| { + ac.interest_levels + .iter() + .find(|(_, lev)| **lev >= 10000) + .map(|(it, _)| it.clone()) + }); + if let Some(peak_reached_interest) = peak_reached_interest { + ctx.trans.save_item_model(&p2_mut).await?; + stop_conversation_mut( + &ctx.trans, + &mut p1_mut, + &format!( + "has had {} fill of talk about {} so puts a stop to the conversation with", + &p1.pronouns.possessive, + peak_reached_interest.display() + ), + ) + .await?; + ctx.trans.save_item_model(&p1_mut).await?; + return Ok(None); + } + + let p1_ago = (Utc::now() - p1_conv.last_change).num_seconds(); + if p1_ago >= (res.my_transition_time as i64) + && p1_ago - 5 < (res.my_transition_time as i64) + && p1.item_type == "player" + { + if let Some((sess, _)) = ctx.trans.find_session_for_player(&p1.item_code).await? { + inform_player_convo_change_ready(&ctx.trans, &p1_conv, &sess).await?; + } + } + let p2_ago = (Utc::now() - p2_conv.last_change).num_seconds(); + if p2_ago >= (res.their_transition_time as i64) + && p2_ago - 5 < (res.their_transition_time as i64) + && p2.item_type == "player" + { + if let Some((sess, _)) = ctx.trans.find_session_for_player(&p2.item_code).await? { + inform_player_convo_change_ready(&ctx.trans, &p2_conv, &sess).await?; + } + } + + ctx.trans.save_item_model(&p1_mut).await?; + ctx.trans.save_item_model(&p2_mut).await?; + + Ok(Some(time::Duration::from_millis(5000))) + } +} +pub static TASK_HANDLER: &(dyn TaskHandler + Sync + Send) = &ShareTaskHandler; + +async fn inform_player_convo_change_ready( + trans: &DBTrans, + convo: &ActiveConversation, + sess: &ListenerSession, +) -> DResult<()> { + let intensity_commands = vec![ + ConversationIntensity::Slow, + ConversationIntensity::Normal, + ConversationIntensity::Fast, + ] + .into_iter() + .filter(|ci| ci != &convo.current_intensity) + .map(|ci| format!(ansi!("{}"), ci.to_command())) + .join(" or "); + + let topics = style_to_allowed_topics(&convo.style) + .into_iter() + .filter(|t| t != &convo.current_topic) + .map(|t| format!(ansi!("{}"), t.display_command())) + .join(" or "); + + let styles = vec![ + ConversationalStyle::Amicable, + ConversationalStyle::Joking, + ConversationalStyle::Serious, + ] + .into_iter() + .filter(|s| s != &convo.style) + .map(|ci| format!(ansi!("{}"), ci.display())) + .join(" or "); + + trans + .queue_for_session( + sess, + Some(&format!(ansi!( + "It's been long enough that you feel you'd have a good shot at changing the pace \ + (try {}), topic (try {}) or style (try {}) of the conversation. \ + [use share status to check interest levels, and help share \ + to learn more]\n"), + intensity_commands, + topics, + styles, + )), + ) + .await?; + Ok(()) +} + +fn share_skill_to_base_transition_time(skill: f64) -> f64 { + ((13.6666666666666666666667 - skill) * 3.0).max(1.0) +} + +fn compute_conversation_result( + me: &Item, + my_conversation: &ActiveConversation, + them: &Item, + their_conversation: &ActiveConversation, +) -> ConversationResult { + let my_direct_interest = my_conversation + .interest_levels + .iter() + .map(|(_, pl)| pl) + .sum(); + let their_direct_interest: u64 = their_conversation + .interest_levels + .iter() + .map(|(_, pl)| pl) + .sum(); + + let my_theoretical_skill_level: f64 = *(me.total_skills.get(&SkillType::Share).unwrap_or(&8.0)); + let their_theoretical_skill_level: f64 = + *(them.total_skills.get(&SkillType::Share).unwrap_or(&8.0)); + + let my_transition_time = share_skill_to_base_transition_time(my_theoretical_skill_level) as u64; + let their_transition_time = + share_skill_to_base_transition_time(their_theoretical_skill_level) as u64; + + let my_total_interest = my_direct_interest * their_transition_time; + let their_total_interest = their_direct_interest * my_transition_time; + + ConversationResult { + my_total_interest, + their_total_interest, + my_direct_interest, + my_skill_level: my_theoretical_skill_level, + my_transition_time, + their_skill_level: their_theoretical_skill_level, + their_transition_time, + } +} + +fn relevant_interest_types(_character: &Item) -> Vec { + // Static for now + vec![ + ConversationalInterestType::Philosophy, + ConversationalInterestType::LocalGeography, + ConversationalInterestType::Threats, + ConversationalInterestType::Tactics, + ConversationalInterestType::Weather, + ConversationalInterestType::Politics, + ConversationalInterestType::Frivolity, + ] +} + +fn append_interest_graph(msg: &mut String, character: &Item, conversation: &ActiveConversation) { + for pt in relevant_interest_types(character) { + let level = *(conversation.interest_levels.get(&pt).unwrap_or(&0)); + msg.push_str(&format!( + ansi!("{}:\t{}[{}]\n"), + pt.display(), + if pt.display().len() >= 15 { "" } else { "\t" }, + // 10,000 levels, 25 divisions 10000/25 => 400 per bar. + bar_n_of_m(level / 400, 25) + )); + } +} + +pub async fn display_conversation_status( + trans: &DBTrans, + to_whom: &Item, + with_whom: &Item, +) -> DResult<()> { + let conversation = match to_whom.active_conversation.as_ref() { + None => return Ok(()), + Some(v) => v, + }; + let partner_conversation = match with_whom.active_conversation.as_ref() { + None => return Ok(()), + Some(v) => v, + }; + + let result = + compute_conversation_result(&to_whom, &conversation, &with_whom, &partner_conversation); + let mut msg = format!(ansi!("The current conversational style is {}. Type the name of one of the following alternative styles to switch and open different topics: {}\n"), + conversation.style.display(), + conversation.style.transitions().iter().map(|v| v.display()).join(" ")); + let alt_topics: Vec = style_to_allowed_topics(&conversation.style) + .into_iter() + .filter(|t| t != &conversation.current_topic) + .map(|t| { + format!( + ansi!("{} (type {})"), + t.display_readable(), + t.display_command() + ) + }) + .collect(); + msg.push_str(&format!("The current topic is {}. Your current style allows switching to the following topics: {}\n", conversation.current_topic.display_readable(), &alt_topics.join(" or "))); + msg.push_str("Your current interest levels:\n"); + append_interest_graph(&mut msg, to_whom, conversation); + msg.push_str(&format!( + "Total direct interest: {}\n", + (result.my_direct_interest as f64 / 100.0).round() + )); + let partner_txt = with_whom.display_for_sentence(true, 1, true); + if result.their_skill_level < result.my_skill_level { + msg.push_str(&format!("Your interest level is increased as you observe how {} is learning despite being a less skilled conversationalist than you.\n", &partner_txt)); + } + + if to_whom.item_type == "player" { + if let Some((sess, _)) = trans.find_session_for_player(&to_whom.item_code).await? { + trans.queue_for_session(&sess, Some(&msg)).await?; + } + } + Ok(()) +} + +// Designed to be neutral as to whether it is p1 or p2. +fn share_event_name(p1: &Item, p2: &Item) -> String { + let canon_it = if p1.item_code < p2.item_code + || (p1.item_code == p2.item_code && p1.item_type < p2.item_type) + { + p1 + } else { + p2 + }; + canon_it.refstr() +} + +pub async fn start_conversation( + trans: &'_ DBTrans, + initiator: &'_ Item, + acceptor: &'_ Item, +) -> UResult<()> { + if acceptor.item_code == initiator.item_code && acceptor.item_type == initiator.item_type { + user_error("That's you... and that would make you feel a bit lonely.".to_owned())?; + } + + if acceptor.item_type == "player" { + if initiator.item_type != "player" { + user_error("Only players can initiate conversation with players".to_owned())?; + } + let (_other_sess, other_sessdat) = + trans + .find_session_for_player(&acceptor.item_code) + .await? + .ok_or_else(|| { + UserError(format!("You propose sharing knowledge with {}, but {} doesn't seem interested. [That player is currently not logged in]", + acceptor.display_for_sentence(true, 1, false), + &acceptor.pronouns.subject + )) + })?; + trans.delete_expired_user_consent().await?; + trans + .find_user_consent_by_parties_type( + &acceptor.item_code, + &initiator.item_code, + &ConsentType::Share, + ) + .await? + .ok_or_else(|| { + UserError(format!( + ansi!( + "You ask {} to share knowledge, but {} doesn't \ + seem interested. [The other player will need to type \ + allow share from {} before their \ + character will consent to knowledge sharing]" + ), + &acceptor.display_for_sentence(true, 1, false), + &acceptor.pronouns.subject, + initiator.display_for_session(&other_sessdat) + )) + })?; + } else { + if !acceptor.flags.contains(&ItemFlag::AllowShare) { + user_error(format!( + "You ask {} to share knowledge with you, but {} doesn't seem interested.", + &acceptor.display_for_sentence(true, 1, false), + &acceptor.pronouns.subject + ))?; + } + } + + if !initiator.queue.is_empty() { + user_error(ansi!("You're a bit busy right now! [Use the stop command to stop what you are doing].").to_owned())?; + } + + if initiator + .active_combat + .as_ref() + .map(|ac| ac.attacking.is_some()) + .unwrap_or(false) + { + user_error( + "You can share knowledge, or you can fight... but both at once seems like too much!" + .to_owned(), + )?; + } + + if !acceptor.queue.is_empty() + || acceptor + .active_combat + .as_ref() + .map(|ac| ac.attacking.is_some()) + .unwrap_or(false) + { + user_error(format!( + "{} seems to be a bit busy right now!", + acceptor.display_for_sentence(true, 1, true) + ))?; + } + + if acceptor.active_conversation.is_some() { + user_error(format!( + "{} seems to be already deep in conversation!", + acceptor.display_for_sentence(true, 1, true) + ))?; + } + + broadcast_to_room( + trans, + &initiator.location, + None, + &format!( + ansi!("{} proposes to share knowledge with {}, and {} accepts!\n"), + &initiator.display_for_sentence(true, 1, true), + &acceptor.display_for_sentence(false, 1, true), + &acceptor.pronouns.subject + ), + None, + ) + .await?; + + let mut acceptor_mut = (*acceptor).clone(); + let mut initiator_mut = (*initiator).clone(); + acceptor_mut.active_conversation = Some(ActiveConversation { + partner_ref: initiator.refstr(), + last_change: Utc::now(), + ..Default::default() + }); + trans.save_item_model(&acceptor_mut).await?; + initiator_mut.active_conversation = Some(ActiveConversation { + partner_ref: acceptor.refstr(), + last_change: Utc::now(), + ..Default::default() + }); + trans.save_item_model(&initiator_mut).await?; + + trans + .upsert_task(&Task { + meta: TaskMeta { + task_code: share_event_name(&initiator, &acceptor), + next_scheduled: Utc::now() + chrono::Duration::milliseconds(5000), + ..Default::default() + }, + details: TaskDetails::ShareTick, + }) + .await?; + + Ok(()) +} + +pub async fn stop_conversation_mut( + trans: &DBTrans, + participant: &mut Item, + // Should make sense with actor first and other partner after. e.g. walks away from conversation with + leave_description: &str, +) -> DResult<()> { + let (partner_type, partner_code) = match participant + .active_conversation + .as_ref() + .and_then(|ac| ac.partner_ref.split_once("/")) + { + None => return Ok(()), + Some(v) => v, + }; + let partner = match trans + .find_item_by_type_code(partner_type, partner_code) + .await? + { + None => return Ok(()), + Some(v) => v, + }; + let mut partner_mut = (*partner).clone(); + + participant.active_conversation = None; + partner_mut.active_conversation = None; + trans.save_item_model(&partner_mut).await?; + + broadcast_to_room( + trans, + &participant.location, + None, + &format!( + ansi!("{} {} {}.\n"), + &participant.display_for_sentence(true, 1, true), + leave_description, + &partner_mut.display_for_sentence(true, 1, false) + ), + None, + ) + .await?; + + Ok(()) +} + +pub fn parse_conversation_topic(input: &str) -> Result, &'static str> { + let r = alt(( + map( + tuple(( + tag("parody"), + space1::<&str, VerboseError<&str>>, + cut(context( + ansi!("Try parody kings office"), + tuple((tag("kings"), space1, tag("office"))), + )), + )), + |_| Some(ConversationTopic::ParodyKingsOffice), + ), + map( + tuple(( + tag("play"), + space1::<&str, VerboseError<&str>>, + cut(context(ansi!("Try play fight"), tag("fight"))), + )), + |_| Some(ConversationTopic::PlayFight), + ), + preceded( + tuple((tag("thoughts"), space1::<&str, VerboseError<&str>>)), + cut(context( + ansi!( + "Try thoughts on machiavelli or thoughts on sun tzu" + ), + preceded( + tuple((tag("on"), space1)), + alt(( + map( + preceded( + tuple((tag("sun"), space1)), + cut(context( + ansi!("Try thoughts on sun tzu"), + tag("tzu"), + )), + ), + |_| Some(ConversationTopic::ThoughtsOnSunTzu), + ), + map(tag("machiavelli"), |_| { + Some(ConversationTopic::ThoughtsOnMachiavelli) + }), + )), + ), + )), + ), + map( + tuple(( + tag("exploring"), + space1::<&str, VerboseError<&str>>, + cut(context( + ansi!("Try exploring ruins"), + tag("ruins"), + )), + )), + |_| Some(ConversationTopic::ExploringRuins), + ), + map( + tuple(( + tag("roaming"), + space1::<&str, VerboseError<&str>>, + cut(context( + ansi!("Try roaming enemies"), + tag("enemies"), + )), + )), + |_| Some(ConversationTopic::RoamingEnemies), + ), + map( + tuple(( + tag("fishing"), + space1::<&str, VerboseError<&str>>, + cut(context( + ansi!("Try fishing spots"), + tag("spots"), + )), + )), + |_| Some(ConversationTopic::FishingSpots), + ), + map( + tuple(( + tag("good"), + space1::<&str, VerboseError<&str>>, + cut(context( + ansi!("Try good ambush spots"), + tuple((tag("ambush"), space1, tag("spots"))), + )), + )), + |_| Some(ConversationTopic::GoodAmbushSpots), + ), + map( + tuple(( + tag("surviving"), + space1::<&str, VerboseError<&str>>, + cut(context( + ansi!("Try surviving weather"), + tag("weather"), + )), + )), + |_| Some(ConversationTopic::SurvivingWeather), + ), + success(None), + ))(input); + const CATCHALL_ERROR: &str = "Invalid command"; + match r { + Ok((_, result)) => Ok(result), + Err(nom::Err::Error(e)) | Err(nom::Err::Failure(e)) => Err(e + .errors + .into_iter() + .find_map(|k| match k.1 { + VerboseErrorKind::Context(s) => Some(s), + _ => None, + }) + .unwrap_or(CATCHALL_ERROR)), + Err(_) => Err(CATCHALL_ERROR), + } +} + +async fn check_conversation_change<'l>( + ctx: &VerbContext<'_>, + player_item: &'l mut Item, +) -> UResult)>> { + let my_convo = player_item + .active_conversation + .as_ref() + .ok_or_else(|| UserError("You're not in a conversation".to_owned()))?; + + let how_long_ago = (Utc::now() - my_convo.last_change).num_seconds(); + if how_long_ago < 1 { + user_error( + "You can't keep up with the rate of change in the conversation [try again later]" + .to_owned(), + )?; + } + // Changing topic after 20s is 50/50 at level 7, level needed increases for every 3s less. + let level = 13.6666666666666666666667 - (how_long_ago as f64) / 3.0; + let result = skill_check_and_grind(ctx.trans, player_item, &SkillType::Share, level).await?; + if result < -0.5 { + // Mostly to prevent spamming until they succeed, have a chance of resetting timer... + if let Some(ac) = player_item.active_conversation.as_mut() { + ac.last_change = Utc::now(); + } + ctx.trans.queue_for_session(&ctx.session, Some(ansi!("Your attempt to change the conversation goes so awkwardly you not only fail, but feel you'll have to wait longer now to have another shot!\n"))).await?; + return Ok(None); + } else if result < 0.0 { + ctx.trans + .queue_for_session( + &ctx.session, + Some(ansi!( + "Your attempt to change the conversation doesn't pan out.\n" + )), + ) + .await?; + return Ok(None); + } + + // This way lets us pass the borrow checker with the skillcheck. + let my_convo = player_item + .active_conversation + .as_mut() + .ok_or_else(|| UserError("You're not in a conversation".to_owned()))?; + let player_info = my_convo + .partner_ref + .split_once("/") + .ok_or_else(|| UserError("Can't get partner ref".to_owned()))?; + let other_player = ctx + .trans + .find_item_by_type_code(player_info.0, player_info.1) + .await? + .ok_or_else(|| UserError("Partner is gone!".to_owned()))?; + + return Ok(Some((my_convo, other_player))); +} + +fn style_to_initial_topic(style: &ConversationalStyle) -> ConversationTopic { + match style { + ConversationalStyle::Amicable => ConversationTopic::FishingSpots, + ConversationalStyle::Serious => ConversationTopic::RoamingEnemies, + ConversationalStyle::Joking => ConversationTopic::ParodyKingsOffice, + } +} + +fn style_to_allowed_topics(style: &ConversationalStyle) -> Vec { + match style { + ConversationalStyle::Amicable => vec![ + ConversationTopic::FishingSpots, + ConversationTopic::ThoughtsOnSunTzu, + ConversationTopic::ExploringRuins, + ], + ConversationalStyle::Joking => vec![ + ConversationTopic::ParodyKingsOffice, + ConversationTopic::PlayFight, + ConversationTopic::ThoughtsOnMachiavelli, + ], + ConversationalStyle::Serious => vec![ + ConversationTopic::RoamingEnemies, + ConversationTopic::SurvivingWeather, + ConversationTopic::GoodAmbushSpots, + ], + } +} + +fn topic_to_allowed_style(topic: &ConversationTopic) -> ConversationalStyle { + match topic { + ConversationTopic::FishingSpots => ConversationalStyle::Amicable, + ConversationTopic::ThoughtsOnSunTzu => ConversationalStyle::Amicable, + ConversationTopic::ExploringRuins => ConversationalStyle::Amicable, + ConversationTopic::ParodyKingsOffice => ConversationalStyle::Joking, + ConversationTopic::PlayFight => ConversationalStyle::Joking, + ConversationTopic::ThoughtsOnMachiavelli => ConversationalStyle::Joking, + ConversationTopic::RoamingEnemies => ConversationalStyle::Serious, + ConversationTopic::SurvivingWeather => ConversationalStyle::Serious, + ConversationTopic::GoodAmbushSpots => ConversationalStyle::Serious, + } +} + +fn topic_to_interest_growth(style: &ConversationTopic) -> Vec<(ConversationalInterestType, i64)> { + use ConversationTopic::*; + use ConversationalInterestType::*; + match style { + FishingSpots => vec![ + (Philosophy, 0), + (LocalGeography, 2), + (Threats, -1), + (Tactics, -1), + (Weather, 3), + (Politics, -1), + (Frivolity, 2), + ], + ThoughtsOnSunTzu => vec![ + (Philosophy, 2), + (LocalGeography, -1), + (Threats, -1), + (Tactics, 4), + (Weather, -1), + (Politics, 3), + (Frivolity, 0), + ], + ExploringRuins => vec![ + (Philosophy, 1), + (LocalGeography, 4), + (Threats, -1), + (Tactics, -1), + (Weather, 2), + (Politics, -1), + (Frivolity, 2), + ], + ParodyKingsOffice => vec![ + (Philosophy, 1), + (LocalGeography, -1), + (Threats, -1), + (Tactics, -1), + (Weather, -1), + (Politics, 4), + (Frivolity, 3), + ], + PlayFight => vec![ + (Philosophy, 1), + (LocalGeography, -1), + (Threats, 2), + (Tactics, 4), + (Weather, -1), + (Politics, -1), + (Frivolity, 2), + ], + ThoughtsOnMachiavelli => vec![ + (Philosophy, 4), + (LocalGeography, -1), + (Threats, -1), + (Tactics, 2), + (Weather, -1), + (Politics, 4), + (Frivolity, -1), + ], + RoamingEnemies => vec![ + (Philosophy, -1), + (LocalGeography, 1), + (Threats, 4), + (Tactics, 1), + (Weather, -1), + (Politics, 0), + (Frivolity, 0), + ], + SurvivingWeather => vec![ + (Philosophy, -1), + (LocalGeography, 3), + (Threats, 2), + (Tactics, -1), + (Weather, 5), + (Politics, -1), + (Frivolity, -1), + ], + GoodAmbushSpots => vec![ + (Philosophy, -1), + (LocalGeography, 1), + (Threats, 3), + (Tactics, 3), + (Weather, 2), + (Politics, -1), + (Frivolity, -1), + ], + } +} + +pub async fn change_conversational_style( + ctx: &VerbContext<'_>, + player_item: &mut Item, + style: ConversationalStyle, +) -> UResult<()> { + if player_item + .active_conversation + .as_ref() + .map(|ac| ac.style == style) + .unwrap_or(false) + { + user_error(format!( + "You're alreading talking in {} {} style!", + indefinite_article(style.display()), + style.display() + ))?; + } + let (my_convo, other_player) = match check_conversation_change(ctx, player_item).await? { + None => return Ok(()), + Some(c) => c, + }; + + my_convo.last_change = Utc::now(); + my_convo.style = style.clone(); + let topic = style_to_initial_topic(&style); + my_convo.current_topic = topic.clone(); + let mut other_player = (*other_player).clone(); + other_player + .active_conversation + .as_mut() + .map(|other_convo| { + other_convo.style = style.clone(); + other_convo.current_topic = topic.clone(); + }); + ctx.trans.save_item_model(&other_player).await?; + + let alt_topics: Vec = style_to_allowed_topics(&style) + .into_iter() + .filter(|t| t != &topic) + .map(|t| { + format!( + ansi!("{} (type {})"), + t.display_readable(), + t.display_command() + ) + }) + .collect(); + let alt_topics_str: String = + language::join_words_or(&alt_topics.iter().map(|t| t.as_str()).collect::>()); + + let msg = format!(ansi!( + "{} changes the style of conversation with {} to be {}. The conversation drifts to {}, but you realise it could shift to {}\n"), + player_item.display_for_sentence(true, 1, true), + other_player.display_for_sentence(true, 1, false), + style.display(), + topic.display_readable(), + &alt_topics_str + ); + broadcast_to_room(&ctx.trans, &player_item.location, None, &msg, Some(&msg)).await?; + + Ok(()) +} + +pub async fn change_conversation_topic( + ctx: &VerbContext<'_>, + player_item: &mut Item, + topic: ConversationTopic, +) -> UResult<()> { + if player_item + .active_conversation + .as_ref() + .map(|ac| ac.current_topic == topic) + .unwrap_or(false) + { + user_error("That's already the topic!".to_owned())?; + } + + let (my_convo, other_player) = match check_conversation_change(ctx, player_item).await? { + None => return Ok(()), + Some(c) => c, + }; + + let expected_style = topic_to_allowed_style(&topic); + if my_convo.style != expected_style { + user_error(format!( + ansi!( + "You need to switch to be talking in the {} style \ + before you can switch to {}." + ), + expected_style.display(), + topic.display_readable() + ))?; + } + + let mut other_player = (*other_player).clone(); + my_convo.current_topic = topic.clone(); + my_convo.last_change = Utc::now(); + other_player.active_conversation.as_mut().map(|ac| { + ac.current_topic = topic.clone(); + }); + ctx.trans.save_item_model(&other_player).await?; + + let msg = format!( + "{} subtley changes the topic of conversation with {}, and it drifts to {}\n", + player_item.display_for_sentence(true, 1, true), + other_player.display_for_sentence(true, 1, false), + topic.display_readable(), + ); + broadcast_to_room(&ctx.trans, &player_item.location, None, &msg, Some(&msg)).await?; + + Ok(()) +} + +pub async fn change_conversation_intensity( + ctx: &VerbContext<'_>, + player_item: &mut Item, + intensity: ConversationIntensity, +) -> UResult<()> { + if player_item + .active_conversation + .as_ref() + .map(|ac| ac.current_intensity == intensity) + .unwrap_or(false) + { + user_error(format!( + "You're already talking {}", + intensity.display_readable() + ))?; + } + let (my_convo, other_player) = match check_conversation_change(ctx, player_item).await? { + None => return Ok(()), + Some(c) => c, + }; + + my_convo.current_intensity = intensity.clone(); + my_convo.last_change = Utc::now(); + + let mut other_player = (*other_player).clone(); + other_player + .active_conversation + .as_mut() + .map(|ac| ac.current_intensity = intensity.clone()); + ctx.trans.save_item_model(&other_player).await?; + + let msg = format!( + "You notice a change in the pace of the conversation started by {}, and soon picked up by {}. The conversation is now proceeding {}.\n", + player_item.display_for_sentence(true, 1, true), + other_player.display_for_sentence(true, 1, false), + intensity.display_readable(), + ); + broadcast_to_room(&ctx.trans, &player_item.location, None, &msg, Some(&msg)).await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::models::item::ConversationTopic; + + use super::*; + + #[test] + fn parse_conversation_topic_works() { + let cases: Vec<(&str, Result, &str>)> = vec![ + ( + "parody kings office", + Ok(Some(ConversationTopic::ParodyKingsOffice)), + ), + ( + "parody your face", + Err(ansi!("Try parody kings office")), + ), + ("play fight", Ok(Some(ConversationTopic::PlayFight))), + ("play fgight", Err(ansi!("Try play fight"))), + ( + "thoughts on sun tzu", + Ok(Some(ConversationTopic::ThoughtsOnSunTzu)), + ), + ( + "thoughts on sun sets", + Err(ansi!("Try thoughts on sun tzu")), + ), + ( + "thoughts on machiavelli", + Ok(Some(ConversationTopic::ThoughtsOnMachiavelli)), + ), + ( + "thoughts non machiavelli", + Err(ansi!( + "Try thoughts on machiavelli or thoughts on sun tzu" + )), + ), + ( + "exploring ruins", + Ok(Some(ConversationTopic::ExploringRuins)), + ), + ( + "exploring roads", + Err(ansi!("Try exploring ruins")), + ), + ( + "roaming enemies ", + Ok(Some(ConversationTopic::RoamingEnemies)), + ), + ( + "roaming villains", + Err(ansi!("Try roaming enemies")), + ), + ("fishing spots", Ok(Some(ConversationTopic::FishingSpots))), + ( + "fishing sports", + Err(ansi!("Try fishing spots")), + ), + ( + "good ambush spots", + Ok(Some(ConversationTopic::GoodAmbushSpots)), + ), + ( + "good ambush places", + Err(ansi!("Try good ambush spots")), + ), + ("say hello world", Ok(None)), + ]; + for case in cases { + assert_eq!(parse_conversation_topic(case.0), case.1); + } + } + + #[test] + fn style_to_topic_to_style_roundtrips() { + use ConversationalStyle::*; + for style in vec![Serious, Amicable, Joking] { + for topic in style_to_allowed_topics(&style) { + assert_eq!(topic_to_allowed_style(&topic), style); + } + } + } +} diff --git a/blastmud_game/src/services/skills.rs b/blastmud_game/src/services/skills.rs index e767795..9e84ba1 100644 --- a/blastmud_game/src/services/skills.rs +++ b/blastmud_game/src/services/skills.rs @@ -183,12 +183,12 @@ pub fn calculate_total_stats_skills_for_user(target_item: &mut Item, user: &User .or_insert(end * 0.5); target_item .total_skills - .entry(SkillType::Fuck) + .entry(SkillType::Share) .and_modify(|sk| *sk += sen * 0.5) .or_insert(sen * 0.5); target_item .total_skills - .entry(SkillType::Fuck) + .entry(SkillType::Share) .and_modify(|sk| *sk += end * 0.5) .or_insert(end * 0.5); target_item diff --git a/blastmud_game/src/static_content/npc/melbs_citizen.rs b/blastmud_game/src/static_content/npc/melbs_citizen.rs index f5247d7..dead8e7 100644 --- a/blastmud_game/src/static_content/npc/melbs_citizen.rs +++ b/blastmud_game/src/static_content/npc/melbs_citizen.rs @@ -56,7 +56,7 @@ pub fn npc_list() -> Vec { message_handler: None, wander_zones: vec!("melbs".to_owned()), says: vec!(melbs_citizen_stdsay.clone()), - player_consents: vec!(ConsentType::Medicine, ConsentType::Sex), + player_consents: vec!(ConsentType::Medicine, ConsentType::Share), ..Default::default() } ).collect() diff --git a/blastmud_game/src/static_content/npc/statbot.rs b/blastmud_game/src/static_content/npc/statbot.rs index 0503920..541e134 100644 --- a/blastmud_game/src/static_content/npc/statbot.rs +++ b/blastmud_game/src/static_content/npc/statbot.rs @@ -104,7 +104,7 @@ fn points_left(user: &User) -> f64 { (62 - (brn + sen + brw + refl + end + col) as i16).max(0) as f64 } -fn next_action_text(session: &Session, user: &User, item: &Item) -> String { +fn next_action_text(_session: &Session, user: &User, item: &Item) -> String { let brn = user .raw_stats .get(&StatType::Brains) @@ -156,13 +156,12 @@ fn next_action_text(session: &Session, user: &User, item: &Item) -> String { are: ").to_owned() + &summary, StatbotState::Senses => format!(ansi!( "Your next job is to choose how good your senses will be. Senses help your \ - appraise, dodge, focus,{} scavenge, sneak, throw, track and whips skills.\n\ + appraise, dodge, focus, scavenge, share knowledge, sneak, throw, track and \ + whips skills.\n\ \tType -statbot senses 8 (or any other number) to \ set your senses 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: {}"), - if session.less_explicit_mode { - "" - } else { " fuck,"}, &summary), + &summary), StatbotState::Brawn => ansi!( "Your next job is to choose how strong you will be. Brawn helps your \ clubs, fists, and throw skills.\n\ @@ -180,11 +179,11 @@ fn next_action_text(session: &Session, user: &User, item: &Item) -> String { ).to_owned() + &summary, StatbotState::Endurance => format!(ansi!( "Your next job is to choose how much stamina you will have. Endurance helps \ - your climb, fish, fists, focus,{} scavenge, spears and swim skills.\n\ + your climb, fish, fists, focus, scavenge, share knowledge, spears and swim skills.\n\ \tType -statbot endurance 8 (or any other number) to \ set your endurance 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: {}" - ), if session.less_explicit_mode { "" } else { " fuck,"}, &summary), + ), &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, sneak \