Add journal system

Also fix up bugs with navigation during death, and awarding payouts when
you don't get any XP.
This commit is contained in:
Condorra 2023-05-16 22:02:42 +10:00
parent 6ce1aff83e
commit 078519be95
11 changed files with 312 additions and 31 deletions

View File

@ -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, .. }, .. } =>

View File

@ -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, .. }, .. } => {},

View File

@ -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, .. } => {

View File

@ -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<Item>, new_room: Arc<Item> } // 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<DoorSituation> {
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()

View File

@ -4,3 +4,4 @@ pub mod item;
pub mod task;
pub mod consent;
pub mod corp;
pub mod journal;

View File

@ -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<JournalType>,
pub in_progress_journals: Vec<JournalInProgress>,
}
impl Default for JournalState {
fn default() -> Self {
Self {
completed_journals: BTreeSet::new(),
in_progress_journals: vec!(),
}
}
}

View File

@ -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<String, DateTime<Utc>>,
pub journals: JournalState,
pub xp_change_for_this_reroll: i64,
pub crafted_items: BTreeMap<String, u64>
}
@ -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(),
}

View File

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

View File

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

View File

@ -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<KillSubscriptionType>;
async fn handle_kill(
&self,
trans: &DBTrans,
user: &mut User,
player: &mut Item,
victim: &Item
) -> DResult<bool>;
}
pub struct JournalData {
name: &'static str,
details: &'static str,
xp: u64,
}
pub fn journal_types() -> &'static BTreeMap<JournalType, JournalData> {
static JOURNAL_TYPES: OnceCell<BTreeMap<JournalType, JournalData>> = 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<Vec<&'static (dyn JournalChecker + Sync + Send)>> = OnceCell::new();
CHECKERS.get_or_init(|| vec!(
&first_dog::CHECKER
))
}
pub fn checkers_by_species() ->
&'static BTreeMap<SpeciesType,
Vec<&'static (dyn JournalChecker + Sync + Send)>>
{
static MAP: OnceCell<BTreeMap<SpeciesType,
Vec<&'static (dyn JournalChecker + Sync + Send)>>> =
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<BTreeMap<&'static str,
Vec<&'static (dyn JournalChecker + Sync + Send)>>> =
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<bool> {
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<bool> {
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)
}

View File

@ -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<KillSubscriptionType> {
vec!(
KillSubscriptionType::SpecificNPCSpecies {
species: SpeciesType::Dog
}
)
}
async fn handle_kill(
&self,
trans: &DBTrans,
user: &mut User,
player: &mut Item,
_victim: &Item
) -> DResult<bool> {
award_journal_if_needed(trans, user, player, JournalType::SlayedMeanDog).await
}
}
pub static CHECKER: FirstDogChecker = FirstDogChecker;