diff --git a/blastmud_game/src/db.rs b/blastmud_game/src/db.rs index 7adf0fe..f5ca9cd 100644 --- a/blastmud_game/src/db.rs +++ b/blastmud_game/src/db.rs @@ -814,6 +814,31 @@ impl DBTrans { }) } + pub async fn match_user_corp_by_name(&self, corpname: &str, username: &str) -> + DResult> { + Ok(match self.pg_trans()? + .query_opt("SELECT c.corp_id, c.details AS cdetails, m.details AS mdetails FROM corps c \ + JOIN corp_membership m ON m.corp_id = c.corp_id \ + WHERE LOWER(c.details->>'name') LIKE $1 AND \ + m.member_username = $2 \ + ORDER BY \ + ABS(length(c.details->>'name')-length($1)) DESC \ + LIMIT 1", + &[&(corpname.replace("\\", "\\\\") + .replace("_", "\\_") + .replace("%", "") + .to_lowercase() + "%"), &(username.to_lowercase())]) + .await? { + None => None, + Some(row) => + Some( + (CorpId(row.get("corp_id")), + serde_json::from_value(row.get("cdetails"))?, + serde_json::from_value(row.get("mdetails"))?) + ) + }) + } + 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? @@ -821,6 +846,15 @@ impl DBTrans { Ok(CorpId(id)) } + pub async fn expire_old_invites(&self) -> DResult<()> { + self.pg_trans()? + .execute( + "DELETE FROM corp_membership WHERE details->>'invited_at' <= $1", + &[&(Utc::now() - chrono::Duration::hours(4)).to_rfc3339_opts(chrono::SecondsFormat::Nanos, true)] + ).await?; + Ok(()) + } + 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) \ @@ -837,7 +871,10 @@ impl DBTrans { 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()]) + AND m.details->>'joined_at' IS NOT NULL \ + ORDER BY (m.details->>'priority')::int DESC NULLS LAST, \ + (m.details->>'joined_at') :: TIMESTAMPTZ ASC", + &[&username.to_lowercase()]) .await? .into_iter() .filter_map(|row| diff --git a/blastmud_game/src/message_handler/user_commands.rs b/blastmud_game/src/message_handler/user_commands.rs index b70f093..db4a453 100644 --- a/blastmud_game/src/message_handler/user_commands.rs +++ b/blastmud_game/src/message_handler/user_commands.rs @@ -15,6 +15,7 @@ mod agree; mod allow; pub mod attack; mod buy; +mod corp; pub mod drop; pub mod get; mod describe; @@ -118,6 +119,7 @@ static REGISTERED_COMMANDS: UserVerbRegistry = phf_map! { "attack" => attack::VERB, "buy" => buy::VERB, + "corp" => corp::VERB, "drop" => drop::VERB, "get" => get::VERB, "inventory" => inventory::VERB, diff --git a/blastmud_game/src/message_handler/user_commands/corp.rs b/blastmud_game/src/message_handler/user_commands/corp.rs new file mode 100644 index 0000000..8b14a78 --- /dev/null +++ b/blastmud_game/src/message_handler/user_commands/corp.rs @@ -0,0 +1,142 @@ +use super::{ + VerbContext, + UserVerb, + UserVerbRef, + UResult, + user_error, + get_user_or_fail, + get_player_item_or_fail, + search_item_for_user, + parsing::parse_command_name, +}; +use crate::{ + models::corp::{CorpMembership, CorpPermission}, + db::ItemSearchParams, +}; +use chrono::Utc; +use async_trait::async_trait; +use ansi::ansi; + +fn check_corp_perm(perm: &CorpPermission, mem: &CorpMembership) -> bool { + mem.permissions.iter().any(|p| *p == CorpPermission::Holder || *p == *perm) +} + +async fn corp_invite(ctx: &mut VerbContext<'_>, remaining: &str) -> UResult<()> { + let (target_raw, into_raw) = match remaining.rsplit_once(" into ") { + None => user_error( + ansi!("Usage: corp hire username into corpname").to_owned() + )?, + Some(c) => c + }; + let user = get_user_or_fail(ctx)?; + let player = get_player_item_or_fail(ctx).await?; + let (corp_id, corp, mem) = + match ctx.trans.match_user_corp_by_name(into_raw.trim(), &user.username).await? { + None => user_error("No such corp!".to_owned())?, + Some(c) => c + }; + if !check_corp_perm(&CorpPermission::Hire, &mem) || mem.joined_at.is_none() { + user_error("You don't have hire permissions for that corp".to_owned())?; + } + + let target_user = search_item_for_user(ctx, &ItemSearchParams { + include_loc_contents: true, + ..ItemSearchParams::base(&player, target_raw.trim()) + }).await?; + + if target_user.item_type != "player" { + user_error("Only players can be hired.".to_owned())?; + } + + let (their_sess, their_sess_dat) = match ctx.trans.find_session_for_player(&target_user.item_code).await? { + None => user_error("The user needs to be logged in while you hire them.".to_owned())?, + Some(c) => c + }; + + ctx.trans.expire_old_invites().await?; + + match ctx.trans.match_user_corp_by_name(into_raw.trim(), &target_user.item_code).await? { + None => (), + Some((_, _, CorpMembership { invited_at: Some(_), .. })) => + user_error("They've already been invited.".to_owned())?, + Some((_, _, _)) => + user_error("They're already hired.".to_owned())?, + }; + + let new_mem = CorpMembership { + invited_at: Some(Utc::now()), + allow_combat: corp.allow_combat_required, + ..Default::default() + }; + ctx.trans.upsert_corp_membership(&corp_id, &target_user.item_code, &new_mem).await?; + + ctx.trans.queue_for_session(&their_sess, Some(&format!( + ansi!("{} wants to hire you into {}! Type corp join {} to accept.{}\n"), + &player.display_for_sentence(!their_sess_dat.less_explicit_mode, 1, true), + &corp.name, + &corp.name, + if new_mem.allow_combat { + " This corp is configured to allow the leadership to declare war on other corps; if you join, you may be attacked by members of other corps, even without your personal consent." + } else { + "" + } + ))).await?; + ctx.trans.queue_for_session( + &ctx.session, + Some(&format!( + "You offer to hire {} into {}.\n", + &target_user.display_for_sentence(!their_sess_dat.less_explicit_mode, 1, false), + &corp.name + ))).await?; + Ok(()) +} + +async fn corp_join(_ctx: &mut VerbContext<'_>, _remaining: &str) -> UResult<()> { + user_error("Join not implemented yet".to_owned())? +} + +async fn corp_list(ctx: &mut VerbContext<'_>, _remaining: &str) -> UResult<()> { + let mut msg = String::new(); + let user = get_user_or_fail(ctx)?; + + let corps = ctx.trans.get_corp_memberships_for_user(&user.username).await?; + if corps.is_empty() { + msg.push_str(ansi!( + "You don't yet belong to any corps - try who to see the \ + corps of people online, and message someone in a corp to see if they \ + will hire you! Or buy a new corp licence at the Kings Office in Melbs.\n" + )); + } else { + msg.push_str("You belong to the following corps:\n"); + msg.push_str(&format!(ansi!("| {:20} | {:7} | {:5} |\n"), + "Name", "Combat?", "Order")); + for corp in &corps { + match corp { + (_, name, CorpMembership { allow_combat: combat, priority: p, .. }) => { + msg.push_str(&format!("| {:20} | {:7} | {:5} |\n", + name, if *combat { "Y" } else { "N" }, p)); + } + } + } + } + + ctx.trans.queue_for_session(ctx.session, Some(&msg)).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 (command, remaining) = parse_command_name(remaining); + match command.to_lowercase().as_str() { + "hire" | "invite" => corp_invite(ctx, remaining).await?, + "join" => corp_join(ctx, remaining).await?, + "" | "list" => corp_list(ctx, remaining).await?, + _ => user_error("Unknown command".to_owned())? + } + Ok(()) + } +} +static VERB_INT: Verb = Verb; +pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef; diff --git a/blastmud_game/src/models/corp.rs b/blastmud_game/src/models/corp.rs index a490f37..f9a4150 100644 --- a/blastmud_game/src/models/corp.rs +++ b/blastmud_game/src/models/corp.rs @@ -1,7 +1,7 @@ use serde::{Serialize, Deserialize}; use chrono::{DateTime, Utc}; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, PartialEq)] pub enum CorpPermission { Holder, // Implies all permissions. Hire, @@ -12,6 +12,7 @@ pub enum CorpPermission { pub struct CorpId(pub i64); #[derive(Serialize, Deserialize)] +#[serde(default)] pub struct Corp { pub name: String, // If true, new members get allow_combat on, and members cannot turn @@ -32,6 +33,7 @@ impl Default for Corp { } #[derive(Serialize, Deserialize)] +#[serde(default)] pub struct CorpMembership { pub invited_at: Option>, pub joined_at: Option>, diff --git a/schema/schema.sql b/schema/schema.sql index ebc5586..d333db2 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -66,6 +66,7 @@ CREATE TABLE corp_membership ( PRIMARY KEY (corp_id, member_username) ); CREATE INDEX corp_membership_by_username ON corp_membership(member_username); +CREATE INDEX corp_membership_by_invited ON corp_membership((details->>'invited_at')); CREATE TABLE user_consent ( consenting_user TEXT NOT NULL REFERENCES users(username),