diff --git a/blastmud_game/src/db.rs b/blastmud_game/src/db.rs index ab08b599..12b89f45 100644 --- a/blastmud_game/src/db.rs +++ b/blastmud_game/src/db.rs @@ -18,6 +18,7 @@ use crate::models::{ }, task::{Task, TaskParse}, consent::{Consent, ConsentType}, + corp::{Corp, CorpId, CorpMembership}, }; use tokio_postgres::types::ToSql; use std::collections::BTreeSet; @@ -788,6 +789,54 @@ impl DBTrans { &serde_json::to_value(details)?]).await?; Ok(()) } + + pub async fn find_corp_by_name(&self, name: &str) -> DResult> { + Ok(match self.pg_trans()? + .query_opt("SELECT corp_id, details FROM corps WHERE LOWER(details->>'name') = $1", + &[&name.to_lowercase()]) + .await? { + None => None, + Some(row) => + Some( + (CorpId(row.get("corp_id")), serde_json::from_value(row.get("details"))?) + ) + }) + } + + pub async fn create_corp(&self, details: &Corp) -> DResult { + let id = self.pg_trans()? + .query_one("INSERT INTO corps (details) VALUES ($1) RETURNING corp_id", &[&serde_json::to_value(details)?]).await? + .get("corp_id"); + Ok(CorpId(id)) + } + + pub async fn upsert_corp_membership(&self, corp: &CorpId, username: &str, details: &CorpMembership) -> DResult<()> { + self.pg_trans()? + .execute("INSERT INTO corp_membership (corp_id, member_username, details) \ + VALUES ($1, $2, $3) \ + ON CONFLICT (corp_id, member_username) DO UPDATE SET \ + details = excluded.details", + &[&corp.0, &username.to_lowercase(), + &serde_json::to_value(details)?] + ).await?; + Ok(()) + } + + pub async fn get_corp_memberships_for_user(&self, username: &str) -> DResult> { + Ok(self.pg_trans()? + .query("SELECT m.corp_id, c.details->>'name', m.details FROM corp_membership m \ + JOIN corps c ON c.corp_id = m.corp_id WHERE m.member_username = $1 \ + AND m.details->>'joined_at' IS NOT NULL", &[&username.to_lowercase()]) + .await? + .into_iter() + .filter_map(|row| + match serde_json::from_value(row.get(2)) { + Err(_) => None, + Ok(j) => + Some((CorpId(row.get(0)), row.get(1), j)) + }) + .collect()) + } 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 c7f23c10..b70f0933 100644 --- a/blastmud_game/src/message_handler/user_commands.rs +++ b/blastmud_game/src/message_handler/user_commands.rs @@ -30,14 +30,16 @@ pub mod movement; mod page; pub mod parsing; mod quit; -mod register; +pub mod register; pub mod say; mod score; +mod sign; mod status; pub mod use_cmd; mod whisper; mod who; pub mod wield; +mod write; pub struct VerbContext<'l> { pub session: &'l ListenerSession, @@ -145,6 +147,8 @@ static REGISTERED_COMMANDS: UserVerbRegistry = phf_map! { "sc" => score::VERB, "score" => score::VERB, + "sign" => sign::VERB, + "st" => status::VERB, "stat" => status::VERB, "status" => status::VERB, @@ -157,6 +161,7 @@ static REGISTERED_COMMANDS: UserVerbRegistry = phf_map! { "wield" => wield::VERB, "who" => who::VERB, + "write" => write::VERB, }; fn resolve_handler(ctx: &VerbContext, cmd: &str) -> Option<&'static UserVerbRef> { diff --git a/blastmud_game/src/message_handler/user_commands/register.rs b/blastmud_game/src/message_handler/user_commands/register.rs index 7108c767..bd9ae7f2 100644 --- a/blastmud_game/src/message_handler/user_commands/register.rs +++ b/blastmud_game/src/message_handler/user_commands/register.rs @@ -60,6 +60,9 @@ impl UserVerb for Verb { if ctx.trans.find_by_username(username).await?.is_some() { user_error("Username already exists".to_owned())?; } + if ctx.trans.find_corp_by_name(username).await?.is_some() { + user_error("Username clashes with existing corp name".to_owned())?; + } if password.len() < 6 { user_error("Password must be 6 characters long or longer".to_owned())?; } else if !validator::validate_email(email) { diff --git a/blastmud_game/src/message_handler/user_commands/sign.rs b/blastmud_game/src/message_handler/user_commands/sign.rs new file mode 100644 index 00000000..49676e52 --- /dev/null +++ b/blastmud_game/src/message_handler/user_commands/sign.rs @@ -0,0 +1,37 @@ +use super::{VerbContext, UserVerb, UserVerbRef, UResult, user_error, get_player_item_or_fail, search_item_for_user}; +use crate::{ + db::ItemSearchParams, + static_content::possession_type::possession_data, +}; +use async_trait::async_trait; + +pub struct Verb; +#[async_trait] +impl UserVerb for Verb { + async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, remaining: &str) -> UResult<()> { + let item_name = remaining.trim(); + if item_name == "" { + user_error("Sign what? Try: sign something".to_owned())?; + } + let player_item = get_player_item_or_fail(ctx).await?; + let item = search_item_for_user(ctx, &ItemSearchParams { + include_contents: true, + ..ItemSearchParams::base(&player_item, item_name) + }).await?; + if item.item_type != "possession" { + user_error("You can't sign that!".to_owned())?; + } + let handler = match item.possession_type.as_ref() + .and_then(|pt| possession_data().get(pt)) + .and_then(|pd| pd.sign_handler) { + None => user_error("You can't sign that!".to_owned())?, + Some(h) => h + }; + + handler.cmd(ctx, &player_item, &item).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/write.rs b/blastmud_game/src/message_handler/user_commands/write.rs new file mode 100644 index 00000000..a6f6d8fd --- /dev/null +++ b/blastmud_game/src/message_handler/user_commands/write.rs @@ -0,0 +1,37 @@ +use super::{VerbContext, UserVerb, UserVerbRef, UResult, user_error, get_player_item_or_fail, search_item_for_user}; +use crate::{ + db::ItemSearchParams, + static_content::possession_type::possession_data, +}; +use async_trait::async_trait; + +pub struct Verb; +#[async_trait] +impl UserVerb for Verb { + async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, remaining: &str) -> UResult<()> { + let (write_what_raw, on_what_raw) = match remaining.rsplit_once(" on ") { + None => user_error("Write on what? Try write something on something".to_owned())?, + Some(v) => v + }; + let player_item = get_player_item_or_fail(ctx).await?; + let item = search_item_for_user(ctx, &ItemSearchParams { + include_contents: true, + ..ItemSearchParams::base(&player_item, on_what_raw.trim()) + }).await?; + if item.item_type != "possession" { + user_error("You can't write on that!".to_owned())?; + } + let handler = match item.possession_type.as_ref() + .and_then(|pt| possession_data().get(pt)) + .and_then(|pd| pd.write_handler) { + None => user_error("You can't write on that!".to_owned())?, + Some(h) => h + }; + + handler.write_cmd(ctx, &player_item, &item, write_what_raw.trim()).await?; + + Ok(()) + } +} +static VERB_INT: Verb = Verb; +pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef; diff --git a/blastmud_game/src/models.rs b/blastmud_game/src/models.rs index 9d50dbe9..ca0ddfe9 100644 --- a/blastmud_game/src/models.rs +++ b/blastmud_game/src/models.rs @@ -3,3 +3,4 @@ pub mod user; pub mod item; pub mod task; pub mod consent; +pub mod corp; diff --git a/blastmud_game/src/models/corp.rs b/blastmud_game/src/models/corp.rs index f8b7b36b..425cc890 100644 --- a/blastmud_game/src/models/corp.rs +++ b/blastmud_game/src/models/corp.rs @@ -1,4 +1,15 @@ use serde::{Serialize, Deserialize}; +use chrono::{DateTime, Utc}; + +#[derive(Serialize, Deserialize)] +pub enum CorpPermission { + Holder, // Implies all permissions. + Hire, + Fire, + ChangeJobTitle, +} + +pub struct CorpId(pub i64); #[derive(Serialize, Deserialize)] pub struct Corp { @@ -7,4 +18,36 @@ pub struct Corp { // allow_combat off. This will allow duly authorised corp members to // consent to combat with other corps, and have it apply to members. pub allow_combat_required: bool, + pub member_permissions: Vec, +} + +impl Default for Corp { + fn default() -> Self { + Self { + name: "Unset".to_owned(), + allow_combat_required: false, + member_permissions: vec!(), + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct CorpMembership { + pub invited_at: Option>, + pub joined_at: Option>, + pub permissions: Vec, + pub allow_combat: bool, + pub job_title: String, +} + +impl Default for CorpMembership { + fn default() -> CorpMembership { + CorpMembership { + invited_at: None, + joined_at: None, + permissions: vec!(), + allow_combat: false, + job_title: "Employee".to_owned(), + } + } } diff --git a/blastmud_game/src/static_content/possession_type.rs b/blastmud_game/src/static_content/possession_type.rs index 4a0d711d..98fcd17f 100644 --- a/blastmud_game/src/static_content/possession_type.rs +++ b/blastmud_game/src/static_content/possession_type.rs @@ -2,11 +2,13 @@ use serde::{Serialize, Deserialize}; use crate::{ models::item::{SkillType, Item, Pronouns}, models::consent::ConsentType, + message_handler::user_commands::{UResult, VerbContext}, }; use once_cell::sync::OnceCell; use std::collections::BTreeMap; use rand::seq::SliceRandom; use super::species::BodyPart; +use async_trait::async_trait; mod fangs; mod antenna_whip; @@ -80,7 +82,7 @@ pub enum UseEffect { BroadcastMessage { messagef: Box (String, String) + Sync + Send>}, // skill_multiplier is always positive - sign flipped for crit fails. ChangeTargetHealth { delay_secs: u64, base_effect: i64, skill_multiplier: f64, - max_effect: i64, message: Box (String, String) + Sync + Send> } + max_effect: i64, message: Box (String, String) + Sync + Send> }, } pub struct UseData { @@ -109,6 +111,16 @@ impl Default for UseData { } } +#[async_trait] +pub trait WriteHandler { + async fn write_cmd(&self, ctx: &mut VerbContext, player: &Item, on_what: &Item, write_what: &str) -> UResult<()>; +} + +#[async_trait] +pub trait ArglessHandler { + async fn cmd(&self, ctx: &mut VerbContext, player: &Item, what: &Item) -> UResult<()>; +} + pub struct PossessionData { pub weapon_data: Option, pub display: &'static str, @@ -121,6 +133,8 @@ pub struct PossessionData { pub use_data: Option, pub becomes_on_spent: Option, pub weight: u64, + pub write_handler: Option<&'static (dyn WriteHandler + Sync + Send)>, + pub sign_handler: Option<&'static (dyn ArglessHandler + Sync + Send)>, } impl Default for PossessionData { @@ -137,6 +151,8 @@ impl Default for PossessionData { charge_data: None, becomes_on_spent: None, use_data: None, + write_handler: None, + sign_handler: None, } } } diff --git a/blastmud_game/src/static_content/possession_type/corp_licence.rs b/blastmud_game/src/static_content/possession_type/corp_licence.rs index 716bb530..4c35f455 100644 --- a/blastmud_game/src/static_content/possession_type/corp_licence.rs +++ b/blastmud_game/src/static_content/possession_type/corp_licence.rs @@ -1,41 +1,127 @@ -use super::{PossessionData, UseData, UseEffect}; -use crate::models::item::{SkillType, ItemSpecialData}; +use super::{PossessionData, WriteHandler, ArglessHandler}; +use crate::{ + models::{ + item::{Item, ItemSpecialData}, + corp::{Corp, CorpMembership, CorpPermission}, + }, + message_handler::user_commands::{ + register::is_invalid_username, + parsing::parse_username, + user_error, + UResult, + VerbContext, + }, + services::broadcast_to_room, +}; use ansi::ansi; +use async_trait::async_trait; +use chrono::Utc; use super::PossessionType::*; +pub struct CorpLicenceHandler { +} + +#[async_trait] +impl WriteHandler for CorpLicenceHandler { + async fn write_cmd(&self, ctx: &mut VerbContext, _player: &Item, on_what: &Item, write_what: &str) -> UResult<()> { + let name = match parse_username(write_what) { + Err(e) => user_error("Invalid corp name: ".to_owned() + e)?, + Ok((_, rest)) if rest != "" => + user_error("No spaces allowed in corp names!".to_owned())?, + Ok((name, _)) => name + }; + if is_invalid_username(name) { + user_error("Sorry, that corp name isn't allowed. Try another".to_owned())?; + } + if ctx.trans.find_by_username(name).await?.is_some() { + user_error("Corp name clashes with existing user name".to_owned())?; + } + if ctx.trans.find_corp_by_name(&name).await?.is_some() { + user_error("Corp name already taken!".to_owned())?; + } + + let mut item_clone = on_what.clone(); + item_clone.special_data = Some(ItemSpecialData::ItemWriting { + text: name.to_owned() + }); + ctx.trans.save_item_model(&item_clone).await?; + ctx.trans.queue_for_session(ctx.session, Some(&format!(ansi!( + "The pencil makes a scratching sound as you mark the paper with the attached \ + pencil and write \"{}\" on it. [Hint: Try the use command to submit \ + your signed paperwork and register the corporation, or write again \ + to erase and change the name].\n"), name))).await?; + + Ok(()) + } +} + +#[async_trait] +impl ArglessHandler for CorpLicenceHandler { + async fn cmd(&self, ctx: &mut VerbContext, player: &Item, what: &Item) -> UResult<()> { + let name = match what.special_data.as_ref() { + Some(ItemSpecialData::ItemWriting { text }) => text, + _ => user_error("You have to write your corp's name on it first!".to_owned())? + }; + if ctx.trans.find_by_username(&name).await?.is_some() { + user_error("Corp name clashes with existing user name".to_owned())?; + } + if ctx.trans.find_corp_by_name(&name).await?.is_some() { + user_error("Corp name already taken!".to_owned())?; + } + + if ctx.trans.get_corp_memberships_for_user(&player.item_code).await?.len() >= 5 { + user_error("You can't be in more than 5 corps".to_owned())?; + } + + broadcast_to_room(ctx.trans, &player.location, None, + &format!( + "{} signs a contract establishing {} as a corp\n", + &player.display_for_sentence(true, 1, true), + name + ), + Some( + &format!("{} signs a contract establishing {} as a corp\n", + &player.display_for_sentence(false, 1, true), + name + ) + )).await?; + let corp_id = ctx.trans.create_corp(&Corp { + name: name.to_owned(), + ..Default::default() + }).await?; + ctx.trans.upsert_corp_membership( + &corp_id, &player.item_code, + &CorpMembership { + joined_at: Some(Utc::now()), + permissions: vec!(CorpPermission::Holder), + allow_combat: true, + job_title: "Founder".to_owned(), + ..Default::default() + }).await?; + + let mut what_mut = what.clone(); + what_mut.possession_type = Some(CertificateOfIncorporation); + let cp_data = cert_data(); + what_mut.display = cp_data.display.to_owned(); + what_mut.details = Some(cp_data.details.to_owned()); + ctx.trans.save_item_model(&what_mut).await?; + + Ok(()) + } +} + +static CORP_LICENCE_HANDLER: CorpLicenceHandler = CorpLicenceHandler {}; + pub fn data() -> PossessionData { PossessionData { display: "new corp licence", - details: ansi!("A blank form that you can use to establish a new corp. It rests on a clipboard with a pencil attached by a chain. There is a space to write on it [try write Blah on licence followed by use licence to create a corp named Blah]"), + details: ansi!("A blank form that you can use to establish a new corp. It rests on a clipboard with a pencil attached by a chain. There is a space to write on it [try write Blah on licence followed by sign licence to create a corp named Blah]"), aliases: vec!("form", "license", "licence", "new"), - use_data: Some(UseData { - uses_skill: SkillType::Persuade, - diff_level: 4.0, - crit_fail_effects: vec!(), - fail_effects: vec!(), - success_effects: vec!( - UseEffect::BroadcastMessage { - messagef: Box::new(|player, _item, _target| ( - format!( - "{} signs a contract establishing Blah as a corp\n", - &player.display_for_sentence(true, 1, true), - ), - format!("{} signs a contract establishing Blah as a corp\n", - &player.display_for_sentence(false, 1, true), - ))) - }, - ), - errorf: Box::new( - |item, _target| - match item.special_data { - Some(ItemSpecialData::ItemWriting { .. }) => None, - _ => Some("You have to your corp's name on it first!".to_owned()) - }), - ..Default::default() - }), weight: 10, becomes_on_spent: Some(CertificateOfIncorporation), + write_handler: Some(&CORP_LICENCE_HANDLER), + sign_handler: Some(&CORP_LICENCE_HANDLER), ..Default::default() } } diff --git a/schema/schema.sql b/schema/schema.sql index e8c1895c..ebc55867 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -58,13 +58,14 @@ CREATE TABLE corps ( corp_id BIGSERIAL NOT NULL PRIMARY KEY, details JSONB NOT NULL ); -CREATE INDEX corp_by_name ON corps((details->>'name')); +CREATE INDEX corp_by_name ON corps((LOWER(details->>'name'))); CREATE TABLE corp_membership ( corp_id BIGSERIAL NOT NULL REFERENCES corps(corp_id), member_username TEXT NOT NULL REFERENCES users(username), details JSONB NOT NULL, PRIMARY KEY (corp_id, member_username) ); +CREATE INDEX corp_membership_by_username ON corp_membership(member_username); CREATE TABLE user_consent ( consenting_user TEXT NOT NULL REFERENCES users(username),