From 078519be959f3ad7b79e0866fe44c08ba06867b6 Mon Sep 17 00:00:00 2001 From: Condorra Date: Tue, 16 May 2023 22:02:42 +1000 Subject: [PATCH] Add journal system Also fix up bugs with navigation during death, and awarding payouts when you don't get any XP. --- .../message_handler/user_commands/close.rs | 14 +- .../src/message_handler/user_commands/look.rs | 2 +- .../message_handler/user_commands/movement.rs | 2 +- .../src/message_handler/user_commands/open.rs | 23 ++- blastmud_game/src/models.rs | 1 + blastmud_game/src/models/journal.rs | 28 +++ blastmud_game/src/models/user.rs | 10 +- blastmud_game/src/services/combat.rs | 48 +++-- blastmud_game/src/static_content.rs | 1 + blastmud_game/src/static_content/journals.rs | 177 ++++++++++++++++++ .../src/static_content/journals/first_dog.rs | 37 ++++ 11 files changed, 312 insertions(+), 31 deletions(-) create mode 100644 blastmud_game/src/models/journal.rs create mode 100644 blastmud_game/src/static_content/journals.rs create mode 100644 blastmud_game/src/static_content/journals/first_dog.rs diff --git a/blastmud_game/src/message_handler/user_commands/close.rs b/blastmud_game/src/message_handler/user_commands/close.rs index 472f22eb..c01fcf96 100644 --- a/blastmud_game/src/message_handler/user_commands/close.rs +++ b/blastmud_game/src/message_handler/user_commands/close.rs @@ -32,7 +32,12 @@ impl QueueCommandHandler for QueueHandler { _ => user_error("Unexpected queued command".to_owned())? }; let player_item = get_player_item_or_fail(ctx).await?; - match is_door_in_direction(&ctx.trans, &direction, &player_item).await? { + let use_location = if player_item.death_data.is_some() { + user_error("Your ethereal hands don't seem to be able to move the door.".to_owned())? + } else { + &player_item.location + }; + match is_door_in_direction(&ctx.trans, &direction, use_location).await? { DoorSituation::NoDoor => user_error("There is no door to close.".to_owned())?, DoorSituation::DoorIntoRoom { state: DoorState { open: false, .. }, .. } | DoorSituation::DoorOutOfRoom { state: DoorState { open: false, .. }, .. } => @@ -51,7 +56,12 @@ impl QueueCommandHandler for QueueHandler { _ => user_error("Unexpected queued command".to_owned())? }; let player_item = get_player_item_or_fail(ctx).await?; - let (room_1, dir_in_room, room_2) = match is_door_in_direction(&ctx.trans, &direction, &player_item).await? { + let use_location = if player_item.death_data.is_some() { + user_error("Your ethereal hands don't seem to be able to move the door.".to_owned())? + } else { + &player_item.location + }; + let (room_1, dir_in_room, room_2) = match is_door_in_direction(&ctx.trans, &direction, use_location).await? { DoorSituation::NoDoor => user_error("There is no door to close.".to_owned())?, DoorSituation::DoorIntoRoom { state: DoorState { open: false, .. }, .. } | DoorSituation::DoorOutOfRoom { state: DoorState { open: false, .. }, .. } => diff --git a/blastmud_game/src/message_handler/user_commands/look.rs b/blastmud_game/src/message_handler/user_commands/look.rs index 826a263f..1c757e51 100644 --- a/blastmud_game/src/message_handler/user_commands/look.rs +++ b/blastmud_game/src/message_handler/user_commands/look.rs @@ -351,7 +351,7 @@ impl UserVerb for Verb { 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) { - match is_door_in_direction(&ctx.trans, &dir, &player_item).await? { + match is_door_in_direction(&ctx.trans, &dir, use_location).await? { DoorSituation::NoDoor | DoorSituation::DoorOutOfRoom { state: DoorState { open: true, .. }, .. } | DoorSituation::DoorIntoRoom { state: DoorState { open: true, .. }, .. } => {}, diff --git a/blastmud_game/src/message_handler/user_commands/movement.rs b/blastmud_game/src/message_handler/user_commands/movement.rs index c0613cad..a2980bf8 100644 --- a/blastmud_game/src/message_handler/user_commands/movement.rs +++ b/blastmud_game/src/message_handler/user_commands/movement.rs @@ -190,7 +190,7 @@ pub async fn attempt_move_immediate( &orig_mover.location }; - match is_door_in_direction(trans, direction, orig_mover).await? { + match is_door_in_direction(trans, direction, use_location).await? { DoorSituation::NoDoor | DoorSituation::DoorOutOfRoom { state: DoorState { open: true, .. }, .. } => {}, DoorSituation::DoorIntoRoom { state: DoorState { open: true, .. }, room_with_door, .. } => { diff --git a/blastmud_game/src/message_handler/user_commands/open.rs b/blastmud_game/src/message_handler/user_commands/open.rs index 167a0753..e7cf09d9 100644 --- a/blastmud_game/src/message_handler/user_commands/open.rs +++ b/blastmud_game/src/message_handler/user_commands/open.rs @@ -75,10 +75,12 @@ impl TaskHandler for SwingShutHandler { pub async fn attempt_open_immediate(trans: &DBTrans, ctx_opt: &mut Option<&mut VerbContext<'_>>, who: &Item, direction: &Direction) -> UResult<()> { - if who.death_data.is_some() { - user_error("Your ethereal hands don't seem to be able to move the door.".to_owned())?; - } - let (room_1, dir_in_room, room_2) = match is_door_in_direction(trans, &direction, &who).await? { + let use_location = if who.death_data.is_some() { + user_error("Your ethereal hands don't seem to be able to move the door.".to_owned())? + } else { + &who.location + }; + let (room_1, dir_in_room, room_2) = match is_door_in_direction(trans, &direction, use_location).await? { DoorSituation::NoDoor => user_error("There is no door to open.".to_owned())?, DoorSituation::DoorIntoRoom { state: DoorState { open: true, .. }, .. } | DoorSituation::DoorOutOfRoom { state: DoorState { open: true, .. }, .. } => @@ -171,7 +173,12 @@ impl QueueCommandHandler for QueueHandler { _ => user_error("Unexpected command".to_owned())? }; let player_item = get_player_item_or_fail(ctx).await?; - match is_door_in_direction(&ctx.trans, &direction, &player_item).await? { + let use_location = if player_item.death_data.is_some() { + user_error("Your ethereal hands don't seem to be able to move the door.".to_owned())? + } else { + &player_item.location + }; + match is_door_in_direction(&ctx.trans, &direction, use_location).await? { DoorSituation::NoDoor => user_error("There is no door to open.".to_owned())?, DoorSituation::DoorIntoRoom { state: DoorState { open: true, .. }, .. } | DoorSituation::DoorOutOfRoom { state: DoorState { open: true, .. }, .. } => @@ -217,13 +224,13 @@ pub enum DoorSituation { DoorOutOfRoom { state: DoorState, room_with_door: Arc, new_room: Arc } // No lockable. } -pub async fn is_door_in_direction(trans: &DBTrans, direction: &Direction, player_item: &Item) -> +pub async fn is_door_in_direction(trans: &DBTrans, direction: &Direction, use_location: &str) -> UResult { - let (loc_type_t, loc_type_c) = player_item.location.split_once("/") + let (loc_type_t, loc_type_c) = use_location.split_once("/") .ok_or_else(|| UserError("Invalid location".to_owned()))?; let cur_loc_item = trans.find_item_by_type_code(loc_type_t, loc_type_c).await? .ok_or_else(|| UserError("Can't find your current location anymore.".to_owned()))?; - let new_loc_item = direction_to_item(trans, &player_item.location, direction).await? + let new_loc_item = direction_to_item(trans, use_location, direction).await? .ok_or_else(|| UserError("That exit doesn't really seem to go anywhere!".to_owned()))?; if let Some(door_state) = cur_loc_item.door_states.as_ref() diff --git a/blastmud_game/src/models.rs b/blastmud_game/src/models.rs index ca0ddfe9..7b160c6d 100644 --- a/blastmud_game/src/models.rs +++ b/blastmud_game/src/models.rs @@ -4,3 +4,4 @@ pub mod item; pub mod task; pub mod consent; pub mod corp; +pub mod journal; diff --git a/blastmud_game/src/models/journal.rs b/blastmud_game/src/models/journal.rs new file mode 100644 index 00000000..b930ed04 --- /dev/null +++ b/blastmud_game/src/models/journal.rs @@ -0,0 +1,28 @@ +use std::collections::BTreeSet; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Clone, Debug, Ord, PartialOrd, Eq, PartialEq)] +pub enum JournalType { + SlayedMeanDog, + Died +} + +#[derive(Serialize, Deserialize, Clone, Debug, Ord, PartialOrd, Eq, PartialEq)] +pub enum JournalInProgress { +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(default)] +pub struct JournalState { + pub completed_journals: BTreeSet, + pub in_progress_journals: Vec, +} + +impl Default for JournalState { + fn default() -> Self { + Self { + completed_journals: BTreeSet::new(), + in_progress_journals: vec!(), + } + } +} diff --git a/blastmud_game/src/models/user.rs b/blastmud_game/src/models/user.rs index a7c01e58..03db38b5 100644 --- a/blastmud_game/src/models/user.rs +++ b/blastmud_game/src/models/user.rs @@ -1,6 +1,9 @@ use serde::{Serialize, Deserialize}; use chrono::{DateTime, Utc}; -use super::item::{SkillType, StatType}; +use super::{ + item::{SkillType, StatType}, + journal::JournalState +}; use std::collections::BTreeMap; #[derive(Serialize, Deserialize, Clone, Debug)] @@ -11,9 +14,10 @@ pub struct UserTermData { } #[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(default)] pub struct UserExperienceData { pub spent_xp: u64, // Since last chargen complete. - pub completed_journals: BTreeMap>, + pub journals: JournalState, pub xp_change_for_this_reroll: i64, pub crafted_items: BTreeMap } @@ -54,7 +58,7 @@ impl Default for UserExperienceData { fn default() -> Self { UserExperienceData { spent_xp: 0, - completed_journals: BTreeMap::new(), + journals: Default::default(), xp_change_for_this_reroll: 0, crafted_items: BTreeMap::new(), } diff --git a/blastmud_game/src/services/combat.rs b/blastmud_game/src/services/combat.rs index ee7b3a95..ce6f1457 100644 --- a/blastmud_game/src/services/combat.rs +++ b/blastmud_game/src/services/combat.rs @@ -8,11 +8,13 @@ use crate::{ models::{ item::{Item, LocationActionType, Subattack, SkillType, DeathData}, task::{Task, TaskMeta, TaskDetails}, + journal::JournalType, }, static_content::{ possession_type::{WeaponData, possession_data, fist}, npc::npc_by_code, species::species_info_map, + journals::{check_journal_for_kill, award_journal_if_needed} }, message_handler::user_commands::{user_error, UResult}, regular_tasks::{TaskRunContext, TaskHandler}, @@ -165,25 +167,22 @@ pub async fn consider_reward_for(trans: &DBTrans, by_item: &mut Item, for_item: None => return Ok(()), Some(r) => r }; - if by_item.total_xp >= for_item.total_xp { - trans.queue_for_session(&session, Some("[You didn't gain any experience for that]\n")).await?; - return Ok(()); - } - let xp_gain = - (((for_item.total_xp - by_item.total_xp) as f64 * 10.0 / (by_item.total_xp + 1) as f64) as u64) - .min(100); - if xp_gain == 0 { - trans.queue_for_session(&session, Some("[You didn't gain any experience for that]\n")).await?; - return Ok(()); - } - - by_item.total_xp += xp_gain; let mut user = match trans.find_by_username(&by_item.item_code).await? { None => return Ok(()), Some(r) => r }; - user.experience.xp_change_for_this_reroll += xp_gain as i64; + let xp_gain = if by_item.total_xp >= for_item.total_xp { + 0 + } else { + let xp_gain = + (((for_item.total_xp - by_item.total_xp) as f64 * 10.0 / (by_item.total_xp + 1) as f64) as u64) + .min(100); + + by_item.total_xp += xp_gain; + user.experience.xp_change_for_this_reroll += xp_gain as i64; + xp_gain + }; // Now consider kill bonuses... if for_item.item_type == "npc" { @@ -196,7 +195,11 @@ pub async fn consider_reward_for(trans: &DBTrans, by_item: &mut Item, for_item: } trans.save_user_model(&user).await?; - trans.queue_for_session(&session, Some(&format!("You gained {} experience points!\n", xp_gain))).await?; + if xp_gain == 0 { + trans.queue_for_session(&session, Some("[You didn't gain any experience for that]\n")).await?; + } else { + trans.queue_for_session(&session, Some(&format!("You gained {} experience points!\n", xp_gain))).await?; + } Ok(()) } @@ -217,6 +220,7 @@ pub async fn handle_death(trans: &DBTrans, whom: &mut Item) -> DResult<()> { .map(|sp| sp.corpse_butchers_into.clone()).unwrap_or_else(|| vec!()), ..Default::default() }); + let vic_is_npc = whom.item_type == "npc"; if let Some(ac) = &whom.active_combat { let at_str = ac.attacking.clone(); for attacker in ac.attacked_by.clone().iter() { @@ -224,6 +228,9 @@ pub async fn handle_death(trans: &DBTrans, whom: &mut Item) -> DResult<()> { if let Some(aitem) = trans.find_item_by_type_code(atype, acode).await? { let mut new_aitem = (*aitem).clone(); consider_reward_for(trans, &mut new_aitem, &whom).await?; + if vic_is_npc { + check_journal_for_kill(trans, &mut new_aitem, whom).await?; + } stop_attacking_mut(trans, &mut new_aitem, whom, true).await?; trans.save_item_model(&new_aitem).await?; } @@ -237,7 +244,7 @@ pub async fn handle_death(trans: &DBTrans, whom: &mut Item) -> DResult<()> { } } } - if whom.item_type == "npc" { + if vic_is_npc { trans.upsert_task(&Task { meta: TaskMeta { task_code: whom.item_code.clone(), @@ -250,6 +257,15 @@ pub async fn handle_death(trans: &DBTrans, whom: &mut Item) -> DResult<()> { }).await?; } else if whom.item_type == "player" { trans.revoke_until_death_consent(&whom.item_code).await?; + match trans.find_by_username(&whom.item_code).await? { + None => {}, + Some(mut user) => { + if award_journal_if_needed(trans, &mut user, whom, JournalType::Died).await? { + trans.save_user_model(&user).await?; + } + } + } + } Ok(()) } diff --git a/blastmud_game/src/static_content.rs b/blastmud_game/src/static_content.rs index 19611c57..17e294d2 100644 --- a/blastmud_game/src/static_content.rs +++ b/blastmud_game/src/static_content.rs @@ -9,6 +9,7 @@ pub mod dynzone; pub mod npc; pub mod possession_type; pub mod species; +pub mod journals; mod fixed_item; pub struct StaticItem { diff --git a/blastmud_game/src/static_content/journals.rs b/blastmud_game/src/static_content/journals.rs new file mode 100644 index 00000000..9420d28e --- /dev/null +++ b/blastmud_game/src/static_content/journals.rs @@ -0,0 +1,177 @@ +use crate::{ + DResult, + models::{ + user::User, + item::Item, + journal::JournalType, + } +}; +use std::collections::{BTreeMap}; +use once_cell::sync::OnceCell; +use mockall_double::double; +#[double] use crate::db::DBTrans; +use log::warn; +use async_trait::async_trait; +use super::species::SpeciesType; +use itertools::Itertools; + +mod first_dog; + +#[allow(unused)] +pub enum KillSubscriptionType { + SpecificNPCSpecies { species: SpeciesType }, + SpecificNPC { code: &'static str }, +} + +#[async_trait] +pub trait JournalChecker { + fn kill_subscriptions(&self) -> Vec; + async fn handle_kill( + &self, + trans: &DBTrans, + user: &mut User, + player: &mut Item, + victim: &Item + ) -> DResult; +} + +pub struct JournalData { + name: &'static str, + details: &'static str, + xp: u64, + +} + +pub fn journal_types() -> &'static BTreeMap { + static JOURNAL_TYPES: OnceCell> = OnceCell::new(); + return JOURNAL_TYPES.get_or_init(|| vec!( + (JournalType::SlayedMeanDog, JournalData { + name: "Slayed a mean dog", + details: "killing a mean street dog for the first time.", + xp: 100 + }), + (JournalType::Died, JournalData { + name: "Carked it", + details: "dying for the first time. Fortunately, you can come back by recloning in to a fresh body, just with fewer credits, a bit less experience, and a bruised ego! All your stuff is still on your body, so better go find it, or give up on it.", + xp: 150 + }) + ).into_iter().collect()) +} + +pub fn journal_checkers() -> &'static Vec<&'static (dyn JournalChecker + Sync + Send)> { + static CHECKERS: OnceCell> = OnceCell::new(); + CHECKERS.get_or_init(|| vec!( + &first_dog::CHECKER + )) +} + +pub fn checkers_by_species() -> + &'static BTreeMap> +{ + static MAP: OnceCell>> = + OnceCell::new(); + MAP.get_or_init(|| { + let species_groups = journal_checkers().iter().flat_map( + |jc| + jc.kill_subscriptions().into_iter() + .filter_map(|sub| + match sub { + KillSubscriptionType::SpecificNPCSpecies { species } => + Some((species.clone(), jc.clone())), + _ => None + }) + ).group_by(|v| v.0.clone()); + species_groups + .into_iter() + .map(|(species, g)| (species, g.into_iter().map(|v| v.1).collect())) + .collect() + }) +} + +pub fn checkers_by_npc() -> + &'static BTreeMap<&'static str, + Vec<&'static (dyn JournalChecker + Sync + Send)>> +{ + static MAP: OnceCell>> = + OnceCell::new(); + MAP.get_or_init(|| { + let npc_groups = journal_checkers().iter().flat_map( + |jc| + jc.kill_subscriptions().into_iter() + .filter_map(|sub| + match sub { + KillSubscriptionType::SpecificNPC { code } => + Some((code.clone(), jc.clone())), + _ => None + }) + ).group_by(|v| v.0.clone()); + npc_groups + .into_iter() + .map(|(species, g)| (species, g.into_iter().map(|v| v.1).collect())) + .collect() + }) +} + +pub async fn award_journal_if_needed(trans: &DBTrans, + user: &mut User, + player: &mut Item, + journal: JournalType) -> DResult { + if user.experience.journals.completed_journals.contains(&journal) { + return Ok(false); + } + + let journal_data = match journal_types().get(&journal) { + None => { + warn!("Tried to award journal type {:#?} that doesn't exist.", &journal); + return Ok(false); + }, + Some(v) => v + }; + user.experience.journals.completed_journals.insert(journal); + // Note: Not counted as 'change for this reroll' since it is permanent. + player.total_xp += journal_data.xp; + if let Some((sess, _)) = trans.find_session_for_player(&player.item_code).await? { + trans.queue_for_session( + &sess, + Some(&format!("Journal earned: {} - You earned {} XP for {}\n", + journal_data.name, journal_data.xp, journal_data.details) + )).await?; + } + + Ok(true) +} + +pub async fn check_journal_for_kill(trans: &DBTrans, + player: &mut Item, + victim: &Item) -> DResult { + if player.item_type != "player" { + return Ok(false); + } + let mut user = match trans.find_by_username(&player.item_code).await? { + None => return Ok(false), + Some(u) => u + }; + + let mut did_work = false; + + if let Some(checkers) = checkers_by_species().get(&victim.species) { + for checker in checkers { + did_work = did_work || + checker.handle_kill(trans, &mut user, player, victim).await?; + } + } + if let Some(checkers) = checkers_by_npc().get(victim.item_code.as_str()) { + for checker in checkers { + did_work = did_work || + checker.handle_kill(trans, &mut user, player, victim).await?; + } + } + + if did_work { + trans.save_user_model(&user).await?; + } + Ok(did_work) +} diff --git a/blastmud_game/src/static_content/journals/first_dog.rs b/blastmud_game/src/static_content/journals/first_dog.rs new file mode 100644 index 00000000..2ca08497 --- /dev/null +++ b/blastmud_game/src/static_content/journals/first_dog.rs @@ -0,0 +1,37 @@ +use super::{JournalChecker, KillSubscriptionType, award_journal_if_needed}; +use crate::{ + DResult, + static_content::species::SpeciesType, + models::{ + user::User, + item::Item, + journal::JournalType, + } +}; +use async_trait::async_trait; +use mockall_double::double; +#[double] use crate::db::DBTrans; + +pub struct FirstDogChecker; +#[async_trait] +impl JournalChecker for FirstDogChecker { + fn kill_subscriptions(&self) -> Vec { + vec!( + KillSubscriptionType::SpecificNPCSpecies { + species: SpeciesType::Dog + } + ) + } + + async fn handle_kill( + &self, + trans: &DBTrans, + user: &mut User, + player: &mut Item, + _victim: &Item + ) -> DResult { + award_journal_if_needed(trans, user, player, JournalType::SlayedMeanDog).await + } +} + +pub static CHECKER: FirstDogChecker = FirstDogChecker;