1259 lines
44 KiB
Rust
1259 lines
44 KiB
Rust
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<Option<time::Duration>> {
|
|
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::<f32>() < 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(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(1, true),
|
|
intensity_word,
|
|
&act,
|
|
&pb.display_for_sentence(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(1, true),
|
|
intensity_word,
|
|
&act,
|
|
&pb.display_for_sentence(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(1, true),
|
|
intensity_word,
|
|
&thought,
|
|
&pb.display_for_sentence(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(1, true),
|
|
intensity_word,
|
|
&thought,
|
|
&pb.display_for_sentence(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(1, true),
|
|
intensity_word,
|
|
&exploration,
|
|
&pb.display_for_sentence(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(1, true),
|
|
intensity_word,
|
|
&enemy_tales,
|
|
&pb.display_for_sentence(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(1, true),
|
|
intensity_word,
|
|
&fishing_info,
|
|
&pb.display_for_sentence(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(1, true),
|
|
intensity_word,
|
|
&ambush_strategy,
|
|
&pb.display_for_sentence(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(1, true),
|
|
intensity_word,
|
|
&weather_survival,
|
|
&pb.display_for_sentence(1, false),
|
|
)
|
|
}
|
|
};
|
|
let msg = format!(ansi!("<magenta>{}<reset>\n"), &msg);
|
|
broadcast_to_room(&ctx.trans, &p1.location, None, &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<ConversationalInterestType> =
|
|
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!("<bold>{}<reset>"), 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!("<bold>{}<reset>"), 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!("<bold>{}<reset>"), 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 <bold>share status<reset> to check interest levels, and <bold>help share<reset> \
|
|
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<ConversationalInterestType> {
|
|
// 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{}<bold>[<reset><magenta><bgblack>{}<reset><bold>]<reset>\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: <bold>{}<reset>\n"),
|
|
conversation.style.display(),
|
|
conversation.style.transitions().iter().map(|v| v.display()).join(" "));
|
|
let alt_topics: Vec<String> = style_to_allowed_topics(&conversation.style)
|
|
.into_iter()
|
|
.filter(|t| t != &conversation.current_topic)
|
|
.map(|t| {
|
|
format!(
|
|
ansi!("{} (type <bold>{}<reset>)"),
|
|
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(1, false);
|
|
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(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 \
|
|
<bold>allow share from {}<reset> before their \
|
|
character will consent to knowledge sharing]"
|
|
),
|
|
&acceptor.display_for_sentence(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(1, false),
|
|
&acceptor.pronouns.subject
|
|
))?;
|
|
}
|
|
}
|
|
|
|
if !initiator.queue.is_empty() {
|
|
user_error(ansi!("You're a bit busy right now! [Use the <bold>stop<reset> 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(1, true)
|
|
))?;
|
|
}
|
|
|
|
if acceptor.active_conversation.is_some() {
|
|
user_error(format!(
|
|
"{} seems to be already deep in conversation!",
|
|
acceptor.display_for_sentence(1, true)
|
|
))?;
|
|
}
|
|
|
|
broadcast_to_room(
|
|
trans,
|
|
&initiator.location,
|
|
None,
|
|
&format!(
|
|
ansi!("<magenta>{} proposes to share knowledge with {}, and {} accepts!<reset>\n"),
|
|
&initiator.display_for_sentence(1, true),
|
|
&acceptor.display_for_sentence(1, true),
|
|
&acceptor.pronouns.subject
|
|
),
|
|
)
|
|
.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!("<magenta>{} {} {}.<reset>\n"),
|
|
&participant.display_for_sentence(1, true),
|
|
leave_description,
|
|
&partner_mut.display_for_sentence(1, false)
|
|
),
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn parse_conversation_topic(input: &str) -> Result<Option<ConversationTopic>, &'static str> {
|
|
let r = alt((
|
|
map(
|
|
tuple((
|
|
tag("parody"),
|
|
space1::<&str, VerboseError<&str>>,
|
|
cut(context(
|
|
ansi!("Try <bold>parody kings office<reset>"),
|
|
tuple((tag("kings"), space1, tag("office"))),
|
|
)),
|
|
)),
|
|
|_| Some(ConversationTopic::ParodyKingsOffice),
|
|
),
|
|
map(
|
|
tuple((
|
|
tag("play"),
|
|
space1::<&str, VerboseError<&str>>,
|
|
cut(context(ansi!("Try <bold>play fight<reset>"), tag("fight"))),
|
|
)),
|
|
|_| Some(ConversationTopic::PlayFight),
|
|
),
|
|
preceded(
|
|
tuple((tag("thoughts"), space1::<&str, VerboseError<&str>>)),
|
|
cut(context(
|
|
ansi!(
|
|
"Try <bold>thoughts on machiavelli<reset> or <bold>thoughts on sun tzu<reset>"
|
|
),
|
|
preceded(
|
|
tuple((tag("on"), space1)),
|
|
alt((
|
|
map(
|
|
preceded(
|
|
tuple((tag("sun"), space1)),
|
|
cut(context(
|
|
ansi!("Try <bold>thoughts on sun tzu<reset>"),
|
|
tag("tzu"),
|
|
)),
|
|
),
|
|
|_| Some(ConversationTopic::ThoughtsOnSunTzu),
|
|
),
|
|
map(tag("machiavelli"), |_| {
|
|
Some(ConversationTopic::ThoughtsOnMachiavelli)
|
|
}),
|
|
)),
|
|
),
|
|
)),
|
|
),
|
|
map(
|
|
tuple((
|
|
tag("exploring"),
|
|
space1::<&str, VerboseError<&str>>,
|
|
cut(context(
|
|
ansi!("Try <bold>exploring ruins<reset>"),
|
|
tag("ruins"),
|
|
)),
|
|
)),
|
|
|_| Some(ConversationTopic::ExploringRuins),
|
|
),
|
|
map(
|
|
tuple((
|
|
tag("roaming"),
|
|
space1::<&str, VerboseError<&str>>,
|
|
cut(context(
|
|
ansi!("Try <bold>roaming enemies<reset>"),
|
|
tag("enemies"),
|
|
)),
|
|
)),
|
|
|_| Some(ConversationTopic::RoamingEnemies),
|
|
),
|
|
map(
|
|
tuple((
|
|
tag("fishing"),
|
|
space1::<&str, VerboseError<&str>>,
|
|
cut(context(
|
|
ansi!("Try <bold>fishing spots<reset>"),
|
|
tag("spots"),
|
|
)),
|
|
)),
|
|
|_| Some(ConversationTopic::FishingSpots),
|
|
),
|
|
map(
|
|
tuple((
|
|
tag("good"),
|
|
space1::<&str, VerboseError<&str>>,
|
|
cut(context(
|
|
ansi!("Try <bold>good ambush spots<reset>"),
|
|
tuple((tag("ambush"), space1, tag("spots"))),
|
|
)),
|
|
)),
|
|
|_| Some(ConversationTopic::GoodAmbushSpots),
|
|
),
|
|
map(
|
|
tuple((
|
|
tag("surviving"),
|
|
space1::<&str, VerboseError<&str>>,
|
|
cut(context(
|
|
ansi!("Try <bold>surviving weather<reset>"),
|
|
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<Option<(&'l mut ActiveConversation, Arc<Item>)>> {
|
|
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!("<red>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!<reset>\n"))).await?;
|
|
return Ok(None);
|
|
} else if result < 0.0 {
|
|
ctx.trans
|
|
.queue_for_session(
|
|
&ctx.session,
|
|
Some(ansi!(
|
|
"<magenta>Your attempt to change the conversation doesn't pan out.<reset>\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<ConversationTopic> {
|
|
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<String> = style_to_allowed_topics(&style)
|
|
.into_iter()
|
|
.filter(|t| t != &topic)
|
|
.map(|t| {
|
|
format!(
|
|
ansi!("{} (type <bold>{}<reset>)"),
|
|
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::<Vec<&str>>());
|
|
|
|
let msg = format!(ansi!(
|
|
"{} changes the style of conversation with {} to be {}. The conversation drifts to <bold>{}<reset>, but you realise it could shift to {}\n"),
|
|
player_item.display_for_sentence(1, true),
|
|
other_player.display_for_sentence(1, false),
|
|
style.display(),
|
|
topic.display_readable(),
|
|
&alt_topics_str
|
|
);
|
|
broadcast_to_room(&ctx.trans, &player_item.location, None, &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 <bold>{}<reset> 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(1, true),
|
|
other_player.display_for_sentence(1, false),
|
|
topic.display_readable(),
|
|
);
|
|
broadcast_to_room(&ctx.trans, &player_item.location, None, &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(1, true),
|
|
other_player.display_for_sentence(1, false),
|
|
intensity.display_readable(),
|
|
);
|
|
broadcast_to_room(&ctx.trans, &player_item.location, None, &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<Option<ConversationTopic>, &str>)> = vec![
|
|
(
|
|
"parody kings office",
|
|
Ok(Some(ConversationTopic::ParodyKingsOffice)),
|
|
),
|
|
(
|
|
"parody your face",
|
|
Err(ansi!("Try <bold>parody kings office<reset>")),
|
|
),
|
|
("play fight", Ok(Some(ConversationTopic::PlayFight))),
|
|
("play fgight", Err(ansi!("Try <bold>play fight<reset>"))),
|
|
(
|
|
"thoughts on sun tzu",
|
|
Ok(Some(ConversationTopic::ThoughtsOnSunTzu)),
|
|
),
|
|
(
|
|
"thoughts on sun sets",
|
|
Err(ansi!("Try <bold>thoughts on sun tzu<reset>")),
|
|
),
|
|
(
|
|
"thoughts on machiavelli",
|
|
Ok(Some(ConversationTopic::ThoughtsOnMachiavelli)),
|
|
),
|
|
(
|
|
"thoughts non machiavelli",
|
|
Err(ansi!(
|
|
"Try <bold>thoughts on machiavelli<reset> or <bold>thoughts on sun tzu<reset>"
|
|
)),
|
|
),
|
|
(
|
|
"exploring ruins",
|
|
Ok(Some(ConversationTopic::ExploringRuins)),
|
|
),
|
|
(
|
|
"exploring roads",
|
|
Err(ansi!("Try <bold>exploring ruins<reset>")),
|
|
),
|
|
(
|
|
"roaming enemies ",
|
|
Ok(Some(ConversationTopic::RoamingEnemies)),
|
|
),
|
|
(
|
|
"roaming villains",
|
|
Err(ansi!("Try <bold>roaming enemies<reset>")),
|
|
),
|
|
("fishing spots", Ok(Some(ConversationTopic::FishingSpots))),
|
|
(
|
|
"fishing sports",
|
|
Err(ansi!("Try <bold>fishing spots<reset>")),
|
|
),
|
|
(
|
|
"good ambush spots",
|
|
Ok(Some(ConversationTopic::GoodAmbushSpots)),
|
|
),
|
|
(
|
|
"good ambush places",
|
|
Err(ansi!("Try <bold>good ambush spots<reset>")),
|
|
),
|
|
("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);
|
|
}
|
|
}
|
|
}
|
|
}
|