diff --git a/blastmud_game/src/db.rs b/blastmud_game/src/db.rs index febf2961..bff5b2f2 100644 --- a/blastmud_game/src/db.rs +++ b/blastmud_game/src/db.rs @@ -8,10 +8,11 @@ use uuid::Uuid; use tokio_postgres::NoTls; use crate::message_handler::ListenerSession; use crate::DResult; +use crate::message_handler::user_commands::parsing::parse_offset; use crate::models::{session::Session, user::User, item::Item}; use tokio_postgres::types::ToSql; use std::collections::BTreeSet; - +use std::sync::Arc; use serde_json::{self, Value}; use futures::FutureExt; @@ -175,6 +176,29 @@ impl DBPool { } } +#[derive(Clone, Debug)] +pub struct ItemSearchParams<'l> { + pub from_item: &'l Item, + pub query: &'l str, + pub include_contents: bool, + pub include_loc_contents: bool, + pub include_active_players: bool, + pub include_all_players: bool +} + +impl ItemSearchParams<'_> { + pub fn base<'l>(from_item: &'l Item, query: &'l str) -> ItemSearchParams<'l> { + ItemSearchParams { + from_item, query, + include_contents: false, + include_loc_contents: false, + include_active_players: false, + include_all_players: false + } + } +} + + impl DBTrans { pub async fn queue_for_session(self: &Self, session: &ListenerSession, @@ -238,7 +262,7 @@ impl DBTrans { // Only copy more permanent fields, others are supposed to change over time and shouldn't // be reset on restart. for to_copy in ["display", "display_less_explicit", "details", "details_less_explicit", - "total_xp", "total_stats", "total_skills"] { + "total_xp", "total_stats", "total_skills", "pronouns"] { det_ex = format!("jsonb_set({}, '{{{}}}', ${})", det_ex, to_copy, var_id); params.push(obj_map.get(to_copy).unwrap_or(&Value::Null)); var_id += 1; @@ -314,61 +338,74 @@ impl DBTrans { } pub async fn find_item_by_type_code(self: &Self, item_type: &str, item_code: &str) -> - DResult> { + DResult>> { if let Some(item) = self.pg_trans()?.query_opt( "SELECT details FROM items WHERE \ details->>'item_type' = $1 AND \ details->>'item_code' = $2", &[&item_type, &item_code]).await? { - return Ok(serde_json::from_value(item.get("details"))?); + return Ok(Some(Arc::new(serde_json::from_value::(item.get("details"))?))); } Ok(None) } - pub async fn find_items_by_location(self: &Self, location: &str) -> DResult> { + pub async fn find_items_by_location(self: &Self, location: &str) -> DResult>> { Ok(self.pg_trans()?.query( "SELECT details FROM items WHERE details->>'location' = $1 \ - LIMIT 20", &[&location] + ORDER BY details->>'display' + LIMIT 100", &[&location] ).await?.into_iter() .filter_map(|i| serde_json::from_value(i.get("details")).ok()) + .map(Arc::new) .collect()) - } - pub async fn resolve_items_by_display_name_for_player( + pub async fn resolve_items_by_display_name_for_player<'l>( self: &Self, - from_item: &Item, - query: &str, - include_contents: bool, - include_loc_contents: bool, - include_active_players: bool, - include_all_players: bool - ) -> DResult> { + search: &'l ItemSearchParams<'l> + ) -> DResult>>> { let mut ctes: Vec = Vec::new(); let mut include_tables: Vec<&'static str> = Vec::new(); - let player_loc = &from_item.location; - let player_desig = format!("{}/{}", from_item.item_type, - from_item.item_code); - if include_contents { - ctes.push("contents AS (\ - SELECT details FROM items WHERE details->>'location' = $1 - )".to_owned()); + let player_loc = &search.from_item.location; + let player_desig = format!("{}/{}", search.from_item.item_type, + search.from_item.item_code); + + let (offset, query) = parse_offset(search.query); + let mut param_no: usize = 3; + let query_wildcard = query.replace("\\", "\\\\") + .replace("_", "\\_") + .replace("%", "") + .to_lowercase() + "%"; + let offset_sql = offset.map(|x| (if x >= 1 { x - 1 } else { x}) as i64).unwrap_or(0); + let mut params: Vec<&(dyn ToSql + Sync)> = vec!( + &query_wildcard, + &offset_sql); + + + if search.include_contents { + ctes.push(format!("contents AS (\ + SELECT details FROM items WHERE details->>'location' = ${}\ + )", param_no)); + param_no += 1; + params.push(&player_desig); include_tables.push("SELECT details FROM contents"); } - if include_loc_contents { - ctes.push("loc_contents AS (\ - SELECT details FROM items WHERE details->>'location' = $2 - )".to_owned()); + if search.include_loc_contents { + ctes.push(format!("loc_contents AS (\ + SELECT details FROM items WHERE details->>'location' = ${}\ + )", param_no)); + drop(param_no); // or increment if this is a problem. + params.push(&player_loc); include_tables.push("SELECT details FROM loc_contents"); } - if include_active_players { + if search.include_active_players { ctes.push("active_players AS (\ SELECT details FROM items WHERE details->>'item_type' = 'player' \ AND current_session IS NOT NULL \ )".to_owned()); include_tables.push("SELECT details FROM active_players"); } - if include_all_players { + if search.include_all_players { ctes.push("all_players AS (\ SELECT details FROM items WHERE details->>'item_type' = 'player' )".to_owned()); @@ -377,21 +414,18 @@ impl DBTrans { ctes.push(format!("relevant_items AS ({})", include_tables.join(" UNION "))); let cte_str: String = ctes.join(", "); - - Ok(self.pg_trans()?.query( + + Ok(Arc::new(self.pg_trans()?.query( &format!( - "WITH {} SELECT details FROM relevant_items WHERE (lower(details->>'display') LIKE $3) \ - OR (lower(details ->>'display_less_explicit') LIKE $3) \ + "WITH {} SELECT details FROM relevant_items WHERE (lower(details->>'display') LIKE $1) \ + OR (lower(details ->>'display_less_explicit') LIKE $1) \ ORDER BY length(details->>'display') DESC \ - LIMIT 2", &cte_str), - &[&player_desig, &player_loc, - &(query.replace("\\", "\\\\") - .replace("_", "\\_") - .replace("%", "") - .to_lowercase() + "%")] + LIMIT 2 OFFSET $2", &cte_str), + ¶ms ).await?.into_iter() - .filter_map(|i| serde_json::from_value(i.get("details")).ok()) - .collect()) + .filter_map(|i| serde_json::from_value(i.get("details")).ok()) + .map(Arc::new) + .collect())) } pub async fn commit(mut self: Self) -> DResult<()> { diff --git a/blastmud_game/src/language.rs b/blastmud_game/src/language.rs new file mode 100644 index 00000000..2770ffb1 --- /dev/null +++ b/blastmud_game/src/language.rs @@ -0,0 +1,79 @@ +use once_cell::sync::OnceCell; + +struct PluralRule<'l> { + match_suffix: &'l str, + drop: usize, + append_suffix: &'l str, +} + +pub fn pluralise(input: &str) -> String { + static PLURAL_RULES: OnceCell> = OnceCell::new(); + let plural_rules = PLURAL_RULES.get_or_init(|| vec!( + PluralRule { match_suffix: "foot", drop: 3, append_suffix: "eet" }, + PluralRule { match_suffix: "tooth", drop: 4, append_suffix: "eeth" }, + PluralRule { match_suffix: "man", drop: 2, append_suffix: "en" }, + PluralRule { match_suffix: "mouse", drop: 4, append_suffix: "ice" }, + PluralRule { match_suffix: "louse", drop: 4, append_suffix: "ice" }, + PluralRule { match_suffix: "fish", drop: 0, append_suffix: "" }, + PluralRule { match_suffix: "sheep", drop: 0, append_suffix: "" }, + PluralRule { match_suffix: "deer", drop: 0, append_suffix: "" }, + PluralRule { match_suffix: "pox", drop: 0, append_suffix: "" }, + PluralRule { match_suffix: "cis", drop: 2, append_suffix: "es" }, + PluralRule { match_suffix: "sis", drop: 2, append_suffix: "es" }, + PluralRule { match_suffix: "xis", drop: 2, append_suffix: "es" }, + PluralRule { match_suffix: "ss", drop: 0, append_suffix: "es" }, + PluralRule { match_suffix: "ch", drop: 0, append_suffix: "es" }, + PluralRule { match_suffix: "sh", drop: 0, append_suffix: "es" }, + PluralRule { match_suffix: "ife", drop: 2, append_suffix: "ves" }, + PluralRule { match_suffix: "lf", drop: 1, append_suffix: "ves" }, + PluralRule { match_suffix: "arf", drop: 1, append_suffix: "ves" }, + PluralRule { match_suffix: "ay", drop: 0, append_suffix: "s" }, + PluralRule { match_suffix: "ey", drop: 0, append_suffix: "s" }, + PluralRule { match_suffix: "iy", drop: 0, append_suffix: "s" }, + PluralRule { match_suffix: "oy", drop: 0, append_suffix: "s" }, + PluralRule { match_suffix: "uy", drop: 0, append_suffix: "s" }, + PluralRule { match_suffix: "y", drop: 1, append_suffix: "ies" }, + PluralRule { match_suffix: "ao", drop: 0, append_suffix: "s" }, + PluralRule { match_suffix: "eo", drop: 0, append_suffix: "s" }, + PluralRule { match_suffix: "io", drop: 0, append_suffix: "s" }, + PluralRule { match_suffix: "oo", drop: 0, append_suffix: "s" }, + PluralRule { match_suffix: "uo", drop: 0, append_suffix: "s" }, + // The o rule could be much larger... we'll add specific exceptions as + // the come up. + PluralRule { match_suffix: "o", drop: 0, append_suffix: "es" }, + // Lots of possible exceptions here. + PluralRule { match_suffix: "ex", drop: 0, append_suffix: "es" }, + )); + + for rule in plural_rules { + if input.ends_with(rule.match_suffix) { + return input[0..(input.len() - rule.drop)].to_owned() + rule.append_suffix; + } + } + input.to_owned() + "s" +} + +#[cfg(test)] +mod test { + #[test] + fn pluralise_should_follow_english_rules() { + for (word, plural) in vec!( + ("cat", "cats"), + ("wolf", "wolves"), + ("scarf", "scarves"), + ("volcano", "volcanoes"), + ("canoe", "canoes"), + ("pistachio", "pistachios"), + ("match", "matches"), + ("the fairest sex", "the fairest sexes"), + ("loud hiss", "loud hisses"), + ("evil axis", "evil axes"), + ("death ray", "death rays"), + ("killer blowfly", "killer blowflies"), + ("house mouse", "house mice"), + ("zombie sheep", "zombie sheep"), + ) { + assert_eq!(super::pluralise(word), plural); + } + } +} diff --git a/blastmud_game/src/main.rs b/blastmud_game/src/main.rs index 81c15d84..92d4c1ef 100644 --- a/blastmud_game/src/main.rs +++ b/blastmud_game/src/main.rs @@ -14,6 +14,7 @@ mod av; mod regular_tasks; mod models; mod static_content; +mod language; pub type DResult = Result>; diff --git a/blastmud_game/src/message_handler.rs b/blastmud_game/src/message_handler.rs index e5a70bd5..91493bd5 100644 --- a/blastmud_game/src/message_handler.rs +++ b/blastmud_game/src/message_handler.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use crate::DResult; mod new_session; -mod user_commands; +pub mod user_commands; #[derive(Clone,Debug)] pub struct ListenerSession { diff --git a/blastmud_game/src/message_handler/user_commands.rs b/blastmud_game/src/message_handler/user_commands.rs index d67defb6..e6de1c0f 100644 --- a/blastmud_game/src/message_handler/user_commands.rs +++ b/blastmud_game/src/message_handler/user_commands.rs @@ -1,11 +1,13 @@ use super::ListenerSession; use crate::DResult; -use crate::db::{DBTrans, DBPool}; +use crate::db::{DBTrans, DBPool, ItemSearchParams}; use ansi::ansi; use phf::phf_map; use async_trait::async_trait; -use crate::models::{session::Session, user::User}; +use crate::models::{session::Session, user::User, item::Item}; use log::warn; +use once_cell::sync::OnceCell; +use std::sync::Arc; mod agree; mod help; @@ -13,15 +15,16 @@ mod ignore; mod less_explicit_mode; mod login; mod look; -mod parsing; +pub mod parsing; mod quit; mod register; +mod whisper; pub struct VerbContext<'l> { - session: &'l ListenerSession, - session_dat: &'l mut Session, - user_dat: &'l mut Option, - trans: &'l DBTrans + pub session: &'l ListenerSession, + pub session_dat: &'l mut Session, + pub user_dat: &'l mut Option, + pub trans: &'l DBTrans } pub enum CommandHandlingError { @@ -70,16 +73,10 @@ static UNREGISTERED_COMMANDS: UserVerbRegistry = phf_map! { static REGISTERED_COMMANDS: UserVerbRegistry = phf_map! { "l" => look::VERB, "look" => look::VERB, + "-" => whisper::VERB, + "whisper" => whisper::VERB, }; -pub fn explicit_if_allowed<'l>(ctx: &VerbContext, explicit: &'l str, non_explicit: Option<&'l str>) -> &'l str { - if ctx.session_dat.less_explicit_mode { - non_explicit.unwrap_or(explicit) - } else { - explicit - } -} - fn resolve_handler(ctx: &VerbContext, cmd: &str) -> Option<&'static UserVerbRef> { let mut result = ALWAYS_AVAILABLE_COMMANDS.get(cmd); @@ -139,3 +136,38 @@ pub async fn handle(session: &ListenerSession, msg: &str, pool: &DBPool) -> DRes } Ok(()) } + +pub fn is_likely_explicit(msg: &str) -> bool { + static EXPLICIT_MARKER_WORDS: OnceCell> = + OnceCell::new(); + let markers = EXPLICIT_MARKER_WORDS.get_or_init(|| + vec!("fuck", "sex", "cock", "cunt", "dick", "pussy", "whore", + "orgasm", "erection", "nipple", "boob", "tit")); + for word in markers { + if msg.contains(word) { + return true + } + } + false +} + +pub fn get_user_or_fail<'l>(ctx: &'l VerbContext) -> UResult<&'l User> { + ctx.user_dat.as_ref() + .ok_or_else(|| UserError("Not logged in".to_owned())) +} + +pub async fn get_player_item_or_fail(ctx: &VerbContext<'_>) -> UResult> { + Ok(ctx.trans.find_item_by_type_code( + "player", &get_user_or_fail(ctx)?.username.to_lowercase()).await? + .ok_or_else(|| UserError("Your player is gone, you'll need to re-register or ask an admin".to_owned()))?) +} + +pub async fn search_item_for_user<'l>(ctx: &'l VerbContext<'l>, search: &'l ItemSearchParams<'l>) -> + UResult> { + Ok(match &ctx.trans.resolve_items_by_display_name_for_player(search).await?[..] { + [] => user_error("Sorry, I couldn't find anything matching.".to_owned())?, + [match_it] => match_it.clone(), + [item1, ..] => + item1.clone(), + }) +} diff --git a/blastmud_game/src/message_handler/user_commands/look.rs b/blastmud_game/src/message_handler/user_commands/look.rs index 0504466b..6972c298 100644 --- a/blastmud_game/src/message_handler/user_commands/look.rs +++ b/blastmud_game/src/message_handler/user_commands/look.rs @@ -1,20 +1,13 @@ -use super::{VerbContext, UserVerb, UserVerbRef, UResult, UserError, user_error, explicit_if_allowed}; +use super::{VerbContext, UserVerb, UserVerbRef, UResult, UserError, user_error, + get_player_item_or_fail, search_item_for_user}; use async_trait::async_trait; use ansi::{ansi, flow_around, word_wrap}; -use crate::models::{user::User, item::{Item, LocationActionType, Subattack}}; +use crate::db::ItemSearchParams; +use crate::models::{item::{Item, LocationActionType, Subattack}}; use crate::static_content::room::{self, Direction}; use itertools::Itertools; - -pub fn get_user_or_fail<'l>(ctx: &'l VerbContext) -> UResult<&'l User> { - ctx.user_dat.as_ref() - .ok_or_else(|| UserError("Not logged in".to_owned())) -} - -pub async fn get_player_item_or_fail(ctx: &VerbContext<'_>) -> UResult { - Ok(ctx.trans.find_item_by_type_code( - "player", &get_user_or_fail(ctx)?.username.to_lowercase()).await? - .ok_or_else(|| UserError("Your player is gone, you'll need to re-register or ask an admin".to_owned()))?) -} +use std::sync::Arc; +use crate::language::pluralise; pub fn render_map(room: &room::Room, width: usize, height: usize) -> String { let mut buf = String::new(); @@ -43,14 +36,8 @@ pub async fn describe_normal_item(ctx: &VerbContext<'_>, item: &Item) -> UResult ctx.trans.queue_for_session( ctx.session, Some(&format!("{}\n{}\n", - explicit_if_allowed( - ctx, - &item.display, - item.display_less_explicit.as_ref().map(|s|&**s)), - explicit_if_allowed( - ctx, - item.details.as_ref().map(|v|&**v).unwrap_or(""), - item.details_less_explicit.as_ref().map(|s|&**s)), + item.display_for_session(&ctx.session_dat), + item.details_for_session(&ctx.session_dat).unwrap_or("") )) ).await?; Ok(()) @@ -63,14 +50,17 @@ fn exits_for(room: &room::Room) -> String { format!(ansi!("[ Exits: {} ]"), exit_text.join(" ")) } -pub async fn describe_room(ctx: &VerbContext<'_>, room: &room::Room, contents: &str) -> UResult<()> { +pub async fn describe_room(ctx: &VerbContext<'_>, item: &Item, + room: &room::Room, contents: &str) -> UResult<()> { let zone = room::zone_details().get(room.zone).map(|z|z.display).unwrap_or("Outside of time"); ctx.trans.queue_for_session( ctx.session, Some(&flow_around(&render_map(room, 5, 5), 10, " ", - &word_wrap(&format!("{} ({})\n{}.{}\n{}\n", room.name, zone, - explicit_if_allowed(ctx, room.description, - room.description_less_explicit), + &word_wrap(&format!("{} ({})\n{}.{}\n{}\n", + item.display_for_session(&ctx.session_dat), + zone, + item.details_for_session( + &ctx.session_dat).unwrap_or(""), contents, exits_for(room)), |row| if row >= 5 { 80 } else { 68 }), 68)) ).await?; @@ -83,12 +73,12 @@ async fn list_item_contents<'l>(ctx: &'l VerbContext<'_>, item: &'l Item) -> URe item.item_type, item.item_code)).await?; items.sort_unstable_by(|it1, it2| (&it1.display).cmp(&it2.display)); - let all_groups: Vec> = items + let all_groups: Vec>> = items .iter() .group_by(|i| &i.display) .into_iter() - .map(|(_, g)|g.collect::>()) - .collect::>>(); + .map(|(_, g)|g.collect::>>()) + .collect::>>>(); for group_items in all_groups { let head = &group_items[0]; @@ -96,14 +86,15 @@ async fn list_item_contents<'l>(ctx: &'l VerbContext<'_>, item: &'l Item) -> URe buf.push(' '); if group_items.len() > 1 { buf.push_str(&format!("{} ", group_items.len())) - } else if !is_creature { + } else if !head.pronouns.is_proper { buf.push_str("A "); } - buf.push_str( - &explicit_if_allowed(ctx, - &head.display, - head.display_less_explicit.as_ref().map(|v|&**v))); - buf.push_str(" is "); + let mut disp = head.display_for_session(&ctx.session_dat).to_owned(); + if group_items.len() > 1 { + disp = pluralise(&disp); + } + buf.push_str(&disp); + buf.push_str(if group_items.len() > 1 { " are " } else { " is "}); match head.action_type { LocationActionType::Sitting => buf.push_str("sitting "), LocationActionType::Reclining => buf.push_str("reclining "), @@ -128,9 +119,7 @@ async fn list_item_contents<'l>(ctx: &'l VerbContext<'_>, item: &'l Item) -> URe match ctx.trans.find_item_by_type_code(ttype, tcode).await? { None => buf.push_str("someone"), Some(it) => buf.push_str( - &explicit_if_allowed(ctx, - &it.display, - it.display_less_explicit.as_ref().map(|v|&**v)) + it.display_for_session(&ctx.session_dat) ) } } @@ -149,52 +138,50 @@ impl UserVerb for Verb { let rem_trim = remaining.trim().to_lowercase(); let (heretype, herecode) = player_item.location.split_once("/").unwrap_or(("room", "repro_xv_chargen")); - let (itype, icode): (String, String) = if rem_trim == "" { - Ok((heretype.to_owned(), herecode.to_owned())) + let item: Arc = if rem_trim == "" { + ctx.trans.find_item_by_type_code(heretype, herecode).await? + .ok_or_else(|| UserError("Sorry, that no longer exists".to_owned()))? } else if let Some(dir) = Direction::parse(&rem_trim) { if heretype != "room" { // Fix this when we have planes / boats / roomkits. - user_error("Navigating outside rooms not yet supported.".to_owned()) + user_error("Navigating outside rooms not yet supported.".to_owned())? } else { if let Some(room) = room::room_map_by_code().get(herecode) { match room.exits.iter().find(|ex| ex.direction == *dir) { - None => user_error("There is nothing in that direction".to_owned()), + None => user_error("There is nothing in that direction".to_owned())?, Some(exit) => { match room::resolve_exit(room, exit) { - None => user_error("There is nothing in that direction".to_owned()), - Some(room2) => Ok(("room".to_owned(), room2.code.to_owned())) + None => user_error("There is nothing in that direction".to_owned())?, + Some(room2) => + ctx.trans.find_item_by_type_code("room", room2.code).await? + .ok_or_else(|| UserError("Sorry, that no longer exists".to_owned()))? + } } } } else { - user_error("Can't find your current location".to_owned()) + user_error("Can't find your current location".to_owned())? } } } else if rem_trim == "me" || rem_trim == "self" { - Ok((player_item.item_type.clone(), player_item.item_code.clone())) + player_item.clone() } else { - match &ctx.trans.resolve_items_by_display_name_for_player( - &player_item, - &rem_trim, - true, true, false, false - ).await?[..] { - [] => user_error("Sorry, I couldn't find anything matching.".to_owned()), - [match_it] => Ok((match_it.item_type.clone(), match_it.item_code.clone())), - [item1, ..] if item1.display.to_lowercase() == rem_trim || - item1.display_less_explicit.as_ref().map(|x|x.to_lowercase()) == Some(rem_trim) => - Ok((item1.item_type.clone(), item1.item_code.clone())), - _ => user_error("Sorry, that name is ambiguous, please be more specific.".to_owned()) - } - }?; - let item = ctx.trans.find_item_by_type_code(&itype, &icode).await? - .ok_or_else(|| UserError("Sorry, that no longer exists".to_owned()))?; - if itype != "room" { + search_item_for_user( + &ctx, + &ItemSearchParams { + include_contents: true, + include_loc_contents: true, + ..ItemSearchParams::base(&player_item, &rem_trim) + } + ).await? + }; + if item.item_type != "room" { describe_normal_item(ctx, &item).await?; } else { let room = - room::room_map_by_code().get(icode.as_str()) + room::room_map_by_code().get(item.item_code.as_str()) .ok_or_else(|| UserError("Sorry, that room no longer exists".to_owned()))?; - describe_room(ctx, &room, &list_item_contents(ctx, &item).await?).await?; + describe_room(ctx, &item, &room, &list_item_contents(ctx, &item).await?).await?; } Ok(()) } diff --git a/blastmud_game/src/message_handler/user_commands/parsing.rs b/blastmud_game/src/message_handler/user_commands/parsing.rs index cf561e4c..6296d0ba 100644 --- a/blastmud_game/src/message_handler/user_commands/parsing.rs +++ b/blastmud_game/src/message_handler/user_commands/parsing.rs @@ -1,6 +1,6 @@ use nom::{ - bytes::complete::{take_till1, take_while}, - character::{complete::{space0, space1, alpha1, one_of}}, + bytes::complete::{take_till, take_till1, take_while}, + character::{complete::{space0, space1, alpha1, one_of, char, u8}}, combinator::{recognize, fail, eof}, sequence::terminated, branch::alt, @@ -25,6 +25,26 @@ pub fn parse_command_name(input: &str) -> (&str, &str) { } } +pub fn parse_to_space(input: &str) -> (&str, &str) { + fn parser(input: &str) -> IResult<&str, &str> { + terminated(take_till(|c| c == ' ' || c == '\t'), alt((space1, eof)))(input) + } + match parser(input) { + Err(_) => ("", ""), /* Impossible? */ + Ok((rest, token)) => (token, rest) + } +} + +pub fn parse_offset(input: &str) -> (Option, &str) { + fn parser(input: &str) -> IResult<&str, u8> { + terminated(u8, char('.'))(input) + } + match parser(input) { + Err(_) => (None, input), + Ok((rest, result)) => (Some(result), rest) + } +} + pub fn parse_username(input: &str) -> Result<(&str, &str), &'static str> { const CATCHALL_ERROR: &'static str = "Must only contain alphanumeric characters or _"; fn parse_valid(input: &str) -> IResult<&str, (), VerboseError<&str>> { @@ -121,4 +141,27 @@ mod tests { fn it_fails_on_long_usernames() { assert_eq!(parse_username("A23456789012345678901"), Err("Limit of 20 characters")); } + + #[test] + fn parse_to_space_splits_on_whitespace() { + assert_eq!(parse_to_space("hello world"), ("hello", "world")); + assert_eq!(parse_to_space("hello\tworld"), ("hello", "world")); + assert_eq!(parse_to_space("hello world"), ("hello", "world")); + } + + #[test] + fn parse_to_space_supports_missing_rest() { + assert_eq!(parse_to_space("hello"), ("hello", "")); + assert_eq!(parse_to_space(""), ("", "")); + } + + #[test] + fn parse_offset_supports_no_offset() { + assert_eq!(parse_offset("hello world"), (None, "hello world")) + } + + #[test] + fn parse_offset_supports_offset() { + assert_eq!(parse_offset("2.hello world"), (Some(2), "hello world")) + } } diff --git a/blastmud_game/src/message_handler/user_commands/register.rs b/blastmud_game/src/message_handler/user_commands/register.rs index bbfa67cd..18ca7488 100644 --- a/blastmud_game/src/message_handler/user_commands/register.rs +++ b/blastmud_game/src/message_handler/user_commands/register.rs @@ -1,7 +1,7 @@ use super::{VerbContext, UserVerb, UserVerbRef, UResult}; use async_trait::async_trait; use super::{user_error, parsing::parse_username}; -use crate::models::{user::User, item::Item}; +use crate::models::{user::User, item::{Item, Pronouns}}; use chrono::Utc; use ansi::ansi; use tokio::time; @@ -36,6 +36,7 @@ impl UserVerb for Verb { display: username.to_owned(), details: Some("A non-descript individual".to_owned()), location: "room/repro_xv_chargen".to_owned(), + pronouns: Pronouns::default_animate(), ..Item::default() }).await?; diff --git a/blastmud_game/src/message_handler/user_commands/whisper.rs b/blastmud_game/src/message_handler/user_commands/whisper.rs new file mode 100644 index 00000000..c673cdd7 --- /dev/null +++ b/blastmud_game/src/message_handler/user_commands/whisper.rs @@ -0,0 +1,56 @@ +use super::{VerbContext, UserVerb, UserVerbRef, UResult, + ItemSearchParams, user_error, + get_player_item_or_fail, is_likely_explicit, + search_item_for_user, + parsing::parse_to_space}; +use crate::static_content::npc::npc_by_code; +use async_trait::async_trait; +use ansi::{ignore_special_characters, ansi}; + +pub struct Verb; +#[async_trait] +impl UserVerb for Verb { + async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, remaining: &str) -> UResult<()> { + let (to_whom_name, say_what_raw) = parse_to_space(remaining); + let say_what = ignore_special_characters(say_what_raw); + if say_what == "" { + user_error("You need to provide a message to send.".to_owned())?; + } + let player_item = get_player_item_or_fail(ctx).await?; + let to_whom = search_item_for_user(ctx, &ItemSearchParams { + include_loc_contents: true, + ..ItemSearchParams::base(&player_item, &to_whom_name) + }).await?; + + match to_whom.item_type.as_str() { + "npc" => {} + "player" => {}, + _ => user_error("Only characters (players / NPCs) accept whispers".to_string())? + } + + ctx.trans.queue_for_session(ctx.session, Some(&format!( + ansi!("{} whispers to {}: \"{}\"\n"), + player_item.display_for_session(&ctx.session_dat), + to_whom.display_for_session(&ctx.session_dat), + say_what + ))).await?; + + match to_whom.item_type.as_str() { + "npc" => { + let npc = npc_by_code().get(to_whom.item_code.as_str()) + .map(Ok) + .unwrap_or_else(|| user_error("That NPC is no longer available".to_owned()))?; + if let Some(handler) = npc.message_handler { + handler.handle(ctx, &player_item, &to_whom, &say_what).await?; + } + } + "player" => { + }, + _ => {} + } + + Ok(()) + } +} +static VERB_INT: Verb = Verb; +pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef; diff --git a/blastmud_game/src/models/item.rs b/blastmud_game/src/models/item.rs index 6d28145f..46aba20d 100644 --- a/blastmud_game/src/models/item.rs +++ b/blastmud_game/src/models/item.rs @@ -1,6 +1,6 @@ use serde::{Serialize, Deserialize}; use std::collections::BTreeMap; -use super::user::{SkillType, StatType}; +use super::{user::{SkillType, StatType}, session::Session}; #[derive(Serialize, Deserialize, Clone, Debug)] pub enum BuffCause { @@ -21,6 +21,66 @@ pub struct Buff { impacts: Vec } +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub struct Pronouns { + pub subject: String, + pub object: String, + pub intensive: String, + pub possessive: String, + // And some miscellaneous details to determine context + pub is_plural: bool, // ... are instead of ... is + pub is_proper: bool, // When naming, just ... instead of The ... +} + +impl Pronouns { + pub fn default_inanimate() -> Pronouns { + Pronouns { + subject: "it".to_owned(), + object: "it".to_owned(), + intensive: "itself".to_owned(), + possessive: "its".to_owned(), + is_plural: false, + is_proper: true, + } + } + + pub fn default_animate() -> Pronouns { + Pronouns { + subject: "they".to_owned(), + object: "them".to_owned(), + intensive: "themselves".to_owned(), + possessive: "their".to_owned(), + is_plural: true, + is_proper: true, + } + } + + #[allow(dead_code)] + pub fn default_male() -> Pronouns { + Pronouns { + subject: "he".to_owned(), + object: "him".to_owned(), + intensive: "himself".to_owned(), + possessive: "his".to_owned(), + is_plural: false, + is_proper: true, + } + } + + + #[allow(dead_code)] + pub fn default_female() -> Pronouns { + Pronouns { + subject: "she".to_owned(), + object: "her".to_owned(), + intensive: "herself".to_owned(), + possessive: "her".to_owned(), + is_plural: false, + is_proper: true, + } + } +} + #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] pub enum Subattack { Normal, @@ -57,6 +117,24 @@ pub struct Item { pub total_stats: BTreeMap, pub total_skills: BTreeMap, pub temporary_buffs: Vec, + pub pronouns: Pronouns, +} + +impl Item { + pub fn display_for_session<'l>(self: &'l Self, session: &Session) -> &'l str { + session.explicit_if_allowed(&self.display, + self.display_less_explicit.as_ref().map(String::as_str)) + } + + pub fn details_for_session<'l>(self: &'l Self, session: &Session) -> Option<&'l str>{ + self.details.as_ref() + .map(|dets| + session.explicit_if_allowed( + dets.as_str(), + self.details_less_explicit.as_ref().map(String::as_str) + ) + ) + } } impl Default for Item { @@ -75,7 +153,8 @@ impl Default for Item { total_xp: 0, total_stats: BTreeMap::new(), total_skills: BTreeMap::new(), - temporary_buffs: Vec::new() + temporary_buffs: Vec::new(), + pronouns: Pronouns::default_inanimate() } } } diff --git a/blastmud_game/src/models/session.rs b/blastmud_game/src/models/session.rs index 015f524b..3f0a1a38 100644 --- a/blastmud_game/src/models/session.rs +++ b/blastmud_game/src/models/session.rs @@ -8,6 +8,16 @@ pub struct Session { // be an Option, or things will crash out for existing sessions. } +impl Session { + pub fn explicit_if_allowed<'l>(self: &Self, explicit: &'l str, non_explicit: Option<&'l str>) -> &'l str { + if self.less_explicit_mode { + non_explicit.unwrap_or(explicit) + } else { + explicit + } + } +} + impl Default for Session { fn default() -> Self { Session { source: "unknown".to_owned(), less_explicit_mode: false } diff --git a/blastmud_game/src/models/user.rs b/blastmud_game/src/models/user.rs index e9f62a17..b37e7e98 100644 --- a/blastmud_game/src/models/user.rs +++ b/blastmud_game/src/models/user.rs @@ -1,6 +1,7 @@ use serde::{Serialize, Deserialize}; use chrono::{DateTime, Utc}; use std::collections::BTreeMap; +use crate::static_content::npc::statbot::StatbotState; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct UserTermData { @@ -75,6 +76,7 @@ pub struct User { pub terms: UserTermData, pub experience: UserExperienceData, + pub statbot: Option, pub raw_skills: BTreeMap, pub raw_stats: BTreeMap, // Reminder: Consider backwards compatibility when updating this. New fields should generally @@ -113,7 +115,8 @@ impl Default for User { banned_until: None, abandoned_at: None, chargen_last_completed_at: None, - + statbot: None, + terms: UserTermData::default(), experience: UserExperienceData::default(), raw_skills: BTreeMap::new(), diff --git a/blastmud_game/src/static_content/npc.rs b/blastmud_game/src/static_content/npc.rs index fa00fe0d..6190697d 100644 --- a/blastmud_game/src/static_content/npc.rs +++ b/blastmud_game/src/static_content/npc.rs @@ -1,26 +1,53 @@ use super::StaticItem; use crate::models::item::Item; use once_cell::sync::OnceCell; +use std::collections::BTreeMap; +use crate::message_handler::user_commands::{VerbContext, UResult}; +use async_trait::async_trait; + +pub mod statbot; + +#[async_trait] +pub trait NPCMessageHandler { + async fn handle( + self: &Self, + ctx: &mut VerbContext, + source: &Item, + target: &Item, + message: &str + ) -> UResult<()>; +} + pub struct NPC { pub code: &'static str, pub name: &'static str, pub description: &'static str, pub spawn_location: &'static str, + pub message_handler: Option<&'static (dyn NPCMessageHandler + Sync + Send)> } -static NPC_LIST: OnceCell> = OnceCell::new(); pub fn npc_list() -> &'static Vec { + static NPC_LIST: OnceCell> = OnceCell::new(); NPC_LIST.get_or_init(|| vec!( NPC { code: "repro_xv_chargen_statbot", name: "Statbot", description: "A silvery shiny metal mechanical being. It lets out a whirring sound as it moves.", - spawn_location: "room/repro_xv_chargen" + spawn_location: "room/repro_xv_chargen", + message_handler: Some(&statbot::StatbotMessageHandler) } )) } +pub fn npc_by_code() -> &'static BTreeMap<&'static str, &'static NPC> { + static NPC_CODE_MAP: OnceCell> = OnceCell::new(); + NPC_CODE_MAP.get_or_init( + || npc_list().iter() + .map(|npc| (npc.code, npc)) + .collect()) +} + pub fn npc_static_items() -> Box> { Box::new(npc_list().iter().map(|c| StaticItem { item_code: c.code, diff --git a/blastmud_game/src/static_content/npc/statbot.rs b/blastmud_game/src/static_content/npc/statbot.rs new file mode 100644 index 00000000..b7c2c14e --- /dev/null +++ b/blastmud_game/src/static_content/npc/statbot.rs @@ -0,0 +1,70 @@ +use super::NPCMessageHandler; +use crate::message_handler::user_commands::{VerbContext, UResult, get_user_or_fail}; +use async_trait::async_trait; +use crate::models::{item::Item, user::User, user::StatType}; +use ansi::ansi; +use serde::{Serialize, Deserialize}; + +pub struct StatbotMessageHandler; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum StatbotState { + Brains, + Senses, + Brawn, + Reflexes, + Endurance, + Cool, + AssignGender, + SetDescription +} + +async fn reply(ctx: &VerbContext<'_>, msg: &str) -> UResult<()> { + ctx.trans.queue_for_session( + ctx.session, + Some(&format!(ansi!("Statbot replies in a mechanical voice: \"{}\"\n"), + msg)) + ).await?; + Ok(()) +} + +fn next_action_text(user: &User) -> String { + let st = user.statbot.as_ref().unwrap_or(&StatbotState::Brains); + let brn = user.raw_stats.get(&StatType::Brains).cloned().unwrap_or(8); + let sen = user.raw_stats.get(&StatType::Senses).cloned().unwrap_or(8); + let brw = user.raw_stats.get(&StatType::Brawn).cloned().unwrap_or(8); + let refl = user.raw_stats.get(&StatType::Reflexes).cloned().unwrap_or(8); + let end = user.raw_stats.get(&StatType::Endurance).cloned().unwrap_or(8); + let col = user.raw_stats.get(&StatType::Cool).cloned().unwrap_or(8); + let tot = brn + sen + brw + refl + end + col; + let summary = format!("Brains: {}, Senses: {}, Brawn: {}, Reflexes: {}, Endurance: {}, Cool: {}. To spend: {}", brn, sen, brw, refl, end, col, tot - 48); + match st { + StatbotState::Brains => ansi!("I am Statbot, a robot servant of the empire, put here to help you choose how your body will function. The base body has 8 each of brains, senses, brawn, reflexes, endurance and cool - but you get 14 points of improvement. Each point spent lifts that stat by one. Your first job is to choose how much brainpower you will have. If you choose 8, you don't spend any points. There is a maximum of 15 - if you choose 15, you will spend 7 points and have 7 left for other stats.\n\n\tType -statbot brains 8 (or any other number) to set your brains to that number. You will be able to adjust your stats by sending me the new value, up until you leave here. Your stats now are: ").to_owned() + &summary, + StatbotState::Senses => "".to_owned(), + StatbotState::Brawn => "".to_owned(), + StatbotState::Reflexes => "".to_owned(), + StatbotState::Endurance => "".to_owned(), + StatbotState::Cool => "".to_owned(), + StatbotState::AssignGender => "".to_owned(), + StatbotState::SetDescription => "".to_owned() + } +} + +#[async_trait] +impl NPCMessageHandler for StatbotMessageHandler { + async fn handle( + self: &Self, + ctx: &mut VerbContext, + _source: &Item, + _target: &Item, + message: &str + ) -> UResult<()> { + let user = get_user_or_fail(ctx)?; + match message { + _ => { + reply(ctx, &next_action_text(user)).await?; + } + } + Ok(()) + } +} diff --git a/blastmud_game/src/static_content/room.rs b/blastmud_game/src/static_content/room.rs index 95d3207f..c40d81ad 100644 --- a/blastmud_game/src/static_content/room.rs +++ b/blastmud_game/src/static_content/room.rs @@ -164,7 +164,7 @@ pub fn room_list() -> &'static Vec { various stages of development floating in them. It smells like bleach. \ Being here makes you realise you aren't exactly alive right now... you \ have no body. But you sense you could go up and attach \ - your memories to a body matching your current stats."), + your memories to a body matching your current stats"), description_less_explicit: None, grid_coords: GridCoords { x: 1, y: 0, z: 1 }, exits: vec!() @@ -192,7 +192,9 @@ pub fn room_static_items() -> Box> { item_code: r.code.to_owned(), item_type: "room".to_owned(), display: r.name.to_owned(), - location: format!("room/{}", r.code), + details: Some(r.description.to_owned()), + details_less_explicit: r.description_less_explicit.map(|d|d.to_owned()), + location: format!("zone/{}", r.zone), is_static: true, ..Item::default() })