diff --git a/blastmud_game/src/db.rs b/blastmud_game/src/db.rs index eccf74e6..62c52a92 100644 --- a/blastmud_game/src/db.rs +++ b/blastmud_game/src/db.rs @@ -16,7 +16,8 @@ use crate::models::{ Item, LocationActionType, }, - task::{Task, TaskParse} + task::{Task, TaskParse}, + consent::{Consent, ConsentType}, }; use tokio_postgres::types::ToSql; use std::collections::BTreeSet; @@ -730,6 +731,53 @@ impl DBTrans { } Ok(None) } + + pub async fn find_user_consent_by_parties_type(&self, consenting: &str, consented: &str, + consent_type: &ConsentType) -> DResult> { + match self.pg_trans()?.query_opt( + "SELECT details FROM user_consent WHERE consenting_user = $1 AND \ + consented_user = $2 AND consent_type = $3", + &[&consenting, &consented, &ConsentType::to_str(consent_type)] + ).await? { + None => Ok(None), + Some(row) => Ok(Some(serde_json::from_value(row.get(0))?)) + } + } + + pub async fn delete_expired_user_consent(&self) -> DResult<()> { + self.pg_trans()?.execute( + "DELETE FROM user_consent WHERE details->>'expires' < $1", + &[&Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Nanos, true)] + ).await?; + Ok(()) + } + + pub async fn delete_user_consent(&self, + consenting: &str, + consented: &str, + consent_type: &ConsentType) -> DResult<()> { + self.pg_trans()?.execute( + "DELETE FROM user_consent WHERE consenting_user = $1 AND \ + consented_user = $2 AND consent_type = $3", + &[&consenting, &consented, &ConsentType::to_str(consent_type)] + ).await?; + Ok(()) + } + + pub async fn upsert_user_consent(&self, + consenting: &str, + consented: &str, + consent_type: &ConsentType, + details: &Consent + ) -> DResult<()> { + self.pg_trans()? + .execute("INSERT INTO user_consent (consenting_user, consented_user, consent_type, details) VALUES ($1, $2, $3, $4) \ + ON CONFLICT (consenting_user, consented_user, consent_type) DO UPDATE SET \ + details = EXCLUDED.details", + &[&consenting, &consented, &ConsentType::to_str(consent_type), + &serde_json::to_value(details)?]).await?; + Ok(()) + } pub async fn commit(mut self: Self) -> DResult<()> { let trans_opt = self.with_trans_mut(|t| std::mem::replace(t, None)); diff --git a/blastmud_game/src/message_handler/user_commands.rs b/blastmud_game/src/message_handler/user_commands.rs index 11ed724e..c7f23c10 100644 --- a/blastmud_game/src/message_handler/user_commands.rs +++ b/blastmud_game/src/message_handler/user_commands.rs @@ -12,6 +12,7 @@ use once_cell::sync::OnceCell; use std::sync::Arc; mod agree; +mod allow; pub mod attack; mod buy; pub mod drop; @@ -110,6 +111,9 @@ static REGISTERED_COMMANDS: UserVerbRegistry = phf_map! { "down" => movement::VERB, // Other commands (alphabetical except aliases grouped): + "allow" => allow::VERB, + "disallow" => allow::VERB, + "attack" => attack::VERB, "buy" => buy::VERB, "drop" => drop::VERB, diff --git a/blastmud_game/src/message_handler/user_commands/allow.rs b/blastmud_game/src/message_handler/user_commands/allow.rs new file mode 100644 index 00000000..f7341673 --- /dev/null +++ b/blastmud_game/src/message_handler/user_commands/allow.rs @@ -0,0 +1,711 @@ +use super::{ + VerbContext, + UserVerb, + UserVerbRef, + UResult, + user_error, + get_player_item_or_fail, + search_item_for_user, + parsing +}; +use async_trait::async_trait; +use crate::{ + models::{ + consent::{Consent, ConsentType, ConsentStatus, FightConsent}, + item::Item + }, + db::ItemSearchParams, + static_content::room::room_map_by_code, +}; + +#[derive(Debug, PartialEq)] +pub enum ConsentTarget<'t> { + CorpTarget { from_corp: &'t str, to_corp: &'t str }, + UserTarget { to_user: &'t str } +} + +#[derive(Debug, PartialEq)] +pub struct ConsentDetails<'t> { + pub duration_minutes: Option, + pub until_death: bool, + pub allow_private: bool, + pub only_in: Vec<&'t str>, + pub allow_pick: bool, + pub freely_revoke: bool, +} + +impl <'t>ConsentDetails<'t> { + pub fn default_for(tp: &ConsentType) -> Self { + Self { + duration_minutes: None, + until_death: false, + allow_private: tp != &ConsentType::Fight, + only_in: vec!(), + allow_pick: false, + freely_revoke: false, + } + } + + pub fn as_string(&self, consent_type: &ConsentType) -> String { + let mut buf = String::new(); + match self.duration_minutes { + None => {}, + Some(n) => buf.push_str(&format!("{} minutes ", n)) + } + if self.until_death { + buf.push_str("until death "); + } + if *consent_type == ConsentType::Fight { + if self.allow_private { + buf.push_str("allow private "); + } + if self.allow_pick { + buf.push_str("allow pick "); + } + if self.freely_revoke { + buf.push_str("allow revoke "); + } + } else { + if !self.allow_private { + buf.push_str("disallow private "); + } + } + for loc in &self.only_in { + buf.push_str(&format!("in {} ", loc)); + } + buf + } +} + +#[derive(Debug, PartialEq)] +pub struct AllowCommand<'t> { + pub consent_type: ConsentType, + pub consent_target: ConsentTarget<'t>, + pub consent_details: ConsentDetails<'t> +} + +#[derive(Debug)] +pub struct ConsentUpdateOutput { + pub new_consent: Option, + pub first_party_message: Option, + pub counterparty_message: Option, + pub mirror_to_counterparty: bool +} + +fn compute_new_consent_state( + player_display: &str, + player_possessive: &str, + counterplayer_display: &str, + counterplayer_possessive: &str, + selector: &str, + consent_type: &ConsentType, + new_consent_details: &ConsentDetails, + my_current_consent: &Option, + their_current_consent: &Option, + is_allow: bool) -> ConsentUpdateOutput { + if !is_allow { + if consent_type != &ConsentType::Fight { + if my_current_consent.is_none() { + return ConsentUpdateOutput { + new_consent: None, + first_party_message: Some("There was no matching consent in force to disallow".to_owned()), + counterparty_message: None, + mirror_to_counterparty: false + } + } else { + return ConsentUpdateOutput { + new_consent: None, + first_party_message: + Some(format!("You no longer allow {} from {}", + ConsentType::to_str(consent_type), counterplayer_display)), + counterparty_message: + Some(format!("{} no longer allows {} from you", player_display, + ConsentType::to_str(consent_type))), + mirror_to_counterparty: false + } + } + } + + if my_current_consent.as_ref().and_then(|c| c.fight_consent.as_ref()) + .map(|c| (c.status == ConsentStatus::PendingAdd)) == Some(true) { + return ConsentUpdateOutput { + new_consent: None, + first_party_message: + Some(format!("You are no longer offering to fight {}", + counterplayer_display)), + counterparty_message: + Some(format!("{} no longer wants to fight you", player_display)), + mirror_to_counterparty: false + }; + } + + if their_current_consent.as_ref().and_then(|c| c.fight_consent.as_ref()) + .map(|c| c.freely_revoke || (c.status == ConsentStatus::PendingDelete)) == Some(true) { + return ConsentUpdateOutput { + new_consent: None, + first_party_message: + Some(format!("You no longer allow {} from {}", + ConsentType::to_str(consent_type), counterplayer_display)), + counterparty_message: + Some(format!("{} no longer allows {} from you", player_display, + ConsentType::to_str(consent_type))), + mirror_to_counterparty: true + }; + } + match my_current_consent.as_ref() { + None => return ConsentUpdateOutput { + new_consent: None, + first_party_message: Some("There was no matching consent in force to disallow".to_owned()), + counterparty_message: None, + mirror_to_counterparty: false + }, + Some(c) if c.fight_consent.as_ref().map(|fc| &fc.status) == Some(&ConsentStatus::PendingDelete) => + return ConsentUpdateOutput { + new_consent: Some((*c).clone()), + first_party_message: Some(format!("You are already waiting on {} to agree to cancel consent to fight.", + counterplayer_display + )), + counterparty_message: None, + mirror_to_counterparty: false + }, + Some(c) => return ConsentUpdateOutput { + new_consent: Some(Consent { + fight_consent: c.fight_consent.as_ref().map(|fc| FightConsent { + status: ConsentStatus::PendingDelete, pending_change: None, ..*fc }), + ..((*c).clone()) }), + first_party_message: + Some(format!("Informing {} of your desire to withdraw consent to fight. The terms of your previous consent \ + were that it was not freely revokable, so the change will only take effect on {} \ + acceptance.", + counterplayer_display, counterplayer_possessive)), + counterparty_message: + Some(format!("{} wants to withdraw {} consent to fight, but only can if you agree. To agree, type disallow \ + fight {}", + player_display, player_possessive, selector)), + mirror_to_counterparty: false + } + } + } + + let their_target_consent = their_current_consent.as_ref().and_then( + |c| c.fight_consent.as_ref() + .map(|fc| fc.pending_change.as_ref() + .map(|c| (**c).clone()).unwrap_or(c.clone()))); + let mut new_consent = Consent::default(); + new_consent.only_in = new_consent_details.only_in.iter().map(|v| (*v).to_owned()).collect(); + let expires_minutes = if *consent_type != ConsentType::Fight { + new_consent_details.duration_minutes + } else { + match new_consent_details.duration_minutes { + None => Some(60 * 24 * 7), + Some(n) => Some(n.min(60 * 24 * 7)) + } + }; + new_consent.expires = expires_minutes + .map(|n| chrono::Utc::now() + + chrono::Duration::minutes(n.min(60 * 24 * 365 * 25) as i64)); + match their_target_consent.as_ref().and_then(|c| c.expires).and_then( + |t_them| new_consent.expires.map(|t_me| (t_me, t_them))) { + Some((t_me, t_them)) if (t_me - t_them).num_minutes().abs() < 5 => { + new_consent.expires = Some(t_them); + } + _ => {} + } + new_consent.allow_private = new_consent_details.allow_private; + new_consent.until_death = new_consent_details.until_death; + + new_consent.fight_consent = if *consent_type == ConsentType::Fight { + Some(FightConsent { + pending_change: None, + allow_pick: new_consent_details.allow_pick, + freely_revoke: new_consent_details.freely_revoke, + ..FightConsent::default() + }) + } else { + None + }; + + if *consent_type == ConsentType::Fight { + if Some(&new_consent) == their_target_consent.as_ref() { + match new_consent.fight_consent.as_mut() { + None => (), + Some(mut m) => { + m.pending_change = None; + m.status = ConsentStatus::Active; + } + } + return ConsentUpdateOutput { + new_consent: Some(new_consent), + first_party_message: Some(format!("You can now fight {} - it's on!", + counterplayer_display)), + counterparty_message: Some(format!("{} accepted your offer to fight - it's on!", + player_display)), + mirror_to_counterparty: true + }; + } + match my_current_consent.as_ref() { + None => { + match new_consent.fight_consent.as_mut() { + None => (), + Some(mut m) => { m.status = ConsentStatus::PendingAdd; } + } + return ConsentUpdateOutput { + new_consent: Some(new_consent), + first_party_message: Some(format!("{} has been asked for consent to fight.", counterplayer_display)), + counterparty_message: Some(format!("{} wants to fight! To accept, type: allow fight {} {}", + player_display, selector, + &new_consent_details.as_string(consent_type))), + mirror_to_counterparty: false + }; + }, + Some(current) if current.fight_consent.as_ref().map(|fc| fc.status == ConsentStatus::PendingAdd) == Some(true) => { + return ConsentUpdateOutput { + new_consent: Some(new_consent), + first_party_message: Some("Waiting for the other side to accept on the new terms".to_owned()), + counterparty_message: + Some(format!("{} changed {} proposed fight terms! To accept, type allow fight {} {}", + player_display, player_possessive, selector, &new_consent_details.as_string(consent_type))), + mirror_to_counterparty: false + }; + }, + Some(current) => { + return ConsentUpdateOutput { + new_consent: Some(Consent { + fight_consent: current.fight_consent.as_ref().map(|fc| FightConsent { + pending_change: Some(Box::new(new_consent)), + ..(*fc).clone() + }), + ..(*current).clone() + }), + first_party_message: Some("Waiting for the other side to accept the change of terms".to_owned()), + counterparty_message: + Some(format!("{} wants to amend the terms of the fight! To accept, type allow fight {} {}", + player_display, selector, &new_consent_details.as_string(consent_type))), + mirror_to_counterparty: false + }; + } + } + } + + ConsentUpdateOutput { + new_consent: Some(new_consent), + first_party_message: Some(format!("You now allow {} from {}", consent_type.to_str(), counterplayer_display)), + counterparty_message: Some(format!("{} now allows {} from you", player_display, consent_type.to_str())), + mirror_to_counterparty: false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compute_new_consent_state_cancels_non_fight() { + let result = + compute_new_consent_state( + "Foo", "his", "Bar", "her", "from bar", + &ConsentType::Sex, + &ConsentDetails::default_for(&ConsentType::Sex), + &Some(Consent::default()), + &None, + false + ); + assert_eq!(result.new_consent, None); + assert_eq!(result.mirror_to_counterparty, false); + assert_eq!(result.first_party_message.is_some(), true); + assert_eq!(result.counterparty_message.is_some(), true); + } + + #[test] + fn compute_new_consent_state_disallow_puts_into_pending_delete_fight() { + let result = + compute_new_consent_state( + "Foo", "his", "Bar", "her", "from bar", + &ConsentType::Fight, + &ConsentDetails::default_for(&ConsentType::Fight), + &Some(Consent { + fight_consent: Some(FightConsent { + status: ConsentStatus::Active, + ..FightConsent::default() + }), + ..Consent::default() + }), + &Some(Consent { + fight_consent: Some(FightConsent { + status: ConsentStatus::Active, + ..FightConsent::default() + }), + ..Consent::default() + }), + false + ); + assert_eq!(result.new_consent.is_some(), true); + assert_eq!(result.new_consent.unwrap() + .fight_consent.unwrap().status, ConsentStatus::PendingDelete); + assert_eq!(result.first_party_message.is_some(), true); + assert_eq!(result.counterparty_message.is_some(), true); + assert_eq!(result.mirror_to_counterparty, false); + } + + #[test] + fn compute_new_consent_state_disallow_cancels_fight_if_freely_revoke() { + let result = + compute_new_consent_state( + "Foo", "his", "Bar", "her", "from bar", + &ConsentType::Fight, + &ConsentDetails::default_for(&ConsentType::Fight), + &Some(Consent { + fight_consent: Some(FightConsent { + status: ConsentStatus::Active, + freely_revoke: true, + ..FightConsent::default() + }), + ..Consent::default() + }), + &Some(Consent { + fight_consent: Some(FightConsent { + status: ConsentStatus::Active, + freely_revoke: true, + ..FightConsent::default() + }), + ..Consent::default() + }), + false + ); + assert_eq!(result.new_consent, None); + assert_eq!(result.first_party_message.is_some(), true); + assert_eq!(result.counterparty_message.is_some(), true); + assert_eq!(result.mirror_to_counterparty, true); + } + + #[test] + fn compute_new_consent_state_disallow_cancels_pending_delete_fight() { + let result = + compute_new_consent_state( + "Foo", "his", "Bar", "her", "from bar", + &ConsentType::Fight, + &ConsentDetails::default_for(&ConsentType::Fight), + &Some(Consent { + fight_consent: Some(FightConsent { + status: ConsentStatus::Active, + ..FightConsent::default() + }), + ..Consent::default() + }), + &Some(Consent { + fight_consent: Some(FightConsent { + status: ConsentStatus::PendingDelete, + ..FightConsent::default() + }), + ..Consent::default() + }), + false + ); + assert_eq!(result.new_consent, None); + assert_eq!(result.first_party_message.is_some(), true); + assert_eq!(result.counterparty_message.is_some(), true); + assert_eq!(result.mirror_to_counterparty, true); + } + + #[test] + fn compute_new_consent_state_unilateral_double_disallow_doesnt_cancel() { + let result = + compute_new_consent_state( + "Foo", "his", "Bar", "her", "from bar", + &ConsentType::Fight, + &ConsentDetails::default_for(&ConsentType::Fight), + &Some(Consent { + fight_consent: Some(FightConsent { + status: ConsentStatus::PendingDelete, + ..FightConsent::default() + }), + ..Consent::default() + }), + &Some(Consent { + fight_consent: Some(FightConsent { + status: ConsentStatus::Active, + ..FightConsent::default() + }), + ..Consent::default() + }), + false + ); + assert_eq!(result.new_consent.is_some(), true); + assert_eq!(result.first_party_message.is_some(), true); + assert_eq!(result.counterparty_message, None); + assert_eq!(result.mirror_to_counterparty, false); + } + + #[test] + fn compute_new_consent_state_adds_nonfight_immediately() { + let result = + compute_new_consent_state( + "Foo", "his", "Bar", "her", "from bar", + &ConsentType::Sex, + &ConsentDetails::default_for(&ConsentType::Sex), + &None, + &None, + true + ); + assert_eq!(result.new_consent.is_some(), true); + assert_eq!(result.mirror_to_counterparty, false); + assert_eq!(result.first_party_message.is_some(), true); + assert_eq!(result.counterparty_message.is_some(), true); + } + + #[test] + fn compute_new_consent_state_creates_pending_add_for_fight() { + let result = + compute_new_consent_state( + "Foo", "his", "Bar", "her", "from bar", + &ConsentType::Fight, + &ConsentDetails::default_for(&ConsentType::Fight), + &None, + &None, + true + ); + assert_eq!(result.new_consent.is_some(), true); + assert_eq!(result.new_consent.unwrap().fight_consent.unwrap().status, ConsentStatus::PendingAdd); + assert_eq!(result.mirror_to_counterparty, false); + assert_eq!(result.first_party_message.is_some(), true); + assert_eq!(result.counterparty_message.is_some(), true); + } + + #[test] + fn compute_new_consent_state_goes_active_if_pending_add_matches() { + let result = + compute_new_consent_state( + "Foo", "his", "Bar", "her", "from bar", + &ConsentType::Fight, + &ConsentDetails::default_for(&ConsentType::Fight), + &None, + &Some(Consent { + expires: Some(chrono::Utc::now() + chrono::Duration::minutes(60 * 24 * 7)), + fight_consent: Some(FightConsent::default()), + ..Consent::default() + }), + true + ); + assert_eq!(result.new_consent.is_some(), true); + assert_eq!(result.new_consent.unwrap().fight_consent.unwrap().status, ConsentStatus::Active); + assert_eq!(result.mirror_to_counterparty, true); + assert_eq!(result.first_party_message.is_some(), true); + assert_eq!(result.counterparty_message.is_some(), true); + } + + #[test] + fn compute_new_consent_state_creates_pending_update_to_change_fight() { + let result = + compute_new_consent_state( + "Foo", "his", "Bar", "her", "from bar", + &ConsentType::Fight, + &ConsentDetails { + freely_revoke: true, + ..ConsentDetails::default_for(&ConsentType::Fight) + }, + &Some(Consent { + expires: Some(chrono::Utc::now() + chrono::Duration::minutes(60 * 24 * 7)), + fight_consent: Some(FightConsent { + status: ConsentStatus::Active, + ..FightConsent::default() + }), + ..Consent::default() + }), + &Some(Consent { + expires: Some(chrono::Utc::now() + chrono::Duration::minutes(60 * 24 * 7)), + fight_consent: Some(FightConsent { + status: ConsentStatus::Active, + ..FightConsent::default() + }), + ..Consent::default() + }), + true + ); + assert_eq!(result.new_consent.is_some(), true); + let fc = result.new_consent.as_ref().unwrap().fight_consent.as_ref().unwrap(); + assert_eq!(fc.status, ConsentStatus::Active); + assert_eq!(fc.freely_revoke, false); + assert_eq!(fc.pending_change.is_some(), true); + assert_eq!(fc.pending_change.as_ref().unwrap() + .fight_consent.as_ref().unwrap().freely_revoke, true); + assert_eq!(result.mirror_to_counterparty, false); + assert_eq!(result.first_party_message.is_some(), true); + assert_eq!(result.counterparty_message.is_some(), true); + } + + #[test] + fn compute_new_consent_state_accepts_pending_update_to_change_fight_if_matches() { + let result = + compute_new_consent_state( + "Foo", "his", "Bar", "her", "from bar", + &ConsentType::Fight, + &ConsentDetails { + freely_revoke: true, + ..ConsentDetails::default_for(&ConsentType::Fight) + }, + &Some(Consent { + expires: Some(chrono::Utc::now() + chrono::Duration::minutes(60 * 24 * 7)), + fight_consent: Some(FightConsent { + status: ConsentStatus::Active, + ..FightConsent::default() + }), + ..Consent::default() + }), + &Some(Consent { + expires: Some(chrono::Utc::now() + chrono::Duration::minutes(60 * 24 * 7)), + fight_consent: Some(FightConsent { + status: ConsentStatus::Active, + pending_change: Some(Box::new( + Consent { + expires: Some(chrono::Utc::now() + chrono::Duration::minutes(60 * 24 * 7)), + fight_consent: Some(FightConsent { + freely_revoke: true, + ..FightConsent::default() + }), + ..Consent::default() + } + )), + ..FightConsent::default() + }), + ..Consent::default() + }), + true + ); + assert_eq!(result.new_consent.is_some(), true); + let fc = result.new_consent.as_ref().unwrap().fight_consent.as_ref().unwrap(); + assert_eq!(fc.status, ConsentStatus::Active); + assert_eq!(fc.freely_revoke, true); + assert_eq!(fc.pending_change, None); + assert_eq!(result.mirror_to_counterparty, true); + assert_eq!(result.first_party_message.is_some(), true); + assert_eq!(result.counterparty_message.is_some(), true); + } + +} + +async fn handle_user_consent(ctx: &mut VerbContext<'_>, source_player: &Item, + to_username: &str, is_allow: bool, cmd: &AllowCommand<'_>) -> UResult<()> { + ctx.trans.delete_expired_user_consent().await?; + let to_user_item = search_item_for_user(ctx, &ItemSearchParams { + include_all_players: true, + ..ItemSearchParams::base(source_player, to_username) + }).await?; + + if source_player.item_code == to_user_item.item_code { + user_error("You know that's you, right?".to_owned())?; + } + + let current_consent = ctx.trans.find_user_consent_by_parties_type( + &source_player.item_code, + &to_user_item.item_code, + &cmd.consent_type + ).await?; + let converse_consent = if cmd.consent_type == ConsentType::Fight { + ctx.trans.find_user_consent_by_parties_type( + &to_user_item.item_code, + &source_player.item_code, + &cmd.consent_type + ).await? + } else { + None + }; + + let update = compute_new_consent_state( + &source_player.display_for_sentence(false, 1, false), + &source_player.pronouns.possessive, + &to_user_item.display_for_sentence(false, 1, false), + &to_user_item.pronouns.possessive, + &("from ".to_owned() + &source_player.item_code), + &cmd.consent_type, &cmd.consent_details, + ¤t_consent, &converse_consent, is_allow + ); + + match update.new_consent.as_ref() { + None => ctx.trans.delete_user_consent( + &source_player.item_code, + &to_user_item.item_code, + &cmd.consent_type + ).await?, + Some(consent) => ctx.trans.upsert_user_consent( + &source_player.item_code, + &to_user_item.item_code, + &cmd.consent_type, + consent + ).await?, + } + if update.mirror_to_counterparty { + match update.new_consent.as_ref() { + None => ctx.trans.delete_user_consent( + &to_user_item.item_code, + &source_player.item_code, + &cmd.consent_type + ).await?, + Some(consent) => ctx.trans.upsert_user_consent( + &to_user_item.item_code, + &source_player.item_code, + &cmd.consent_type, + consent + ).await?, + } + } + + match update.first_party_message { + None => {}, + Some(msg) => ctx.trans.queue_for_session(&ctx.session, Some(&(msg + "\n"))).await? + } + match update.counterparty_message { + None => {}, + Some(msg) => { + match ctx.trans.find_session_for_player(&to_user_item.item_code).await? { + None => {}, + Some((session, _)) => + ctx.trans.queue_for_session(&session, Some(&(msg + "\n"))).await? + } + } + } + + Ok(()) +} + +pub struct Verb; +#[async_trait] +impl UserVerb for Verb { + async fn handle(self: &Self, ctx: &mut VerbContext, verb: &str, remaining: &str) -> UResult<()> { + let player_item = get_player_item_or_fail(ctx).await?; + let is_allow = verb == "allow"; + + let remaining_trim = remaining.trim(); + if remaining_trim == "" { + // TODO: List all allows + } else { + let mut cmd = match parsing::parse_allow(remaining, !ctx.session_dat.less_explicit_mode) { + Err(msg) => user_error(msg)?, + Ok(cmd) => cmd + }; + for place in cmd.consent_details.only_in.iter_mut() { + if place == &"here" { + let loc_code = match player_item.location.split_once("/") { + None => user_error("Can't use \"in here\" where you are now".to_owned())?, + Some((loc_type, _)) if loc_type != "room" => + user_error("Can't use \"in here\" outside a public room".to_owned())?, + Some((_, loc_code)) => loc_code + }; + *place = loc_code; + } + if room_map_by_code().get(*place).is_none() { + user_error(format!("Place {} not found", *place))? + } + } + match cmd.consent_target { + ConsentTarget::CorpTarget { .. } => user_error( + "Corporate allow/disallow not implemented yet".to_owned())?, + ConsentTarget::UserTarget { to_user } => + handle_user_consent(ctx, &player_item, to_user, is_allow, &cmd).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/attack.rs b/blastmud_game/src/message_handler/user_commands/attack.rs index f98eb3ec..1efa5ed9 100644 --- a/blastmud_game/src/message_handler/user_commands/attack.rs +++ b/blastmud_game/src/message_handler/user_commands/attack.rs @@ -5,8 +5,12 @@ use ansi::ansi; use crate::{ services::{ combat::start_attack, + check_consent, + }, + models::{ + consent::ConsentType, + item::ItemFlag, }, - db::ItemSearchParams, }; @@ -26,6 +30,18 @@ impl UserVerb for Verb { ..ItemSearchParams::base(&player_item, remaining) }).await?; + let (loctype, loccode) = match player_item.location.split_once("/") { + None => user_error("Your current location is invalid!".to_owned())?, + Some(l) => l + }; + let player_loc = match ctx.trans.find_item_by_type_code(loctype, loccode).await? { + None => user_error("Your current location is invalid!".to_owned())?, + Some(l) => l + }; + if player_loc.flags.contains(&ItemFlag::NoSeeContents) { + user_error("It is too foggy to even see who is here, let alone attack!".to_owned())?; + } + match attack_whom.item_type.as_str() { "npc" => {} "player" => {}, @@ -36,9 +52,8 @@ impl UserVerb for Verb { user_error("That's you, silly!".to_string())? } - if attack_whom.is_challenge_attack_only { - // Add challenge check here. - user_error(ansi!("Your wristpad vibrates and blocks you from doing that. You get a feeling that while the empire might be gone, the system to stop subjects with working wristpads from fighting each unless they have an accepted challenge is very much functional. [Try help challenge]").to_string())? + if !check_consent(ctx.trans, "attack", &ConsentType::Fight, &player_item, &attack_whom).await? { + user_error(ansi!("Your wristpad vibrates and blocks you from doing that. You get a feeling that while the empire might be gone, the system to stop subjects with working wristpads from fighting each unless they have an consented is very much functional. [Try help allow]").to_string())? } if attack_whom.is_dead { diff --git a/blastmud_game/src/message_handler/user_commands/parsing.rs b/blastmud_game/src/message_handler/user_commands/parsing.rs index 5b475fb3..b0ee5a42 100644 --- a/blastmud_game/src/message_handler/user_commands/parsing.rs +++ b/blastmud_game/src/message_handler/user_commands/parsing.rs @@ -1,12 +1,15 @@ use nom::{ bytes::complete::{take_till, take_till1, take_while}, - character::{complete::{space0, space1, alpha1, one_of, char, u8}}, + character::{complete::{space0, space1, alpha1, one_of, char, u8, u16}}, combinator::{recognize, fail, eof}, sequence::terminated, branch::alt, error::{context, VerboseError, VerboseErrorKind}, IResult, }; +use super::allow::{AllowCommand, ConsentTarget, ConsentDetails}; +use ansi::{ansi, strip_special_characters}; +use crate::models::consent::ConsentType; pub fn parse_command_name(input: &str) -> (&str, &str) { fn parse(input: &str) -> IResult<&str, &str> { @@ -85,6 +88,157 @@ pub fn parse_on_or_default<'l>(input: &'l str, default_on: &'l str) -> (&'l str, } } +pub fn parse_duration_mins<'l>(input: &'l str) -> Result<(u64, &'l str), String> { + let (input, number) = match u16::<&'l str, ()>(input) { + Err(_) => Err("Invalid number - duration should start with a number, e.g. 5 minutes")?, + Ok(n) => n + }; + let (tok, input) = match input.trim_start().split_once(" ") { + None => (input, ""), + Some(v) => v + }; + Ok((match tok.to_lowercase().as_str() { + "min" | "mins" | "minute" | "minutes" => number as u64, + "h" | "hr" | "hrs" | "hour" | "hours" => (number as u64) * 60, + "d" | "day" | "days" => (number as u64) * 60 * 24, + "w" | "wk" | "wks" | "week" | "weeks" => (number as u64) * 60 * 24 * 7, + _ => Err("Duration number needs to be followed by a valid unit - minutes, hours, days or weeks")? + }, input)) +} + +pub fn parse_allow<'l>(input: &'l str, is_explicit: bool) -> 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(" ") { + None => Err(usage), + Some(v) => 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" + }), + Some(ct) => Ok(ct) + }?; + + let (tok, mut input) = match input.trim_start().split_once(" ") { + None => Err(usage), + Some(v) => Ok(v) + }?; + let tok_trim = tok.trim_start().to_lowercase(); + let consent_target = + if tok_trim == "against" { + if consent_type != ConsentType::Fight { + Err("corps can only currently consent to fight, no other actions")? + } else { + let (my_corp_raw, new_input) = match input.trim_start().split_once(" ") { + None => Err(usage), + Some(v) => Ok(v) + }?; + let my_corp = my_corp_raw.trim_start(); + let (tok, new_input) = match new_input.trim_start().split_once(" ") { + None => Err(usage), + Some(v) => Ok(v) + }?; + if tok.trim_start().to_lowercase() != "by" { + Err(usage)?; + } + let (target_corp_raw, new_input) = match new_input.trim_start().split_once(" ") { + None => (new_input.trim_start(), ""), + Some(v) => v + }; + input = new_input; + ConsentTarget::CorpTarget { from_corp: my_corp, to_corp: target_corp_raw.trim_start() } + } + } else if tok_trim == "from" { + let (target_user_raw, new_input) = match input.trim_start().split_once(" ") { + None => (input.trim_start(), ""), + Some(v) => v + }; + input = new_input; + ConsentTarget::UserTarget { to_user: target_user_raw.trim_start() } + } else { + Err(usage)? + }; + + let mut consent_details = ConsentDetails::default_for(&consent_type); + loop { + input = input.trim_start(); + if input == "" { + break; + } + let (tok, new_input) = match input.split_once(" ") { + None => (input, ""), + Some(v) => v + }; + match tok.to_lowercase().as_str() { + "for" => { + let (minutes, new_input) = parse_duration_mins(new_input)?; + input = new_input; + consent_details.duration_minutes = Some(minutes); + } + "until" => { + let (tok, new_input) = match new_input.split_once(" ") { + None => (input, ""), + Some(v) => v + }; + if tok.trim_start().to_lowercase() != "death" { + Err("Option until needs to be followed with death - until death")? + } + consent_details.until_death = true; + input = new_input; + } + "allow" => { + let (tok, new_input) = match new_input.split_once(" ") { + None => (new_input, ""), + Some(v) => v + }; + match tok.trim_start().to_lowercase().as_str() { + "private" => { + consent_details.allow_private = true; + }, + "pick" => { + consent_details.allow_pick = true; + }, + "revoke" => { + consent_details.freely_revoke = true; + }, + _ => Err("Option allow needs to be followed with private, pick or revoke - allow private | allow pick | allow revoke")? + } + input = new_input; + } + "disallow" => { + let (tok, new_input) = match new_input.split_once(" ") { + None => (new_input, ""), + Some(v) => v + }; + match tok.trim_start().to_lowercase().as_str() { + "private" => { + consent_details.allow_private = false; + }, + "pick" => { + consent_details.allow_pick = false; + }, + _ => Err("Option disallow needs to be followed with private or pick - disallow private | disallow pick")? + } + input = new_input; + } + "in" => { + let (tok, new_input) = match new_input.split_once(" ") { + None => (new_input, ""), + Some(v) => v + }; + consent_details.only_in.push(tok); + input = new_input; + } + _ => Err(format!("I don't understand the option \"{}\"", strip_special_characters(tok)))? + } + } + + + Ok(AllowCommand { consent_type: consent_type, consent_target: consent_target, consent_details: consent_details }) +} + #[cfg(test)] mod tests { use super::*; @@ -182,4 +336,42 @@ mod tests { fn parse_offset_supports_offset() { assert_eq!(parse_offset("2.hello world"), (Some(2), "hello world")) } + + #[test] + fn parse_consent_works_default_options_user() { + assert_eq!(super::parse_allow("medicine From Athorina", false), + Ok(AllowCommand { + consent_type: ConsentType::Medicine, + consent_target: ConsentTarget::UserTarget { to_user: "Athorina" }, + consent_details: ConsentDetails::default_for(&ConsentType::Medicine) + })) + } + + #[test] + fn parse_consent_works_default_options_corp() { + assert_eq!(super::parse_allow("Fight Against megacorp By supercorp", false), + Ok(AllowCommand { + consent_type: ConsentType::Fight, + consent_target: ConsentTarget::CorpTarget { from_corp: "megacorp", to_corp: "supercorp" }, + consent_details: ConsentDetails::default_for(&ConsentType::Fight) + })) + } + + #[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), + Ok(AllowCommand { + consent_type: ConsentType::Fight, + consent_target: ConsentTarget::UserTarget { to_user: "athorina" }, + consent_details: ConsentDetails { + duration_minutes: Some(120), + until_death: true, + allow_private: true, + allow_pick: false, + freely_revoke: true, + only_in: vec!("here", "pit"), + ..ConsentDetails::default_for(&ConsentType::Fight) + } + })) + } } diff --git a/blastmud_game/src/message_handler/user_commands/register.rs b/blastmud_game/src/message_handler/user_commands/register.rs index 18ca7488..7108c767 100644 --- a/blastmud_game/src/message_handler/user_commands/register.rs +++ b/blastmud_game/src/message_handler/user_commands/register.rs @@ -5,10 +5,42 @@ use crate::models::{user::User, item::{Item, Pronouns}}; use chrono::Utc; use ansi::ansi; use tokio::time; +use once_cell::sync::OnceCell; +use std::collections::HashSet; + +pub fn is_invalid_username(name: &str) -> bool { + static INVALID_PREFIXES: OnceCell> = OnceCell::new(); + static INVALID_SUFFIXES: OnceCell> = OnceCell::new(); + static INVALID_WORDS: OnceCell> = OnceCell::new(); + let invalid_prefixes = INVALID_PREFIXES.get_or_init(|| vec!( + "admin", "god", "helper", "npc", "corpse", "dead" + )); + let invalid_suffixes = INVALID_SUFFIXES.get_or_init(|| vec!( + "bot" + )); + let invalid_words = INVALID_WORDS.get_or_init(|| HashSet::from( + ["corp", "to", "from", "dog", "bot"] + )); + if invalid_words.contains(name) { + return true; + } + for pfx in invalid_prefixes.iter() { + if name.starts_with(pfx) { + return true; + } + } + for sfx in invalid_suffixes.iter() { + if name.ends_with(sfx) { + return true; + } + } + false +} + pub struct Verb; #[async_trait] -impl UserVerb for Verb { +impl UserVerb for Verb { async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, remaining: &str) -> UResult<()> { let (username, password, email) = match parse_username(remaining) { Err(e) => user_error("Invalid username: ".to_owned() + e)?, @@ -20,6 +52,10 @@ impl UserVerb for Verb { } } }; + + if is_invalid_username(&username.to_lowercase()) { + user_error("Sorry, that username isn't allowed. Try another".to_owned())?; + } if ctx.trans.find_by_username(username).await?.is_some() { user_error("Username already exists".to_owned())?; diff --git a/blastmud_game/src/message_handler/user_commands/wield.rs b/blastmud_game/src/message_handler/user_commands/wield.rs index f6fad53f..9c552694 100644 --- a/blastmud_game/src/message_handler/user_commands/wield.rs +++ b/blastmud_game/src/message_handler/user_commands/wield.rs @@ -94,13 +94,11 @@ impl QueueCommandHandler for QueueHandler { if item.location != format!("player/{}", player_item.item_code) { user_error("You try to wield it but realise you no longer have it".to_owned())? } - let msg_exp = format!("{} wields {} {}\n", + let msg_exp = format!("{} wields {}\n", &player_item.display_for_sentence(true, 1, true), - &player_item.pronouns.possessive, &item.display_for_sentence(true, 1, false)); - let msg_nonexp = format!("{} wields {} {}\n", + let msg_nonexp = format!("{} wields {}\n", &player_item.display_for_sentence(false, 1, true), - &player_item.pronouns.possessive, &item.display_for_sentence(false, 1, false)); broadcast_to_room(ctx.trans, &player_item.location, None, &msg_exp, Some(&msg_nonexp)).await?; ctx.trans.set_exclusive_action_type_to(&item, diff --git a/blastmud_game/src/models/consent.rs b/blastmud_game/src/models/consent.rs index e0e92c28..574bb507 100644 --- a/blastmud_game/src/models/consent.rs +++ b/blastmud_game/src/models/consent.rs @@ -1,7 +1,7 @@ use serde::{Serialize, Deserialize}; use chrono::{DateTime, Utc}; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, PartialEq, Debug)] pub enum ConsentType { Fight, Medicine, @@ -10,27 +10,74 @@ pub enum ConsentType { Sex } -#[derive(Serialize, Deserialize)] +impl ConsentType { + pub fn from_str(inp: &str) -> Option { + use ConsentType::*; + match inp { + "fight" => Some(Fight), + "medicine" => Some(Medicine), + "gifts" => Some(Gifts), + "visit" => Some(Visit), + "sex" => Some(Sex), + _ => None + } + } + + pub fn to_str(&self) -> &'static str { + use ConsentType::*; + match self { + Fight => "fight", + Medicine => "medicine", + Gifts => "gifts", + Visit => "visit", + Sex => "sex", + } + } +} + +#[derive(Serialize, Deserialize, PartialEq, Clone, Debug)] pub enum ConsentStatus { PendingAdd, // Added but awaiting other party to ratify by giving matching consent. Active, // Consent in force, no delete pending. PendingDelete, // Pending cancellation but other party has to also disallow to ratify. } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, PartialEq, Clone, Debug)] pub struct FightConsent { - status: ConsentStatus, - pending_change: Option>, - allow_pick: bool, - freely_revoke: bool, + pub status: ConsentStatus, + pub pending_change: Option>, + pub allow_pick: bool, + pub freely_revoke: bool, } -#[derive(Serialize, Deserialize)] -pub struct Consent { - consent_type: ConsentType, - fight_consent: Option, - expires: Option>, - only_in: Vec, - allow_private: bool, - until_death: bool, +impl Default for FightConsent { + fn default() -> Self { + Self { + status: ConsentStatus::PendingAdd, + pending_change: None, + allow_pick: false, + freely_revoke: false + } + } +} + +#[derive(Serialize, Deserialize, PartialEq, Clone, Debug)] +pub struct Consent { + pub fight_consent: Option, + pub expires: Option>, + pub only_in: Vec, + pub allow_private: bool, + pub until_death: bool, +} + +impl Default for Consent { + fn default() -> Self { + Self { + fight_consent: None, + expires: None, + only_in: vec!(), + allow_private: false, + until_death: false + } + } } diff --git a/blastmud_game/src/models/item.rs b/blastmud_game/src/models/item.rs index ac83a26e..dee79b40 100644 --- a/blastmud_game/src/models/item.rs +++ b/blastmud_game/src/models/item.rs @@ -307,7 +307,6 @@ pub struct Item { pub presence_target: Option, // e.g. what are they sitting on. pub is_static: bool, pub is_dead: bool, - pub is_challenge_attack_only: bool, pub species: SpeciesType, pub health: u64, pub total_xp: u64, @@ -385,7 +384,6 @@ impl Default for Item { presence_target: None, is_static: false, is_dead: false, - is_challenge_attack_only: true, species: SpeciesType::Human, health: 24, total_xp: 0, diff --git a/blastmud_game/src/services.rs b/blastmud_game/src/services.rs index e22c83f4..dccf0abd 100644 --- a/blastmud_game/src/services.rs +++ b/blastmud_game/src/services.rs @@ -1,6 +1,8 @@ use crate::{ DResult, models::item::Item, + models::consent::{Consent, ConsentType, ConsentStatus}, + static_content::npc::npc_by_code, }; use mockall_double::double; #[double] use crate::db::DBTrans; @@ -28,3 +30,57 @@ pub async fn broadcast_to_room(trans: &DBTrans, location: &str, from_item: Optio } Ok(()) } + +fn check_one_consent(consent: &Consent, action: &str, target: &Item) -> bool { + if let Some((loctype, loccode)) = target.location.split_once("/") { + if !consent.only_in.is_empty() { + if loctype != "room" || !consent.only_in.iter().any(|v| v == loccode) { + return false; + } + } + if !consent.allow_private && loctype != "room" { + return false; + } + } else { + if !consent.only_in.is_empty() || !consent.allow_private { + return false; + } + } + + if let Some(fight_consent) = consent.fight_consent.as_ref() { + if fight_consent.status == ConsentStatus::PendingAdd { + return false; + } + if !fight_consent.allow_pick && action == "pick" { + return false; + } + } + + true +} + +pub async fn check_consent(trans: &DBTrans, action: &str, + consent_type: &ConsentType, + by: &Item, + target: &Item) -> DResult { + // Consent is only a factor on actions by players towards other players or npcs. + if by.item_type != "player" || (target.item_type != "player" && target.item_type != "npc") { + return Ok(true); + } + if target.item_type == "npc" { + return Ok(match npc_by_code().get(target.item_code.as_str()) { + None => false, + Some(npc) => npc.player_consents.contains(consent_type) + }); + } + + trans.delete_expired_user_consent().await?; + if let Some(consent) = trans.find_user_consent_by_parties_type( + &target.item_code, &by.item_code, consent_type).await? { + if check_one_consent(&consent, action, &target) { + return Ok(true); + } + } + + Ok(false) +} diff --git a/blastmud_game/src/static_content/npc.rs b/blastmud_game/src/static_content/npc.rs index ac070028..77736fcd 100644 --- a/blastmud_game/src/static_content/npc.rs +++ b/blastmud_game/src/static_content/npc.rs @@ -10,7 +10,8 @@ use super::{ }; use crate::models::{ item::{Item, Pronouns, SkillType}, - task::{Task, TaskMeta, TaskRecurrence, TaskDetails} + task::{Task, TaskMeta, TaskRecurrence, TaskDetails}, + consent::{ConsentType}, }; use crate::services::{ combat::{ @@ -75,7 +76,6 @@ pub struct NPC { pub message_handler: Option<&'static (dyn NPCMessageHandler + Sync + Send)>, pub aliases: Vec<&'static str>, pub says: Vec, - pub attackable: bool, pub aggression: u64, pub max_health: u64, pub intrinsic_weapon: Option, @@ -84,6 +84,7 @@ pub struct NPC { pub species: SpeciesType, pub wander_zones: Vec<&'static str>, pub kill_bonus: Option, + pub player_consents: Vec, } impl Default for NPC { @@ -100,13 +101,13 @@ impl Default for NPC { total_xp: 1000, total_skills: SkillType::values().into_iter() .map(|sk| (sk.clone(), if &sk == &SkillType::Dodge { 8.0 } else { 10.0 })).collect(), - attackable: false, aggression: 0, max_health: 24, intrinsic_weapon: None, species: SpeciesType::Human, wander_zones: vec!(), kill_bonus: None, + player_consents: vec!(), } } } @@ -163,7 +164,6 @@ pub fn npc_static_items() -> Box> { location: c.spawn_location.to_owned(), is_static: true, pronouns: c.pronouns.clone(), - is_challenge_attack_only: !c.attackable, total_xp: c.total_xp.clone(), total_skills: c.total_skills.clone(), species: c.species.clone(), diff --git a/blastmud_game/src/static_content/npc/melbs_citizen.rs b/blastmud_game/src/static_content/npc/melbs_citizen.rs index ede80351..5b8ceb09 100644 --- a/blastmud_game/src/static_content/npc/melbs_citizen.rs +++ b/blastmud_game/src/static_content/npc/melbs_citizen.rs @@ -1,5 +1,8 @@ use super::{NPC, NPCSayInfo, NPCSayType}; -use crate::models::item::Pronouns; +use crate::models::{ + item::Pronouns, + consent::ConsentType, +}; pub fn npc_list() -> Vec { use NPCSayType::FromFixedList; @@ -29,6 +32,7 @@ pub fn npc_list() -> Vec { message_handler: None, wander_zones: vec!("melbs"), says: vec!(melbs_citizen_stdsay.clone()), + player_consents: vec!(ConsentType::Medicine, ConsentType::Sex), ..Default::default() } } diff --git a/blastmud_game/src/static_content/npc/melbs_dog.rs b/blastmud_game/src/static_content/npc/melbs_dog.rs index b02a40d4..c0f409c6 100644 --- a/blastmud_game/src/static_content/npc/melbs_dog.rs +++ b/blastmud_game/src/static_content/npc/melbs_dog.rs @@ -1,5 +1,8 @@ use super::{NPC, KillBonus}; -use crate::models::item::Pronouns; +use crate::models::{ + item::Pronouns, + consent::ConsentType, +}; use crate::static_content::{ possession_type::PossessionType, species::SpeciesType @@ -11,7 +14,6 @@ macro_rules! dog { code: concat!("melbs_dog_", $code), name: concat!($adj, " dog"), pronouns: Pronouns { is_proper: false, ..Pronouns::default_inanimate() }, - attackable: true, aggression: 12, wander_zones: vec!("melbs"), description: "A malnourished looking dog. Its skeleton is visible through its thin and patchy fur. It smells terrible, and certainly doesn't look tame.", @@ -23,6 +25,7 @@ macro_rules! dog { msg: "On your wristpad: Thank you for helping Melbs with animal control! Here's your fee.", payment: 100, }), + player_consents: vec!(ConsentType::Fight), ..Default::default() } } diff --git a/schema/schema.sql b/schema/schema.sql index b5409b42..e8c1895c 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -69,15 +69,19 @@ CREATE TABLE corp_membership ( CREATE TABLE user_consent ( consenting_user TEXT NOT NULL REFERENCES users(username), consented_user TEXT NOT NULL REFERENCES users(username), + consent_type TEXT NOT NULL, details JSONB NOT NULL, - PRIMARY KEY (consenting_user, consented_user) + PRIMARY KEY (consenting_user, consented_user, consent_type) ); CREATE INDEX user_consent_by_consented ON user_consent (consented_user); +CREATE INDEX user_consent_by_expires ON user_consent ((details->>'expires')); CREATE TABLE corp_consent ( consenting_corp BIGINT NOT NULL REFERENCES corps(corp_id), consented_corp BIGINT NOT NULL REFERENCES corps(corp_id), + consent_type TEXT NOT NULL, details JSONB NOT NULL, - PRIMARY KEY (consenting_corp, consented_corp) + PRIMARY KEY (consenting_corp, consented_corp, consent_type) ); CREATE INDEX corp_consent_by_consented ON corp_consent (consented_corp); +CREATE INDEX corp_consent_by_expires ON corp_consent ((details->>'expires'));