diff --git a/Cargo.lock b/Cargo.lock index c59d362..f4a7b76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,28 @@ dependencies = [ "syn", ] +[[package]] +name = "async-stream" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad445822218ce64be7a341abfb0b1ea43b5c23aa83902542a4542e78309d8e5e" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4655ae1a7b0cdf149156f780c5bf3f1352bc53cbd9e0a361a7ef7b22947e965" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.60" @@ -170,6 +192,7 @@ dependencies = [ "tokio-postgres", "tokio-serde", "tokio-stream", + "tokio-test", "tokio-util", "uuid", "validator", @@ -2045,6 +2068,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-test" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53474327ae5e166530d17f2d956afcb4f8a004de581b3cae10f12006bc8163e3" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-tungstenite" version = "0.17.2" diff --git a/README.md b/README.md index fb6d33f..4c73167 100644 --- a/README.md +++ b/README.md @@ -64,5 +64,5 @@ Create a user with a secret password, and username `blast`. Create a production To get to the latest schema: * Run `psql -d template1 /tmp/update.sql` +* Run `migra "postgresql:///blast" "postgresql:///blast_schemaonly" > /tmp/update.sql` * Check `/tmp/update.sql` and if it looks good, apply it with `psql -U blast -d blast (self: &'a Self, leader: &str) -> DResult { + Ok(self + .pg_trans()? + .query_one( + "SELECT COUNT(*) FROM items WHERE details->'following'->>'follow_whom' = $1", + &[&leader], + ) + .await? + .get(0)) + } + + pub async fn find_followers_by_leader<'a>( + self: &'a Self, + leader: &str, + ) -> DResult>> { + Ok(self + .pg_trans()? + .query( + "SELECT details FROM items WHERE details->'following'->>'follow_whom' = $1", + &[&leader], + ) + .await? + .into_iter() + .filter_map(|i| serde_json::from_value(i.get("details")).ok()) + .map(Arc::new) + .collect()) + } + pub async fn commit(mut self: Self) -> DResult<()> { let trans_opt = self.with_trans_mut(|t| std::mem::replace(t, None)); if let Some(trans) = trans_opt { diff --git a/blastmud_game/src/message_handler/user_commands.rs b/blastmud_game/src/message_handler/user_commands.rs index a55c7e5..44664a9 100644 --- a/blastmud_game/src/message_handler/user_commands.rs +++ b/blastmud_game/src/message_handler/user_commands.rs @@ -26,7 +26,7 @@ pub mod cut; pub mod delete; mod describe; pub mod drop; -mod follow; +pub mod follow; mod gear; pub mod get; mod help; diff --git a/blastmud_game/src/message_handler/user_commands/delete.rs b/blastmud_game/src/message_handler/user_commands/delete.rs index fb57460..ee00cc6 100644 --- a/blastmud_game/src/message_handler/user_commands/delete.rs +++ b/blastmud_game/src/message_handler/user_commands/delete.rs @@ -1,8 +1,9 @@ use std::collections::BTreeMap; use super::{ - get_player_item_or_fail, get_user_or_fail, look, rent::recursively_destroy_or_move_item, - user_error, UResult, UserError, UserVerb, UserVerbRef, VerbContext, + follow::cancel_follow_by_leader, get_player_item_or_fail, get_user_or_fail, look, + rent::recursively_destroy_or_move_item, user_error, UResult, UserError, UserVerb, UserVerbRef, + VerbContext, }; use crate::{ models::task::{Task, TaskDetails, TaskMeta}, @@ -123,6 +124,7 @@ impl TaskHandler for DestroyUserHandler { None => return Ok(None), Some(p) => p, }; + cancel_follow_by_leader(&ctx.trans, &player_item.refstr()).await?; destroy_container(&ctx.trans, &player_item).await?; for dynzone in ctx.trans.find_dynzone_for_user(&username).await? { recursively_destroy_or_move_item(ctx, &dynzone).await?; diff --git a/blastmud_game/src/message_handler/user_commands/follow.rs b/blastmud_game/src/message_handler/user_commands/follow.rs index bd685c5..890a4d7 100644 --- a/blastmud_game/src/message_handler/user_commands/follow.rs +++ b/blastmud_game/src/message_handler/user_commands/follow.rs @@ -1,9 +1,164 @@ use super::{ - get_player_item_or_fail, search_item_for_user, user_error, UResult, UserVerb, UserVerbRef, - VerbContext, + get_player_item_or_fail, search_item_for_user, user_error, CommandHandlingError, DResult, + UResult, UserError, UserVerb, UserVerbRef, VerbContext, +}; +#[double] +use crate::db::DBTrans; +use crate::{ + db::ItemSearchParams, + models::item::{FollowData, FollowState, Item}, + regular_tasks::queued_command::{queue_command_for_npc, MovementSource, QueueCommand}, + static_content::room::Direction, }; -use crate::db::ItemSearchParams; use async_trait::async_trait; +use mockall_double::double; +use std::mem; + +pub async fn propagate_move_to_followers( + trans: &DBTrans, + mover: &Item, + direction: &Direction, + source: &MovementSource, +) -> DResult<()> { + let (event_id, chain) = match source { + MovementSource::Command { event_id } => (event_id, vec![]), + MovementSource::Follow { chain, origin_uuid } => (origin_uuid, chain.clone()), + MovementSource::Internal { .. } => return Ok(()), // Not to be propagated. + }; + + let mut new_chain = chain.clone(); + new_chain.push(mover.refstr()); + let new_movement = QueueCommand::Movement { + direction: direction.clone(), + source: MovementSource::Follow { + origin_uuid: event_id.clone(), + chain: new_chain, + }, + }; + for follower in trans.find_followers_by_leader(&mover.refstr()).await? { + let mut follower_mut = (*follower).clone(); + if !chain.contains(&follower.refstr()) { + if let Some(&FollowData { + state: FollowState::IfSameRoom, + ref follow_whom, + }) = follower.following.as_ref() + { + if follower.location != mover.location { + continue; + } + follower_mut.following = Some(FollowData { + state: FollowState::Active, + follow_whom: follow_whom.clone(), + }); + } + // We use the NPC variant since adding [queued] will be confusing. + match queue_command_for_npc(&trans, &mut follower_mut, &new_movement).await { + Err(e) if e.to_string().starts_with("Can't queue more") => { + suspend_follow_for_independent_move(&mut follower_mut); + } + Err(e) => return Err(e), + _ => {} + } + trans.save_item_model(&follower_mut).await?; + } + } + Ok(()) +} + +pub async fn update_follow_for_failed_movement( + trans: &DBTrans, + player: &mut Item, + source: &MovementSource, +) -> DResult<()> { + // Failing is the same as an independent movement back. + suspend_follow_for_independent_move(player); + + let event_id = source.event_id(); + for follower in trans.find_followers_by_leader(&player.refstr()).await? { + if let Some(QueueCommand::Movement { + source: MovementSource::Follow { origin_uuid, chain }, + .. + }) = follower.queue.front().as_ref() + { + if origin_uuid == event_id && chain.last() == Some(&player.refstr()) { + // The follower has already started moving (and their followers may indeed have + // followed them in turn). So we treat the move the follower made + // but the leader failed as an independent move. + let mut follower_mut = (*follower).clone(); + suspend_follow_for_independent_move(&mut follower_mut); + trans.save_item_model(&follower_mut).await?; + continue; + } + } + + // Otherwise, it's not too late, so just cancel it from the queue. + let mut follower_mut = (*follower).clone(); + follower_mut.queue.retain(|qit| match qit { + QueueCommand::Movement { + source: MovementSource::Follow { origin_uuid, chain }, + .. + } => !(origin_uuid == event_id && chain.last() == Some(&player.refstr())), + _ => true, + }); + if follower_mut.queue != follower.queue { + trans.save_item_model(&follower_mut).await?; + } + } + Ok(()) +} + +pub fn suspend_follow_for_independent_move(player: &mut Item) { + if let Some(mut following) = player.following.as_mut() { + following.state = FollowState::IfSameRoom; + } +} + +// Caller has to save follower. +pub async fn stop_following(trans: &DBTrans, follower: &mut Item) -> UResult<()> { + let old_following = mem::replace(&mut follower.following, None) + .ok_or_else(|| UserError("You're not following anyone.".to_owned()))?; + if let Some((follow_type, follow_code)) = old_following.follow_whom.split_once("/") { + if let Some(old_item) = trans + .find_item_by_type_code(&follow_type, &follow_code) + .await? + { + if follower.item_type == "player" { + if let Some((session, session_dat)) = + trans.find_session_for_player(&follower.item_code).await? + { + trans + .queue_for_session( + &session, + Some(&format!( + "You are no longer following {}.\n", + old_item.display_for_session(&session_dat) + )), + ) + .await?; + } + } + } + } + Ok(()) +} + +pub async fn cancel_follow_by_leader(trans: &DBTrans, leader: &str) -> DResult<()> { + for follower in trans.find_followers_by_leader(leader).await? { + if let Some(player_item) = trans + .find_item_by_type_code(&follower.item_type, &follower.item_code) + .await? + { + let mut player_item_mut = (*player_item).clone(); + match stop_following(trans, &mut player_item_mut).await { + Ok(_) => {} + Err(CommandHandlingError::UserError(_)) => {} + Err(CommandHandlingError::SystemError(e)) => Err(e)?, + } + trans.save_item_model(&player_item_mut).await?; + } + } + Ok(()) +} pub struct Verb; #[async_trait] @@ -14,9 +169,9 @@ impl UserVerb for Verb { verb: &str, remaining: &str, ) -> UResult<()> { - let player_item = (*(get_player_item_or_fail(ctx).await?)).clone(); + let mut player_item = (*(get_player_item_or_fail(ctx).await?)).clone(); if verb == "follow" { - let follow_who = search_item_for_user( + let follow_whom = search_item_for_user( ctx, &ItemSearchParams { include_loc_contents: true, @@ -24,23 +179,146 @@ impl UserVerb for Verb { }, ) .await?; - if follow_who.item_type != "player" && follow_who.item_type != "npc" { + if follow_whom.item_type != "player" && follow_whom.item_type != "npc" { user_error("Only characters (player / NPC) can be followed.".to_owned())?; } + if follow_whom.item_type == "player" && follow_whom.item_code == player_item.item_code { + user_error("No point chasing your own tail!".to_owned())?; + } if let Some(follow) = player_item.following.as_ref() { - if follow.follow_whom == player_item.refstr() { + if follow.follow_whom == follow_whom.refstr() { user_error(format!( "You're already following {}!", - follow_who.pronouns.possessive + follow_whom.pronouns.possessive ))?; } - // sess_dets.queue.filter() } + if ctx + .trans + .count_followers_by_leader(&follow_whom.refstr()) + .await? + >= 20 + { + user_error(format!( + "There's such a huge crowd following {}, there's not really room for more.", + &follow_whom.pronouns.object + ))?; + } + if player_item.active_climb.is_some() { + user_error( + "You can't focus on following someone while you are climbing!".to_owned(), + )?; + } + player_item.queue.retain(|qit| match qit { + QueueCommand::Movement { .. } => false, + _ => true, + }); + if let Some(QueueCommand::Movement { direction, source }) = + follow_whom.queue.front().as_ref() + { + let event_id = source.event_id(); + + // Safer to make a singleton chain, since follow is manual + // intervention. Even if the current movement came from player, + // still want to duplicate it, and don't want to duplicate + // player in chain. + let new_chain = vec![follow_whom.refstr()]; + let new_movement = QueueCommand::Movement { + direction: direction.clone(), + source: MovementSource::Follow { + origin_uuid: event_id.clone(), + chain: new_chain, + }, + }; + // We use the NPC variant since adding [queued] will be confusing. + queue_command_for_npc(&ctx.trans, &mut player_item, &new_movement).await?; + } + + player_item.following = Some(FollowData { + follow_whom: follow_whom.refstr(), + state: FollowState::Active, + }); + ctx.trans + .queue_for_session( + &ctx.session, + Some(&format!( + "You are now following {}.\n", + &follow_whom.display_for_session(&ctx.session_dat) + )), + ) + .await?; + ctx.trans.save_item_model(&player_item).await?; } else { + stop_following(&ctx.trans, &mut player_item).await?; + ctx.trans.save_item_model(&player_item).await?; } - user_error("Sorry, we're still building the follow command!".to_owned())?; Ok(()) } } static VERB_INT: Verb = Verb; pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef; + +#[cfg(test)] +mod test { + use uuid::Uuid; + + use super::*; + use crate::db::MockDBTrans; + use std::sync::Arc; + + #[test] + fn propagating_to_overflowing_queue_works() { + let mut trans = MockDBTrans::default(); + let leader: Item = Item { + item_type: "test".to_owned(), + item_code: "test1".to_owned(), + ..Default::default() + }; + let source = MovementSource::Command { + event_id: Uuid::parse_str("ed25224e-9925-4635-8e4f-e612376ef6e9").unwrap(), + }; + let follower: Item = Item { + item_type: "test".to_owned(), + item_code: "test2".to_owned(), + queue: std::iter::repeat(QueueCommand::Movement { + direction: Direction::NORTH, + source: source.clone(), + }) + .take(20) + .collect(), + following: Some(FollowData { + follow_whom: "test/test1".to_owned(), + state: FollowState::Active, + }), + ..Default::default() + }; + + trans + .expect_find_followers_by_leader() + .times(1) + .withf(|m| m == "test/test1") + .returning(move |_| Ok(vec![Arc::new(follower.clone())])); + + trans + .expect_save_item_model() + .times(1) + .withf(|i| { + &i.item_code == "test2" + && i.queue.len() == 20 + && i.following + == Some(FollowData { + follow_whom: "test/test1".to_owned(), + state: FollowState::IfSameRoom, + }) + }) + .returning(|_| Ok(())); + + assert!(tokio_test::block_on(propagate_move_to_followers( + &trans, + &leader, + &Direction::NORTH, + &source + )) + .is_ok()); + } +} diff --git a/blastmud_game/src/message_handler/user_commands/movement.rs b/blastmud_game/src/message_handler/user_commands/movement.rs index 9484b91..a1f24f8 100644 --- a/blastmud_game/src/message_handler/user_commands/movement.rs +++ b/blastmud_game/src/message_handler/user_commands/movement.rs @@ -1,4 +1,8 @@ use super::{ + follow::{ + propagate_move_to_followers, suspend_follow_for_independent_move, + update_follow_for_failed_movement, + }, get_player_item_or_fail, look, open::{attempt_open_immediate, is_door_in_direction, DoorSituation}, user_error, UResult, UserError, UserVerb, UserVerbRef, VerbContext, @@ -12,8 +16,7 @@ use crate::{ item::{ActiveClimb, DoorState, Item, ItemSpecialData, LocationActionType, SkillType}, }, regular_tasks::queued_command::{ - queue_command_and_save, MovementSource, QueueCommand, QueueCommandHandler, - QueuedCommandContext, + queue_command, MovementSource, QueueCommand, QueueCommandHandler, QueuedCommandContext, }, services::{ check_consent, @@ -33,6 +36,7 @@ use mockall_double::double; use rand_distr::{Distribution, Normal}; use std::sync::Arc; use std::time; +use uuid::Uuid; pub async fn announce_move( trans: &DBTrans, @@ -298,11 +302,13 @@ pub async fn handle_fall(trans: &DBTrans, faller: &mut Item, fall_dist: u64) -> Ok(descriptor.to_owned()) } +// Returns true if the move is either complete or still in progress. +// Returns false if the move failed. async fn attempt_move_immediate( direction: &Direction, mut ctx: &mut QueuedCommandContext<'_>, source: &MovementSource, -) -> UResult<()> { +) -> UResult { let use_location = if ctx.item.death_data.is_some() { if ctx.item.item_type != "player" { user_error("Dead players don't move".to_owned())?; @@ -332,9 +338,11 @@ async fn attempt_move_immediate( // Players take an extra step. So tell them to come back. ctx.item.queue.push_front(QueueCommand::Movement { direction: direction.clone(), - source: source.clone(), + source: MovementSource::Internal { + event_id: source.event_id().clone(), + }, }); - return Ok(()); + return Ok(true); } } @@ -356,19 +364,21 @@ async fn attempt_move_immediate( let mut narrative = String::new(); if skills <= -0.25 { // Crit fail - they have fallen. - let (fall_dist, from_room, to_room) = if climb.height < 0 { + let (fall_dist, from_room, to_room, got_there) = if climb.height < 0 { // At least they get to where they want to go! ctx.item.location = new_loc.clone(); ( climb.height.abs() as u64 - active_climb.height, new_loc.to_owned(), use_location.clone(), + true, ) } else { ( active_climb.height, use_location.clone(), new_loc.to_owned(), + false, ) }; ctx.item.active_climb = None; @@ -390,7 +400,7 @@ async fn attempt_move_immediate( broadcast_to_room(ctx.trans, &from_room, None, &msg_exp, Some(&msg_nonexp)).await?; broadcast_to_room(ctx.trans, &to_room, None, &msg_exp, Some(&msg_nonexp)).await?; ctx.item.queue.truncate(0); - return Ok(()); + return Ok(got_there); } else if skills <= 0.0 { if climb.height >= 0 { narrative.push_str("You lose your grip and slide a metre back down"); @@ -454,9 +464,11 @@ async fn attempt_move_immediate( } ctx.item.queue.push_front(QueueCommand::Movement { direction: direction.clone(), - source: source.clone(), + source: MovementSource::Internal { + event_id: source.event_id().clone(), + }, }); - return Ok(()); + return Ok(true); } } } else { @@ -478,7 +490,9 @@ async fn attempt_move_immediate( ctx.item.queue.push_front(QueueCommand::Movement { direction: direction.clone(), - source: source.clone(), + source: MovementSource::Internal { + event_id: source.event_id().clone(), + }, }); escape_check_only = true; } @@ -567,13 +581,14 @@ async fn attempt_move_immediate( ) .await?; } - return Ok(()); + ctx.item.queue.truncate(0); + return Ok(false); } } } } if escape_check_only { - return Ok(()); + return Ok(true); } if ctx.item.death_data.is_some() { @@ -625,13 +640,28 @@ async fn attempt_move_immediate( } } - Ok(()) + Ok(true) } pub struct QueueHandler; #[async_trait] impl QueueCommandHandler for QueueHandler { - async fn start_command(&self, _ctx: &mut QueuedCommandContext<'_>) -> UResult { + async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult { + let (direction, source) = match ctx.command { + QueueCommand::Movement { direction, source } => (direction, source), + _ => user_error("Unexpected command".to_owned())?, + }; + let use_location = if ctx.item.death_data.is_some() { + if ctx.item.item_type != "player" { + user_error("Dead players don't move".to_owned())?; + } + "room/repro_xv_respawn".to_owned() + } else { + ctx.item.location.clone() + }; + // Solely to eliminate completely invalid moves before propagating. + move_to_where(&use_location, direction, ctx).await?; + propagate_move_to_followers(&ctx.trans, &mut ctx.item, &direction, &source).await?; Ok(time::Duration::from_secs(1)) } @@ -641,7 +671,17 @@ impl QueueCommandHandler for QueueHandler { QueueCommand::Movement { direction, source } => (direction, source), _ => user_error("Unexpected command".to_owned())?, }; - attempt_move_immediate(direction, ctx, source).await?; + match attempt_move_immediate(direction, ctx, source).await { + Ok(true) => {} + Ok(false) => { + update_follow_for_failed_movement(&ctx.trans, &mut ctx.item, source).await?; + } + Err(UserError(err)) => { + update_follow_for_failed_movement(&ctx.trans, &mut ctx.item, source).await?; + Err(UserError(err))? + } + Err(e) => Err(e)?, + } Ok(()) } } @@ -658,16 +698,21 @@ impl UserVerb for Verb { ) -> UResult<()> { let dir = Direction::parse(&(verb.to_owned() + " " + remaining.trim()).trim()) .ok_or_else(|| UserError("Unknown direction".to_owned()))?; - let player_item = get_player_item_or_fail(ctx).await?; - queue_command_and_save( + let mut player_item = (*get_player_item_or_fail(ctx).await?).clone(); + suspend_follow_for_independent_move(&mut player_item); + queue_command( ctx, - &player_item, + &mut player_item, &QueueCommand::Movement { direction: dir.clone(), - source: MovementSource::Command, + source: MovementSource::Command { + event_id: Uuid::new_v4(), + }, }, ) - .await + .await?; + ctx.trans.save_item_model(&player_item).await?; + Ok(()) } } static VERB_INT: Verb = Verb; diff --git a/blastmud_game/src/regular_tasks/queued_command.rs b/blastmud_game/src/regular_tasks/queued_command.rs index 138128b..9a3456b 100644 --- a/blastmud_game/src/regular_tasks/queued_command.rs +++ b/blastmud_game/src/regular_tasks/queued_command.rs @@ -20,11 +20,32 @@ use once_cell::sync::OnceCell; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::time; +use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Ord, PartialOrd)] pub enum MovementSource { - Command, - Follow, + Command { + event_id: Uuid, + }, + Follow { + origin_uuid: Uuid, + chain: Vec, + }, + Internal { + event_id: Uuid, + }, +} + +impl MovementSource { + pub fn event_id(&self) -> &Uuid { + match self { + MovementSource::Command { ref event_id, .. } => event_id, + MovementSource::Follow { + ref origin_uuid, .. + } => origin_uuid, + MovementSource::Internal { ref event_id } => event_id, + } + } } #[derive(Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)] diff --git a/blastmud_game/src/services/combat.rs b/blastmud_game/src/services/combat.rs index 9fa347b..0ab6efd 100644 --- a/blastmud_game/src/services/combat.rs +++ b/blastmud_game/src/services/combat.rs @@ -1,7 +1,9 @@ #[double] use crate::db::DBTrans; use crate::{ - message_handler::user_commands::{user_error, CommandHandlingError, UResult}, + message_handler::user_commands::{ + follow::cancel_follow_by_leader, user_error, CommandHandlingError, UResult, + }, models::{ item::{DeathData, Item, LocationActionType, SkillType, Subattack}, journal::JournalType, @@ -445,6 +447,8 @@ pub async fn handle_death(trans: &DBTrans, whom: &mut Item) -> DResult<()> { .unwrap_or_else(|| vec![]), ..Default::default() }); + whom.following = None; + cancel_follow_by_leader(trans, &whom.refstr()).await?; let vic_is_npc = whom.item_type == "npc"; if let Some(ac) = &whom.active_combat { let at_str = ac.attacking.clone(); diff --git a/blastmud_game/src/static_content/npc.rs b/blastmud_game/src/static_content/npc.rs index 08e4c7a..59e0501 100644 --- a/blastmud_game/src/static_content/npc.rs +++ b/blastmud_game/src/static_content/npc.rs @@ -27,6 +27,7 @@ use once_cell::sync::OnceCell; use rand::{prelude::*, thread_rng, Rng}; use std::collections::BTreeMap; use std::time; +use uuid::Uuid; mod melbs_citizen; mod melbs_dog; @@ -406,7 +407,9 @@ impl TaskHandler for NPCWanderTaskHandler { &item, &QueueCommand::Movement { direction: dir.clone(), - source: MovementSource::Command, + source: MovementSource::Command { + event_id: Uuid::new_v4(), + }, }, ) .await?; diff --git a/blastmud_game/src/static_content/room/chonkers.rs b/blastmud_game/src/static_content/room/chonkers.rs index 379614d..6f3d3e8 100644 --- a/blastmud_game/src/static_content/room/chonkers.rs +++ b/blastmud_game/src/static_content/room/chonkers.rs @@ -65,7 +65,7 @@ pub fn room_list() -> Vec { code: "chonkers_climbing_top", name: "Top of the Climbing Wall", short: ansi!("CW"), - description: ansi!("Congratulations, you made it to the top! It is quite snug up here in a little alove at the top of the wall, but the view from the top of the wall is amazing; you have a 180 degree view of buff men and women pumping iron and keeping themselves fit"), + description: ansi!("Congratulations, you made it to the top! It is quite snug up here in a little alcove at the top of the wall, but the view from the top of the wall is amazing; you have a 180 degree view of buff men and women pumping iron and keeping themselves fit"), description_less_explicit: None, grid_coords: GridCoords { x: 0, y: -1, z: 1 }, exits: vec!( diff --git a/docs/dbfixes.md b/docs/dbfixes.md new file mode 100644 index 0000000..0f73c5f --- /dev/null +++ b/docs/dbfixes.md @@ -0,0 +1,19 @@ +# Diagnostics to run on the database +## Dead NPCs with no reclone task (permadead without intervention) + +The following should be 0. I've seen it non-zero in test, possibly from old data though. + +select count(*) from items i where details->>'item_type' = 'npc' and details->>'death_data' is not null and not exists (select 1 from tasks t where t.details->>'task_type' = 'RecloneNPC' and t.details->>'task_code' = i.details->>'item_code'); + +Fix with something like: + +INSERT INTO tasks (details) + SELECT JSONB_BUILD_OBJECT('is_static', false, 'task_type', 'RecloneNPC', 'task_code', details->>'item_code', 'task_details', JSONB_BUILD_OBJECT('npc_code', details->>'item_code'), 'next_scheduled', now(), 'consecutive_failure_count', 0) AS details FROM items i WHERE i.details->>'item_type' = 'npc' AND i.details->>'death_data' IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.details->>'task_type' = 'RecloneNPC' AND t.details->>'task_code' = i.details->>'item_code'); + +## Corpses with no rot handler + +select count(*) from items i where details->>'item_type' = 'corpse' and not exists (select 1 from tasks t where t.details->>'task_type' = 'RotCorpse' and t.details->>'task_code' = i.details->>'item_code'); + +This is expected to be 0 - haven't seen any instances of it deviating, so any bugs seen involving corpses probably aren't due to stale data. + diff --git a/schema/schema.sql b/schema/schema.sql index c476e72..6342d79 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -29,6 +29,7 @@ CREATE INDEX item_by_static ON items ((cast(details->>'is_static' as boolean))); CREATE INDEX item_by_display ON items (lower(details->>'display')); CREATE INDEX item_by_owner ON items (lower(details->>'owner')); CREATE INDEX item_by_display_less_explicit ON items (lower(details->>'display_less_explicit')); +CREATE INDEX item_by_following ON items ((details->'following'->>'follow_whom')); CREATE UNIQUE INDEX item_dynamic_entrance ON items ( (details->'dynamic_entrance'->>'source_item'), (LOWER(details->'dynamic_entrance'->>'direction')));