Enable PvP fighting.

This commit is contained in:
Condorra 2023-03-13 15:23:07 +11:00
parent 73d92e3074
commit b96fc2e772
14 changed files with 1153 additions and 37 deletions

View File

@ -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<Option<Consent>> {
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));

View File

@ -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,

View File

@ -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<u64>,
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<Consent>,
pub first_party_message: Option<String>,
pub counterparty_message: Option<String>,
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<Consent>,
their_current_consent: &Option<Consent>,
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,
&current_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;

View File

@ -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!("<blue>Your wristpad vibrates and blocks you from doing that.<reset> 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 <bold>help challenge<reset>]").to_string())?
if !check_consent(ctx.trans, "attack", &ConsentType::Fight, &player_item, &attack_whom).await? {
user_error(ansi!("<blue>Your wristpad vibrates and blocks you from doing that.<reset> 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 <bold>help allow<reset>]").to_string())?
}
if attack_whom.is_dead {

View File

@ -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<AllowCommand, String> {
let usage: &'static str =
ansi!("Usage: allow <lt>action> from <lt>user> <lt>options> | allow <lt>action> against <lt>corp> by <lt>corp> <lt>options>. Try <bold>help allow<reset> 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)
}
}))
}
}

View File

@ -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<Vec<&'static str>> = OnceCell::new();
static INVALID_SUFFIXES: OnceCell<Vec<&'static str>> = OnceCell::new();
static INVALID_WORDS: OnceCell<HashSet<&'static str>> = 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())?;

View File

@ -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,

View File

@ -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<ConsentType> {
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<Box<Consent>>,
allow_pick: bool,
freely_revoke: bool,
pub status: ConsentStatus,
pub pending_change: Option<Box<Consent>>,
pub allow_pick: bool,
pub freely_revoke: bool,
}
#[derive(Serialize, Deserialize)]
pub struct Consent {
consent_type: ConsentType,
fight_consent: Option<FightConsent>,
expires: Option<DateTime<Utc>>,
only_in: Vec<String>,
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<FightConsent>,
pub expires: Option<DateTime<Utc>>,
pub only_in: Vec<String>,
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
}
}
}

View File

@ -307,7 +307,6 @@ pub struct Item {
pub presence_target: Option<String>, // 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,

View File

@ -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<bool> {
// 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)
}

View File

@ -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<NPCSayInfo>,
pub attackable: bool,
pub aggression: u64,
pub max_health: u64,
pub intrinsic_weapon: Option<PossessionType>,
@ -84,6 +84,7 @@ pub struct NPC {
pub species: SpeciesType,
pub wander_zones: Vec<&'static str>,
pub kill_bonus: Option<KillBonus>,
pub player_consents: Vec<ConsentType>,
}
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<dyn Iterator<Item = StaticItem>> {
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(),

View File

@ -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<NPC> {
use NPCSayType::FromFixedList;
@ -29,6 +32,7 @@ pub fn npc_list() -> Vec<NPC> {
message_handler: None,
wander_zones: vec!("melbs"),
says: vec!(melbs_citizen_stdsay.clone()),
player_consents: vec!(ConsentType::Medicine, ConsentType::Sex),
..Default::default()
}
}

View File

@ -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()
}
}

View File

@ -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'));