Support following other players.

This commit is contained in:
Condorra 2023-06-30 23:46:38 +10:00
parent 261151881d
commit 4b524fda96
14 changed files with 479 additions and 39 deletions

36
Cargo.lock generated
View File

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

View File

@ -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 <schema/schema.sql` to create the temporary `blast_schemaonly` database.
* Run `migra "postgres:///blast" "postgres:///blast_schemaonly" > /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 </tmp/update.sql`

View File

@ -41,3 +41,6 @@ humantime = "2.1.0"
rust_decimal = "1.28.0"
mockall = "0.11.3"
mockall_double = "0.3.0"
[dev-dependencies]
tokio-test = "0.4.2"

View File

@ -1569,6 +1569,34 @@ impl DBTrans {
).await?.get(0))
}
pub async fn count_followers_by_leader<'a>(self: &'a Self, leader: &str) -> DResult<i64> {
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<Vec<Arc<Item>>> {
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 {

View File

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

View File

@ -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?;

View File

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

View File

@ -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<bool> {
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<time::Duration> {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
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;

View File

@ -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<String>,
},
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)]

View File

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

View File

@ -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?;

View File

@ -65,7 +65,7 @@ pub fn room_list() -> Vec<Room> {
code: "chonkers_climbing_top",
name: "Top of the Climbing Wall",
short: ansi!("<bgblack><white>CW<reset>"),
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!(

19
docs/dbfixes.md Normal file
View File

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

View File

@ -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')));