Implement NPC AI for Ronald

Also add more armour to protect against guns.
This commit is contained in:
Condorra 2024-06-12 22:40:09 +10:00
parent 78abdb761b
commit 6bb3e6a335
20 changed files with 647 additions and 61 deletions

View File

@ -742,7 +742,7 @@ impl DBTrans {
.pg_trans()?
.query(
"SELECT details FROM items WHERE details->>'location' = $1 \
ORDER BY details->>'display'
ORDER BY details->>'display' ASC, item_id ASC
LIMIT 100",
&[&location],
)
@ -2047,9 +2047,12 @@ impl DBTrans {
Ok(self
.pg_trans()?
.query(
"SELECT details FROM users WHERE idle_park_time < NOW() \
AND details->>'location' NOT LIKE 'room/holding%' \
AND details->>'location' NOT IN ('room/repro_xv_chargen', \
"SELECT u.details FROM users u JOIN items i ON \
i.details->>'item_type' = 'player' AND \
i.details->>'item_code' = u.username \
WHERE u.idle_park_time < NOW() \
AND i.details->>'location' NOT LIKE 'room/holding%' \
AND i.details->>'location' NOT IN ('room/repro_xv_chargen', \
'room/repro_xv_respawn') \
LIMIT 100",
&[],

View File

@ -21,7 +21,7 @@ mod agree;
mod allow;
mod attack;
mod butcher;
mod buy;
pub mod buy;
mod c;
pub mod close;
pub mod corp;

View File

@ -2,6 +2,8 @@ use super::{
get_player_item_or_fail, parsing::parse_offset, user_error, UResult, UserVerb, UserVerbRef,
VerbContext,
};
#[double]
use crate::db::DBTrans;
use crate::{
models::item::Item,
services::{
@ -10,9 +12,11 @@ use crate::{
},
static_content::possession_type::possession_data,
static_content::room,
DResult,
};
use ansi::ansi;
use async_trait::async_trait;
use mockall_double::double;
pub struct Verb;
#[async_trait]
@ -140,18 +144,7 @@ impl UserVerb for Verb {
};
ctx.trans.create_item(&new_item).await?;
if let Some(container_data) = possession_type.container_data.as_ref() {
for sub_possession_type in &container_data.default_contents {
let sub_item_code = ctx.trans.alloc_item_code().await?;
let new_sub_item = Item {
item_code: format!("{}", sub_item_code),
location: new_item.refstr(),
..sub_possession_type.clone().into()
};
ctx.trans.create_item(&new_sub_item).await?;
}
}
create_item_default_contents(&ctx.trans, &new_item).await?;
ctx.trans
.queue_for_session(
@ -173,5 +166,26 @@ impl UserVerb for Verb {
user_error(ansi!("That doesn't seem to be for sale. Try <bold>list<reset>").to_owned())
}
}
pub async fn create_item_default_contents(trans: &DBTrans, item: &Item) -> DResult<()> {
if let Some(container_data) = item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt))
.and_then(|pt| pt.container_data.as_ref())
{
for sub_possession_type in &container_data.default_contents {
let sub_item_code = trans.alloc_item_code().await?;
let new_sub_item = Item {
item_code: format!("{}", sub_item_code),
location: item.refstr(),
..sub_possession_type.clone().into()
};
trans.create_item(&new_sub_item).await?;
}
}
Ok(())
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -34,7 +34,7 @@ impl QueueCommandHandler for QueueHandler {
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if item.location != format!("player/{}", ctx.item.item_code) {
if item.location != ctx.item.refstr() {
user_error("You try to wield it but realise you no longer have it".to_owned())?
}
let msg = format!(
@ -89,7 +89,7 @@ impl QueueCommandHandler for QueueHandler {
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if item.location != format!("player/{}", ctx.item.item_code) {
if item.location != ctx.item.refstr() {
user_error("You try to wield it but realise you no longer have it".to_owned())?
}
let msg = format!(

View File

@ -343,6 +343,7 @@ pub enum ItemFlag {
Invincible,
NoIdlePark,
DontCounterattack,
EnableCombatAi,
}
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]

View File

@ -24,6 +24,7 @@ pub mod display;
pub mod effect;
pub mod environment;
pub mod idlepark;
pub mod npc_ai;
pub mod room_effects;
pub mod sharing;
pub mod skills;

View File

@ -39,10 +39,11 @@ use chrono::{TimeDelta, Utc};
use mockall_double::double;
use rand::{prelude::IteratorRandom, thread_rng, Rng};
use rand_distr::{Distribution, Normal};
use std::{sync::Arc, time};
use std::{collections::BTreeSet, sync::Arc, time};
use super::{
effect::{cancel_effect, default_effects_for_type, run_effects},
npc_ai::npc_combat_ai_attacking,
sharing::stop_conversation_mut,
};
@ -63,6 +64,8 @@ pub async fn soak_damage<DamageDist: DamageDistribution>(
.collect();
clothes.sort_unstable_by(|c1, c2| c2.action_type_started.cmp(&c1.action_type_started));
let mut damaged_clothes: BTreeSet<String> = BTreeSet::new();
let mut total_damage = 0.0;
for (damage_type, mut damage_amount) in &damage_by_type {
@ -102,6 +105,7 @@ pub async fn soak_damage<DamageDist: DamageDistribution>(
)),
)
.await?;
damaged_clothes.insert(clothing.item_code.clone());
}
}
}
@ -111,6 +115,9 @@ pub async fn soak_damage<DamageDist: DamageDistribution>(
}
for clothing in &clothes {
if !damaged_clothes.contains(&clothing.item_code) {
continue;
}
if clothing.health <= 0 {
trans
.delete_item(&clothing.item_type, &clothing.item_code)
@ -130,6 +137,8 @@ pub async fn soak_damage<DamageDist: DamageDistribution>(
.await?;
}
}
} else {
trans.save_item_model(&clothing).await?;
}
}
@ -513,6 +522,10 @@ impl TaskHandler for AttackTaskHandler {
let mut attacker_item_mut = (*attacker_item).clone();
if ctype == "npc" {
npc_combat_ai_attacking(ctx, &mut attacker_item_mut, &victim_item).await?;
}
if attacker_item_mut
.active_effects
.iter()

View File

@ -0,0 +1,313 @@
use std::collections::BTreeMap;
use crate::{
models::item::{Item, ItemFlag, LocationActionType},
regular_tasks::{
queued_command::{queue_command_for_npc, QueueCommand},
TaskRunContext,
},
static_content::possession_type::{
possession_data, ContainerFlag, PossessionData, PossessionType,
},
DResult,
};
pub async fn npc_combat_ai_attacking(
ctx: &mut TaskRunContext<'_>,
npc: &mut Item,
_opponent: &Item,
) -> DResult<()> {
if !npc.flags.contains(&ItemFlag::EnableCombatAi) {
return Ok(());
}
// Look through the inventory and find the weapons.
let possessed_items = ctx.trans.find_items_by_location(&npc.refstr()).await?;
// We always truncate the queue to 1 and re-insert actions...
npc.queue.truncate(1);
let all_weapons = rank_weapons_for_ai(&possessed_items);
let mut ammo_loaded_cache: BTreeMap<String, usize> = BTreeMap::new();
queue_ai_wield_weapon(
&all_weapons,
ctx,
&mut ammo_loaded_cache,
&possessed_items,
npc,
)
.await?;
queue_weapon_reloads_for_ai(&possessed_items, all_weapons, ammo_loaded_cache, ctx, npc).await?;
Ok(())
}
async fn queue_weapon_reloads_for_ai(
possessed_items: &Vec<std::sync::Arc<Item>>,
all_weapons: Vec<(String, &PossessionData)>,
mut ammo_loaded_cache: BTreeMap<String, usize>,
ctx: &mut TaskRunContext<'_>,
npc: &mut Item,
) -> DResult<()> {
let direct_ammo: Vec<(String, PossessionType)> = find_direct_ammo_for_ai(&possessed_items);
let clips: Vec<(String, PossessionType)> = find_clips_for_ai(possessed_items);
for (poss_id, poss_data) in &all_weapons {
match poss_data.container_data.as_ref() {
None => break,
Some(cont_data) => {
// If a weapon is loaded sufficiently, don't use more ammo...
let ammolevel = match ammo_loaded_cache.get(poss_id.as_str()) {
Some(v) => *v,
None => {
let loaded_ammo = ctx
.trans
.find_items_by_location(&format!("possession/{}", &poss_id))
.await?;
let ammolevel = loaded_ammo.len();
ammo_loaded_cache.insert(poss_id.clone(), ammolevel);
ammolevel
}
};
if ammolevel > 3 {
break;
}
let mut to_load = 3 - ammolevel;
// Check for an ammo item in inventory directly.
queue_direct_load_ammo_for_ai(
&direct_ammo,
cont_data,
ammolevel,
&mut to_load,
npc,
ctx,
poss_id,
)
.await?;
// We might need to load some ammo from a clip...
queue_load_ammo_from_clip_for_ai(&clips, to_load, cont_data, ctx, npc, poss_id)
.await?;
}
}
}
Ok(())
}
async fn queue_load_ammo_from_clip_for_ai(
clips: &Vec<(String, PossessionType)>,
mut to_load: usize,
cont_data: &crate::static_content::possession_type::ContainerData,
ctx: &mut TaskRunContext<'_>,
npc: &mut Item,
poss_id: &String,
) -> DResult<()> {
for (clip_id, ammo_type) in clips {
if to_load == 0 {
break;
}
if !cont_data.checker.check_place_type(&Some(ammo_type.clone())) {
continue;
}
let clip_contents = ctx
.trans
.find_items_by_location(&format!("possession/{}", &clip_id))
.await?;
for ammo_it in &clip_contents {
if to_load == 0 {
return Ok(());
}
if ammo_it.possession_type.as_ref() != Some(ammo_type) {
continue;
}
to_load -= 1;
queue_command_for_npc(
&ctx.trans,
npc,
&QueueCommand::GetFromContainer {
from_item_id: format!("possession/{}", clip_id),
get_possession_id: ammo_it.item_code.clone(),
},
)
.await?;
queue_command_for_npc(
&ctx.trans,
npc,
&QueueCommand::Put {
container_possession_id: poss_id.clone(),
target_possession_id: ammo_it.item_code.clone(),
},
)
.await?;
}
}
Ok(())
}
async fn queue_direct_load_ammo_for_ai(
direct_ammo: &Vec<(String, PossessionType)>,
cont_data: &crate::static_content::possession_type::ContainerData,
ammolevel: usize,
to_load: &mut usize,
npc: &mut Item,
ctx: &mut TaskRunContext<'_>,
poss_id: &String,
) -> DResult<()> {
let load_ammo: Vec<String> = direct_ammo
.iter()
.filter(|(_, pt)| cont_data.checker.check_place_type(&Some(pt.clone())))
.map(|(it_id, _)| it_id.clone())
.take(ammolevel)
.collect();
*to_load -= load_ammo.len();
for load_id in load_ammo {
match npc.queue.front() {
Some(QueueCommand::Put {
target_possession_id,
..
}) if target_possession_id == &load_id => break,
_ => {}
}
queue_command_for_npc(
&ctx.trans,
npc,
&QueueCommand::Put {
container_possession_id: poss_id.clone(),
target_possession_id: load_id.clone(),
},
)
.await?;
}
Ok(())
}
fn find_clips_for_ai(possessed_items: &Vec<std::sync::Arc<Item>>) -> Vec<(String, PossessionType)> {
possessed_items
.iter()
.filter_map(|it| {
it.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt))
.and_then(|pd| {
pd.container_data.as_ref().and_then(|cd| {
if cd.container_flags.contains(&ContainerFlag::AmmoClip) {
cd.default_contents
.first()
.map(|pt_in| (it.item_code.clone(), pt_in.clone()))
} else {
None
}
})
})
})
.collect()
}
fn find_direct_ammo_for_ai(
possessed_items: &Vec<std::sync::Arc<Item>>,
) -> Vec<(String, PossessionType)> {
possessed_items
.iter()
.filter_map(|it| {
match it
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt).map(|pd| (pt, pd)))
{
Some((pt, pd)) if pd.ammo_data.is_some() => {
Some((it.item_code.clone(), pt.clone()))
}
_ => None,
}
})
.collect()
}
async fn queue_ai_wield_weapon(
all_weapons: &Vec<(String, &PossessionData)>,
ctx: &mut TaskRunContext<'_>,
ammo_loaded_cache: &mut BTreeMap<String, usize>,
possessed_items: &Vec<std::sync::Arc<Item>>,
npc: &mut Item,
) -> DResult<()> {
let mut best_ready_weapon: Option<String> = None;
for (poss_code, poss_data) in all_weapons {
if poss_data.container_data.is_none() {
best_ready_weapon = Some(poss_code.clone());
break;
}
// Check loaded status...
let loaded_ammo = ctx
.trans
.find_items_by_location(&format!("possession/{}", &poss_code))
.await?;
let ammolevel = loaded_ammo.len();
ammo_loaded_cache.insert(poss_code.clone(), ammolevel);
if ammolevel > 0 {
best_ready_weapon = Some(poss_code.clone());
break;
}
}
let current_wield = possessed_items
.iter()
.find(|it| it.action_type == LocationActionType::Wielded)
.map(|it| it.item_code.clone());
let pending_wield = npc.queue.front().and_then(|comm| match comm {
QueueCommand::Wield { possession_id } => Some(possession_id.clone()),
_ => None,
});
let eventual_wield = pending_wield.or(current_wield);
Ok(match (eventual_wield, best_ready_weapon) {
(_, None) => {} // Do nothing if nothing possible.
(Some(wielded), Some(to_wield)) if wielded == to_wield => {}
(_, Some(to_wield)) => {
queue_command_for_npc(
&ctx.trans,
npc,
&QueueCommand::Wield {
possession_id: to_wield.clone(),
},
)
.await?
}
})
}
fn rank_weapons_for_ai(
possessed_items: &Vec<std::sync::Arc<Item>>,
) -> Vec<(String, &PossessionData)> {
let mut all_weapons: Vec<(String, &'static PossessionData)> = possessed_items
.iter()
.filter_map(|it| {
match it
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt))
{
None => None,
Some(pd) if pd.weapon_data.is_none() => None,
Some(pd) => Some((it.item_code.clone(), *pd)),
}
})
.collect();
all_weapons.sort_by(|(_, pd1), (_, pd2)| {
pd1.container_data
.is_some()
.cmp(&pd2.container_data.is_some())
.then(
pd1.weapon_data
.as_ref()
.map_or(0.0, |wd| wd.normal_attack.mean_damage)
.partial_cmp(
&pd2.weapon_data
.as_ref()
.map_or(0.0, |wd| wd.normal_attack.mean_damage),
)
.unwrap_or(std::cmp::Ordering::Equal),
)
.reverse()
});
all_weapons
}

View File

@ -85,6 +85,8 @@ fn static_task_registry() -> Vec<StaticThingTypeGroup<StaticTask>> {
#[cfg(not(test))]
async fn refresh_static_items(pool: &DBPool) -> DResult<()> {
use crate::message_handler::user_commands::buy::create_item_default_contents;
let registry = static_item_registry();
let expected_type: BTreeSet<String> =
@ -118,6 +120,7 @@ async fn refresh_static_items(pool: &DBPool) -> DResult<()> {
for mut item in (expected_item.extra_items_on_create)(&new_item) {
item.item_code = format!("{}", tx.alloc_item_code().await?);
tx.create_item(&item).await?;
create_item_default_contents(&tx, &item).await?;
}
}
for existing_item_code in expected_set.intersection(&existing_items) {

View File

@ -8,7 +8,10 @@ use super::{
use crate::db::DBTrans;
use crate::{
message_handler::{
user_commands::{say::say_to_room, CommandHandlingError, UResult, VerbContext},
user_commands::{
buy::create_item_default_contents, say::say_to_room, CommandHandlingError, UResult,
VerbContext,
},
ListenerSession,
},
models::{
@ -163,7 +166,7 @@ impl Default for NPC {
)
})
.collect(),
total_stats: vec![].into_iter().collect(),
total_stats: vec![(StatType::Brawn, 8.0)].into_iter().collect(),
aggression: 0,
aggro_pc_only: false,
max_health: 24,
@ -271,6 +274,7 @@ pub fn npc_static_items() -> Box<dyn Iterator<Item = StaticItem>> {
pos_it.action_type_started = Some(
Utc::now() - chrono::TimeDelta::try_seconds(spawn_item.wear_layer).unwrap(),
);
pos_it
}))
}),
@ -648,6 +652,7 @@ impl TaskHandler for NPCRecloneTaskHandler {
item.item_code = format!("{}", ctx.trans.alloc_item_code().await?);
item.action_type = spawn_item.action_type.clone();
ctx.trans.create_item(&item).await?;
create_item_default_contents(&ctx.trans, &item).await?;
}
return Ok(None);

View File

@ -1,10 +1,10 @@
use crate::{
models::{
consent::ConsentType,
item::{Pronouns, SkillType},
item::{ItemFlag, LocationActionType, Pronouns, SkillType},
},
static_content::{
npc::{NPCSayInfo, NPCSayType},
npc::{NPCSayInfo, NPCSayType, NPCSpawnPossession},
possession_type::PossessionType,
species::SpeciesType,
},
@ -28,7 +28,7 @@ pub fn npc_list() -> Vec<NPC> {
"I once had a private army, now I must rely on my wits, my gun and my blade. They have never failed me.",
"You dare threaten me, the Praefect of a fallen empire? I'll show you what true power is.",
"Your intrusion is an affront to my dignity. Prepare to face my wrath.",
"I once commanded entire planets, now I must defend myself and my the green code share against pathetic invaders like you.",
"I once commanded an entire praefect, now I must defend myself and my green code share against pathetic invaders like you.",
"The late emperor trusted me with the green code share, and I will not let it fall into the wrong hands.",
"My people used to cower in fear of me. Now they dare to trespass on my property.",
"I once ruled an empire, now I must defend my home and my code against intruders like you.",
@ -51,6 +51,7 @@ pub fn npc_list() -> Vec<NPC> {
NPC {
code: "ronalds_house_taipan".to_owned(),
name: "Brown-headed taipan".to_owned(),
pronouns: Pronouns::default_inanimate(),
description: "A large olive coloured snake, its head a darker brown. Its head tracks you as it tastes the air with its forked tongue. As it flicks its tongue, you see a glimpse of two needle-sharp fangs in the front of its mouth".to_owned(),
spawn_location: "room/ronalds_snakepit".to_owned(),
aliases: vec!["snake".to_owned(), "taipan".to_owned()],
@ -82,7 +83,28 @@ pub fn npc_list() -> Vec<NPC> {
pronouns: Pronouns::default_male(),
description: "A tall man, probably in his fifties, but still looking fit. His hair shows tinges of gray, while his rugged face and piercing blue eyes give the impression this is a man who would make a heartless decision in an instant".to_owned(),
spawn_location: "room/ronalds_study".to_owned(),
spawn_possessions: vec![],
spawn_possessions: vec![
NPCSpawnPossession {
what: PossessionType::Z3000Pistol,
action_type: LocationActionType::Normal,
wear_layer: 0,
},
NPCSpawnPossession {
what: PossessionType::NinemilFiftyBox,
action_type: LocationActionType::Normal,
wear_layer: 0,
},
NPCSpawnPossession {
what: PossessionType::NinemilFiftyBox,
action_type: LocationActionType::Normal,
wear_layer: 0,
},
NPCSpawnPossession {
what: PossessionType::Electroblade,
action_type: LocationActionType::Wielded,
wear_layer: 0,
},
],
says: ronald_says,
aggression: 21,
aggro_pc_only: true,
@ -95,13 +117,15 @@ pub fn npc_list() -> Vec<NPC> {
sk.clone(),
match sk {
SkillType::Dodge => 14.0,
SkillType::Fists => 18.0,
SkillType::Blades => 16.0,
SkillType::Pistols => 18.0,
_ => 8.0
}
)
}).collect(),
species: SpeciesType::Human,
player_consents: vec!(ConsentType::Fight),
extra_flags: vec![ItemFlag::EnableCombatAi],
..Default::default()
}
]

View File

@ -264,28 +264,9 @@ pub trait TurnToggleHandler {
}
pub trait ContainerCheck {
fn check_place(&self, container: &Item, item: &Item) -> UResult<()>;
}
pub struct PermissiveContainerCheck;
static PERMISSIVE_CONTAINER_CHECK: PermissiveContainerCheck = PermissiveContainerCheck;
impl ContainerCheck for PermissiveContainerCheck {
fn check_place(&self, _container: &Item, _item: &Item) -> UResult<()> {
Ok(())
}
}
pub struct AllowlistContainerCheck {
allowed_types: Vec<PossessionType>,
}
impl ContainerCheck for AllowlistContainerCheck {
// The full check that needs item data.
fn check_place(&self, container: &Item, item: &Item) -> UResult<()> {
if item
.possession_type
.as_ref()
.map(|pt| !self.allowed_types.contains(pt))
.unwrap_or(true)
{
if !self.check_place_type(&item.possession_type) {
user_error(format!(
"You realise {} wasn't designed to hold that.",
container.display_for_sentence(1, false),
@ -294,6 +275,29 @@ impl ContainerCheck for AllowlistContainerCheck {
Ok(())
}
}
// A lighter check that only factors in the content type. May pass in some cases when
// check_place will fail. None is for non-possessions.
fn check_place_type(&self, item_type: &Option<PossessionType>) -> bool;
}
pub struct PermissiveContainerCheck;
static PERMISSIVE_CONTAINER_CHECK: PermissiveContainerCheck = PermissiveContainerCheck;
impl ContainerCheck for PermissiveContainerCheck {
fn check_place_type(&self, _item_type: &Option<PossessionType>) -> bool {
true
}
}
pub struct AllowlistContainerCheck {
allowed_types: Vec<PossessionType>,
}
impl ContainerCheck for AllowlistContainerCheck {
fn check_place_type(&self, item_type: &Option<PossessionType>) -> bool {
item_type
.as_ref()
.map(|pt| self.allowed_types.contains(pt))
.unwrap_or(false)
}
}
#[async_trait]
@ -448,10 +452,14 @@ pub enum PossessionType {
// Armour / Clothes
RustyMetalPot,
HockeyMask,
CombatHelmet,
CombatBoots,
Shirt,
LeatherJacket,
ShieldWeaveJacket,
Jeans,
LeatherPants,
ShieldWeavePants,
RadSuit,
// Weapons: Whips
AntennaWhip,
@ -884,7 +892,13 @@ mod tests {
.sum::<f64>()
* container_data.compression_ratio)
.ceil() as u64);
assert!(tot as u64 == pd.weight);
assert!(
tot as u64 == pd.weight,
"Starting weight for {}, {} didn't match calculated weight, {}",
pd.display,
pd.weight,
tot
);
}
}
}

View File

@ -19,15 +19,20 @@ pub fn recipe_set() -> &'static BTreeSet<PossessionType> {
struct RecipesOnlyChecker;
impl ContainerCheck for RecipesOnlyChecker {
fn check_place(&self, _container: &Item, item: &Item) -> UResult<()> {
item.possession_type
.as_ref()
.and_then(|pt| recipe_set().get(pt))
.ok_or_else(|| {
UserError("You don't find a sensible place for that in a recipe book.".to_owned())
})?;
if !self.check_place_type(&item.possession_type) {
Err(UserError(
"You don't find a sensible place for that in a recipe book.".to_owned(),
))
} else {
Ok(())
}
}
fn check_place_type(&self, item_type: &Option<PossessionType>) -> bool {
item_type
.as_ref()
.map_or(false, |pt| recipe_set().contains(pt))
}
}
static RECIPES_ONLY_CHECKER: RecipesOnlyChecker = RecipesOnlyChecker;

View File

@ -64,6 +64,7 @@ pub fn data() -> &'static Vec<(PossessionType, PossessionData)> {
display: "box of airsoft rounds",
details: "A box of airsoft rounds, each a soft plastic spherical projectile",
aliases: vec!["air-soft round box", "air soft round box", "ammo"],
weight: 361,
container_data: Some(ContainerData {
max_weight: 400,
base_weight: 1,
@ -111,6 +112,7 @@ pub fn data() -> &'static Vec<(PossessionType, PossessionData)> {
power_attack: None
}
),
weight: 500,
container_data: Some(ContainerData {
max_weight: 96,
base_weight: 500,
@ -165,6 +167,7 @@ pub fn data() -> &'static Vec<(PossessionType, PossessionData)> {
display: "box of 9mm solid bullets",
details: "A box of nine millimetre bullets, each consisting of casing and a solid-tipped projectile",
aliases: vec!["bullet", "nine-millimetre bullet", "ammo"],
weight: 361,
container_data: Some(ContainerData {
max_weight: 400,
base_weight: 1,
@ -182,6 +185,7 @@ pub fn data() -> &'static Vec<(PossessionType, PossessionData)> {
display: "Z3000 pistol",
details: "A black metal pistol, featuring a cylindrical barrel, with a rectangular body and butt",
aliases: vec!["gun", "pistol"],
weight: 500,
weapon_data: Some(
WeaponData {
uses_skill: SkillType::Pistols,

View File

@ -80,6 +80,51 @@ pub fn data() -> &'static Vec<(PossessionType, PossessionData)> {
}),
..Default::default()
}
)
),
(
PossessionType::CombatHelmet,
PossessionData {
display: "combat helmet",
details: "A military grade combat helmet, featuring a visor. It looks like it would provide full protection for the head or face against many threats a soldier might face on the battlefield",
aliases: vec!("helmet"),
weight: 500,
wear_data: Some(WearData {
covers_parts: vec!(BodyPart::Face, BodyPart::Head),
thickness: 6.0,
dodge_penalty: 0.4,
soaks: vec!(
(DamageType::Beat,
SoakData {
min_soak: 3.0,
max_soak: 4.0,
damage_probability_per_soak: 0.1
}
),
(DamageType::Slash,
SoakData {
min_soak: 2.0,
max_soak: 3.0,
damage_probability_per_soak: 0.1
}
),
(DamageType::Pierce,
SoakData {
min_soak: 2.0,
max_soak: 3.0,
damage_probability_per_soak: 0.1
}
),
(DamageType::Bullet,
SoakData {
min_soak: 60.0,
max_soak: 70.0,
damage_probability_per_soak: 0.1
}
),
).into_iter().collect()
}),
..Default::default()
}
),
))
}

View File

@ -62,5 +62,88 @@ pub fn data() -> &'static Vec<(PossessionType, PossessionData)> {
..Default::default()
}
),
(
PossessionType::ShieldWeavePants,
PossessionData {
display: "pair of ShieldWeave pants",
details: "Black pants that look like they could be formal business pants, except they are made out of some kind of light but extremely strong fibre. A label inside says they are ShieldWeave bullet-resistant pants, offering stylish protection against fire from pistols, as well as offering a degree of stab and slash protection",
aliases: vec!("shield weave pants", "shieldweave pants", "pants"),
weight: 300,
wear_data: Some(WearData {
covers_parts: vec!(BodyPart::Groin, BodyPart::Legs),
thickness: 4.0,
dodge_penalty: 0.25,
soaks: vec!(
(DamageType::Bullet,
SoakData {
min_soak: 60.0,
max_soak: 70.0,
damage_probability_per_soak: 0.1
}
),
(DamageType::Slash,
SoakData {
min_soak: 2.0,
max_soak: 3.0,
damage_probability_per_soak: 0.1
}
),
(DamageType::Pierce,
SoakData {
min_soak: 2.0,
max_soak: 3.0,
damage_probability_per_soak: 0.1
}
),
).into_iter().collect(),
}),
..Default::default()
}
),
(
PossessionType::CombatBoots,
PossessionData {
display: "pair of combat boots",
details: "A pair of calf-high rough tan-leather combat boots. They look like they would protect the feet",
aliases: vec!("boots", "footwear", "combat boots"),
weight: 300,
wear_data: Some(WearData {
covers_parts: vec!(BodyPart::Feet),
thickness: 4.0,
dodge_penalty: 0.25,
soaks: vec!(
(DamageType::Beat,
SoakData {
min_soak: 4.0,
max_soak: 5.0,
damage_probability_per_soak: 0.1
}
),
(DamageType::Slash,
SoakData {
min_soak: 3.0,
max_soak: 4.0,
damage_probability_per_soak: 0.1
}
),
(DamageType::Pierce,
SoakData {
min_soak: 3.0,
max_soak: 4.0,
damage_probability_per_soak: 0.1
}
),
(DamageType::Bullet,
SoakData {
min_soak: 40.0,
max_soak: 50.0,
damage_probability_per_soak: 0.1
}
),
).into_iter().collect(),
}),
..Default::default()
}
),
))
}

View File

@ -99,5 +99,45 @@ pub fn data() -> &'static Vec<(PossessionType, PossessionData)> {
..Default::default()
}
),
(
PossessionType::ShieldWeaveJacket,
PossessionData {
display: "ShieldWeave jacket",
details: "A fancy-looking black jacket that could be formal business wear, except it is made out of some kind of light but extremely strong fibre. A label inside says it is a ShieldWeave bullet-resistant business jacket, offering stylish protection against fire from pistols, as well as offering a degree of stab and slash protection",
aliases: vec!("shield weave jacket", "jacket", "shieldweave jacket"),
weight: 300,
wear_data: Some(WearData {
covers_parts: vec!(BodyPart::Arms,
BodyPart::Chest,
BodyPart::Back),
thickness: 4.0,
dodge_penalty: 0.25,
soaks: vec!(
(DamageType::Bullet,
SoakData {
min_soak: 60.0,
max_soak: 70.0,
damage_probability_per_soak: 0.1
}
),
(DamageType::Slash,
SoakData {
min_soak: 2.0,
max_soak: 3.0,
damage_probability_per_soak: 0.1
}
),
(DamageType::Pierce,
SoakData {
min_soak: 2.0,
max_soak: 3.0,
damage_probability_per_soak: 0.1
}
),
).into_iter().collect(),
}),
..Default::default()
}
),
))
}

View File

@ -1796,6 +1796,15 @@
- possession_type: LeatherPants
list_price: 500
poverty_discount: false
- possession_type: CombatHelmet
list_price: 15000
poverty_discount: false
- possession_type: ShieldWeavePants
list_price: 35000
poverty_discount: false
- possession_type: ShieldWeaveJacket
list_price: 40000
poverty_discount: false
- zone: melbs
code: melbs_bourkest_200
name: Bourke St - 200 block
@ -1854,6 +1863,9 @@
- possession_type: ElectricLantern
list_price: 500
poverty_discount: false
- possession_type: CombatBoots
list_price: 3000
poverty_discount: false
scavtable: CityStreet
- zone: melbs
code: melbs_bourkest_190

View File

@ -52,8 +52,8 @@
z: 0
description: A section of hallway that branches off to several rooms of the house. On a pillar hangs a framed but faded picture of two men, each with an evil smile, one recognisable as the former emperor, and the other clad in the nanoweave smartwear of the fallen empire's most trusted elite
exits:
- direction: south
- direction: east
- direction: north
- direction: west
- zone: ronalds_house
code: ronalds_study
name: Study
@ -65,4 +65,4 @@
z: 0
description: A spacious study, the walls lined with massive electronic ink displays. An extremely fancy leather chair, featuring electronic controls, has prime place in the middle
exits:
- direction: west
- direction: east

View File

@ -19,7 +19,7 @@ This is expected to be 0 - haven't seen any instances of it deviating, so any bu
## NPC combat non-symmetrical attacking / attacked_by
select i1.details->>'item_code' as i1_code, i1.details->>'active_combat' as i1_combat, i1.details->>'location' as i1_loc, i2.details->>'item_code' as i2_code, i2.details->>'active_combat' as i2_combat, i2.details->>'location' as i2_loc from items i1 join items i2 on i2.details->>'item_type' = (regexp_split_to_array(i1.details->'active_combat'->>'attacking', '/'))[1] and i2.details->>'item_code' = (regexp_split_to_array(i1.details->'active_combat'->>'attacking', '/'))[2] where not exists (select 1 from jsonb_array_elements_text(i2.details->'active_combat'->'attacked_by') e where e = ((i1.details->>'item_type') || '/' || (i1.details->>'item_code')));
`select i1.details->>'item_code' as i1_code, i1.details->>'active_combat' as i1_combat, i1.details->>'location' as i1_loc, i2.details->>'item_code' as i2_code, i2.details->>'active_combat' as i2_combat, i2.details->>'location' as i2_loc from items i1 join items i2 on i2.details->>'item_type' = (regexp_split_to_array(i1.details->'active_combat'->>'attacking', '/'))[1] and i2.details->>'item_code' = (regexp_split_to_array(i1.details->'active_combat'->>'attacking', '/'))[2] where not exists (select 1 from jsonb_array_elements_text(i2.details->'active_combat'->'attacked_by') e where e = ((i1.details->>'item_type') || '/' || (i1.details->>'item_code')));`
This should be empty, but there has been a bug breaking this.
@ -27,4 +27,10 @@ This should be empty, but there has been a bug breaking this.
## Rename a skill
Something like this:
with fexdet as (select item_id, jsonb_strip_nulls(jsonb_set(details, '{total_skills,Fuck}', 'null')) as details, details->'total_skills'->'Fuck' as f from items), amenddet as (select jsonb_set(details :: jsonb, '{total_skills,Share}', f :: jsonb) as details, item_id from fexdet where f is not null) update items i set details = a.details from amenddet a where i.item_id = a.item_id;
`with fexdet as (select item_id, jsonb_strip_nulls(jsonb_set(details, '{total_skills,Fuck}', 'null')) as details, details->'total_skills'->'Fuck' as f from items), amenddet as (select jsonb_set(details :: jsonb, '{total_skills,Share}', f :: jsonb) as details, item_id from fexdet where f is not null) update items i set details = a.details from amenddet a where i.item_id = a.item_id;`
# Advanced admin via the database to help debugging / admin
## Immediately reclone a dead NPC
Set task_code to the NPC's ID as appropriate. `next_scheduled` can be any time in the past.
`update tasks set details=jsonb_set(details, '{next_scheduled}', '"2024-01-01T00:00:00Z"') where details->>'task_type' = 'RecloneNPC' and details->>'task_code' = 'ronalds_house_ronald';`