use super::{ journals::award_journal_if_needed, possession_type::PossessionType, scavtable::ScavtableType, StaticItem, }; #[double] use crate::db::DBTrans; use crate::{ message_handler::user_commands::{CommandHandlingError, UResult, VerbContext}, models::{ effect::SimpleEffect, item::{DoorState, Item, ItemFlag}, journal::JournalType, user::WristpadHack, }, regular_tasks::queued_command::QueuedCommandContext, services::room_effects::{RoomEffectEntryTrigger, RoomEffectExitTrigger}, DResult, }; use ansi_markup::parse_ansi_markup; use async_trait::async_trait; use mockall_double::double; use once_cell::sync::OnceCell; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; mod chonkers; mod cok_murl; pub mod computer_museum; pub mod general_hospital; pub mod melbs; pub mod melbs_sewers; pub mod northern_radfields; mod repro_xv; mod special; pub struct Zone { pub code: &'static str, pub display: &'static str, pub outdoors: bool, } static STATIC_ZONE_DETAILS: OnceCell> = OnceCell::new(); pub fn zone_details() -> &'static BTreeMap<&'static str, Zone> { STATIC_ZONE_DETAILS.get_or_init(|| { vec![ Zone { code: "special", display: "Outside of Time", outdoors: false, }, Zone { code: "melbs", display: "Melbs", outdoors: true, }, Zone { code: "repro_xv", display: "Reprolabs XV", outdoors: false, }, Zone { code: "cok_murl", display: "CoK-Murlison Complex", outdoors: false, }, Zone { code: "chonkers", display: "Chonker's Gym", outdoors: false, }, Zone { code: "computer_museum", display: "Computer Museum", outdoors: false, }, Zone { code: "general_hospital", display: "General Hospital", outdoors: false, }, Zone { code: "melbs_sewers", display: "Melbs Sewers", outdoors: false, }, Zone { code: "kings_office", display: "King's Office", outdoors: false, }, Zone { code: "northern_radfields", display: "Northern Radfields", outdoors: false, }, ] .into_iter() .map(|x| (x.code, x)) .collect() }) } #[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Debug, Serialize, Deserialize)] pub struct GridCoords { pub x: i64, pub y: i64, pub z: i64, } impl GridCoords { pub fn apply(self: &GridCoords, dir: &Direction) -> GridCoords { match dir { Direction::NORTH => GridCoords { y: self.y - 1, ..*self }, Direction::SOUTH => GridCoords { y: self.y + 1, ..*self }, Direction::EAST => GridCoords { x: self.x + 1, ..*self }, Direction::WEST => GridCoords { x: self.x - 1, ..*self }, Direction::NORTHEAST => GridCoords { x: self.x + 1, y: self.y - 1, ..*self }, Direction::SOUTHEAST => GridCoords { x: self.x + 1, y: self.y + 1, ..*self }, Direction::NORTHWEST => GridCoords { x: self.x - 1, y: self.y - 1, ..*self }, Direction::SOUTHWEST => GridCoords { x: self.x - 1, y: self.y + 1, ..*self }, Direction::UP => GridCoords { z: self.z + 1, ..*self }, Direction::DOWN => GridCoords { z: self.z - 1, ..*self }, Direction::IN { .. } => self.clone(), } } } #[async_trait] pub trait ExitBlocker { // True if they will be allowed to pass the exit, false otherwise. async fn attempt_exit( self: &Self, ctx: &mut QueuedCommandContext, exit: &Exit, ) -> UResult; } pub enum ExitType { Free, // Anyone can just walk it (subject to any door logic). Blocked(Box), // Custom code about who can pass. } #[allow(dead_code)] #[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Debug)] pub enum Direction { NORTH, SOUTH, EAST, WEST, NORTHEAST, SOUTHEAST, NORTHWEST, SOUTHWEST, UP, DOWN, IN { item: String }, } impl Serialize for Direction { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { Serialize::serialize(&self.describe(), serializer) } } impl<'de> Deserialize<'de> for Direction { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { Deserialize::deserialize(deserializer).and_then(|s: String| { Self::parse(s.as_str()).ok_or(serde::de::Error::custom("Invalid direction")) }) } } impl Direction { pub fn describe(self: &Self) -> String { match self { Direction::NORTH => "north".to_owned(), Direction::SOUTH => "south".to_owned(), Direction::EAST => "east".to_owned(), Direction::WEST => "west".to_owned(), Direction::NORTHEAST => "northeast".to_owned(), Direction::SOUTHEAST => "southeast".to_owned(), Direction::NORTHWEST => "northwest".to_owned(), Direction::SOUTHWEST => "southwest".to_owned(), Direction::UP => "up".to_owned(), Direction::DOWN => "down".to_owned(), Direction::IN { item } => "in ".to_owned() + item, } } pub fn describe_climb(self: &Self, climb_dir: &str) -> String { match self { Direction::NORTH => format!("{} to the north", climb_dir), Direction::SOUTH => format!("{} to the south", climb_dir), Direction::EAST => format!("{} to the east", climb_dir), Direction::WEST => format!("{} to the west", climb_dir), Direction::NORTHEAST => format!("{} to the northeast", climb_dir), Direction::SOUTHEAST => format!("{} to the southeast", climb_dir), Direction::NORTHWEST => format!("{} to the northwest", climb_dir), Direction::SOUTHWEST => format!("{} to the southwest", climb_dir), Direction::UP => "upwards".to_owned(), Direction::DOWN => "downwards".to_owned(), Direction::IN { item } => format!("{} and in ", item), } } pub fn parse(input: &str) -> Option { if input.starts_with("in ") { return Some(Direction::IN { item: input["in ".len()..].trim().to_owned(), }); } match input { "north" | "n" => Some(Direction::NORTH), "south" | "s" => Some(Direction::SOUTH), "east" | "e" => Some(Direction::EAST), "west" | "w" => Some(Direction::WEST), "northeast" | "ne" => Some(Direction::NORTHEAST), "southeast" | "se" => Some(Direction::SOUTHEAST), "northwest" | "nw" => Some(Direction::NORTHWEST), "southwest" | "sw" => Some(Direction::SOUTHWEST), "up" => Some(Direction::UP), "down" => Some(Direction::DOWN), _ => None, } } pub fn reverse(&self) -> Option { match self { Direction::NORTH => Some(Direction::SOUTH), Direction::SOUTH => Some(Direction::NORTH), Direction::EAST => Some(Direction::WEST), Direction::WEST => Some(Direction::EAST), Direction::NORTHEAST => Some(Direction::SOUTHWEST), Direction::SOUTHEAST => Some(Direction::NORTHWEST), Direction::NORTHWEST => Some(Direction::SOUTHEAST), Direction::SOUTHWEST => Some(Direction::NORTHEAST), Direction::UP => Some(Direction::DOWN), Direction::DOWN => Some(Direction::UP), Direction::IN { .. } => None, } } } #[derive(Eq, Ord, Debug, PartialEq, PartialOrd, Clone, Serialize, Deserialize)] pub enum ExitTarget { UseGPS, Custom(String), } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct ExitClimb { // Negative if it is down. pub height: i64, pub difficulty: i64, } pub struct Exit { pub direction: Direction, pub target: ExitTarget, pub exit_type: ExitType, pub exit_climb: Option, } impl Default for Exit { fn default() -> Self { Self { direction: Direction::NORTH, target: ExitTarget::UseGPS, exit_type: ExitType::Free, exit_climb: None, } } } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] #[serde(default)] pub struct ScanBlockerInfo { need_scancode: ScanCode, block_message: String, } impl Default for ScanBlockerInfo { fn default() -> Self { Self { need_scancode: ScanCode::SewerAccess, block_message: "You aren't allowed in there".to_owned(), } } } #[async_trait] impl ExitBlocker for ScanBlockerInfo { async fn attempt_exit(&self, ctx: &mut QueuedCommandContext, _exit: &Exit) -> UResult { if ctx.item.item_type != "player" { return Ok(false); } let user = ctx .trans .find_by_username(&ctx.item.item_code) .await? .ok_or_else(|| CommandHandlingError::UserError("No user exists".to_owned()))?; if !user.scan_codes.contains(&self.need_scancode) { if let Some((sess, _sess_dat)) = ctx .trans .find_session_for_player(&ctx.item.item_code) .await? { ctx.trans .queue_for_session(&sess, Some(&format!("{}\n", &self.block_message))) .await?; } return Ok(false); } Ok(true) } } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct NpcBlockerInfo { block_message: String, } #[async_trait] impl ExitBlocker for NpcBlockerInfo { async fn attempt_exit(&self, ctx: &mut QueuedCommandContext, _exit: &Exit) -> UResult { if ctx.item.item_type != "player" { return Ok(false); } if ctx .trans .count_items_by_location_type(&ctx.item.location, "npc") .await? == 0 { return Ok(true); } if let Some((sess, _sess_dat)) = ctx .trans .find_session_for_player(&ctx.item.item_code) .await? { ctx.trans .queue_for_session(&sess, Some(&format!("{}\n", &self.block_message))) .await?; } Ok(false) } } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] #[serde(default)] pub struct SimpleExit { pub direction: Direction, pub target: ExitTarget, pub exit_climb: Option, pub needs_scan: Option, pub needs_npc_cleared: Option, } impl Default for SimpleExit { fn default() -> Self { Self { direction: Direction::NORTH, target: ExitTarget::UseGPS, exit_climb: None, needs_scan: None, needs_npc_cleared: None, } } } impl Into for SimpleExit { fn into(self: SimpleExit) -> Exit { Exit { direction: self.direction, target: self.target, exit_type: match self.needs_scan { None => match self.needs_npc_cleared { None => ExitType::Free, Some(s) => ExitType::Blocked(Box::new(NpcBlockerInfo { block_message: parse_ansi_markup(&s.block_message).unwrap(), ..s })), }, Some(s) => ExitType::Blocked(Box::new(ScanBlockerInfo { block_message: parse_ansi_markup(&s.block_message).unwrap(), ..s })), }, exit_climb: self.exit_climb, } } } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct SecondaryZoneRecord { pub zone: String, pub short: String, pub grid_coords: GridCoords, pub caption: Option, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] #[serde(default)] pub struct RoomStock { pub possession_type: PossessionType, pub list_price: u64, pub poverty_discount: bool, pub can_buy: bool, pub can_sell: Option, // sell price in hundredths of a percent of the buy price, e.g. 8000 = 80% of buy price back. } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum ScanCode { SewerAccess, RedImperialCode, } impl Default for RoomStock { fn default() -> Self { Self { possession_type: PossessionType::AntennaWhip, list_price: 1000000000, poverty_discount: false, can_buy: true, can_sell: Some(8000), } } } #[derive(Serialize, Deserialize, Clone)] pub enum RentSuiteType { Residential, Commercial, } #[derive(Serialize, Deserialize, Clone)] pub struct RentInfo { pub rent_what: String, pub suite_type: RentSuiteType, pub dynzone: super::dynzone::DynzoneType, pub daily_price: u64, pub setup_fee: u64, } #[allow(unused)] #[derive(Serialize, Deserialize, Clone)] pub enum MaterialType { Normal, WaterSurface, Underwater, Soft { damage_modifier: u64 }, } #[async_trait] pub trait RoomEnterTrigger { async fn handle_enter(self: &Self, ctx: &mut QueuedCommandContext, room: &Room) -> UResult<()>; } #[async_trait] pub trait RoomExitTrigger { async fn handle_exit(self: &Self, ctx: &mut QueuedCommandContext, room: &Room) -> UResult<()>; } #[async_trait] pub trait RoomSellTrigger { async fn handle_sell( &self, ctx: &mut VerbContext, room: &Room, player_item: &Item, sell_item: &Item, ) -> UResult<()>; } pub struct Room { pub zone: String, // Other zones where it can be seen on the map and accessed. pub secondary_zones: Vec, pub code: String, pub name: String, pub short: String, pub grid_coords: GridCoords, pub description: String, pub exits: Vec, pub should_caption: bool, pub repel_npc: bool, pub item_flags: Vec, // Empty means not a shop. pub stock_list: Vec, // What can be rented here... pub rentable_dynzone: Vec, pub material_type: MaterialType, pub has_power: bool, pub door_states: Option>, pub wristpad_hack_allowed: Option, pub scan_code: Option, pub journal: Option, pub enter_trigger: Option>, pub exit_trigger: Option>, pub sell_trigger: Option>, pub scavtable: ScavtableType, } impl Default for Room { fn default() -> Self { Self { zone: "default".to_owned(), secondary_zones: vec![], code: "default".to_owned(), name: "default".to_owned(), short: "DF".to_owned(), grid_coords: GridCoords { x: 0, y: 0, z: 0 }, description: "default".to_owned(), exits: vec![], should_caption: true, repel_npc: false, item_flags: vec![], stock_list: vec![], rentable_dynzone: vec![], material_type: MaterialType::Normal, has_power: false, door_states: None, wristpad_hack_allowed: None, scan_code: None, journal: None, enter_trigger: None, exit_trigger: None, sell_trigger: None, scavtable: ScavtableType::Nothing, } } } #[derive(Serialize, Deserialize)] #[serde(default)] pub struct SimpleRoom { pub zone: String, // Other zones where it can be seen on the map and accessed. pub secondary_zones: Vec, pub code: String, pub name: String, pub short: String, pub grid_coords: GridCoords, pub description: String, pub exits: Vec, pub should_caption: bool, pub repel_npc: bool, pub item_flags: Vec, // Empty means not a shop. pub stock_list: Vec, // What can be rented here... pub rentable_dynzone: Vec, pub material_type: MaterialType, pub has_power: bool, pub door_states: Option>, pub wristpad_hack_allowed: Option, pub scan_code: Option, pub journal: Option, pub scavtable: ScavtableType, pub effects: Option>, pub extra: T, } impl Into for SimpleRoom { fn into(self: SimpleRoom) -> Room { Room { zone: parse_ansi_markup(&self.zone).unwrap(), secondary_zones: self .secondary_zones .into_iter() .map(|sz| SecondaryZoneRecord { zone: sz.zone, short: parse_ansi_markup(&sz.short).unwrap(), grid_coords: sz.grid_coords, caption: sz.caption, }) .collect(), code: self.code, name: parse_ansi_markup(&self.name).unwrap(), short: parse_ansi_markup(&self.short).unwrap(), grid_coords: self.grid_coords, description: parse_ansi_markup(&self.description).unwrap(), exits: self.exits.into_iter().map(|e| e.into()).collect(), should_caption: self.should_caption, repel_npc: self.repel_npc, item_flags: self.item_flags, stock_list: self.stock_list, rentable_dynzone: self.rentable_dynzone, material_type: self.material_type, has_power: self.has_power, door_states: self.door_states, wristpad_hack_allowed: self.wristpad_hack_allowed, scan_code: self.scan_code, journal: self.journal, exit_trigger: self.effects.as_ref().map(|_fx| { Box::new(RoomEffectExitTrigger) as Box<(dyn RoomExitTrigger + std::marker::Send + Sync + 'static)> }), enter_trigger: self.effects.map(|fx| { Box::new(RoomEffectEntryTrigger { effects: fx }) as Box<(dyn RoomEnterTrigger + std::marker::Send + Sync + 'static)> }), sell_trigger: None, scavtable: self.scavtable, } } } impl<'a, T: Default> Default for SimpleRoom { fn default() -> Self { Self { zone: "default".to_owned(), secondary_zones: vec![], code: "default".to_owned(), name: "default".to_owned(), short: "DF".to_owned(), grid_coords: GridCoords { x: 0, y: 0, z: 0 }, description: "default".to_owned(), exits: vec![], should_caption: true, repel_npc: false, item_flags: vec![], stock_list: vec![], rentable_dynzone: vec![], material_type: MaterialType::Normal, has_power: false, door_states: None, wristpad_hack_allowed: None, scan_code: None, journal: None, scavtable: ScavtableType::Nothing, effects: None, extra: Default::default(), } } } static STATIC_ROOM_LIST: OnceCell> = OnceCell::new(); pub fn room_list() -> &'static Vec { STATIC_ROOM_LIST.get_or_init(|| { let mut rooms = repro_xv::room_list(); rooms.append(&mut melbs::room_list()); rooms.append(&mut cok_murl::room_list()); rooms.append(&mut chonkers::room_list()); rooms.append(&mut special::room_list()); rooms.append(&mut computer_museum::room_list()); rooms.append(&mut general_hospital::room_list()); rooms.append(&mut melbs_sewers::room_list()); rooms.append(&mut northern_radfields::room_list()); rooms.into_iter().collect() }) } static STATIC_ROOM_MAP_BY_CODE: OnceCell> = OnceCell::new(); pub fn room_map_by_code() -> &'static BTreeMap<&'static str, &'static Room> { STATIC_ROOM_MAP_BY_CODE .get_or_init(|| room_list().iter().map(|r| (r.code.as_str(), r)).collect()) } static STATIC_ROOM_MAP_BY_ZLOC: OnceCell< BTreeMap<(&'static str, &'static GridCoords), &'static Room>, > = OnceCell::new(); pub fn room_map_by_zloc() -> &'static BTreeMap<(&'static str, &'static GridCoords), &'static Room> { STATIC_ROOM_MAP_BY_ZLOC.get_or_init(|| { room_list() .iter() .map(|r| ((r.zone.as_str(), &r.grid_coords), r)) .chain(room_list().iter().flat_map(|r| { r.secondary_zones .iter() .map(|sz| ((sz.zone.as_str(), &sz.grid_coords), r)) .collect::>() })) .collect() }) } pub fn room_static_items() -> Box> { Box::new(room_list().iter().map(|r| StaticItem { item_code: r.code.clone(), initial_item: Box::new(|| Item { item_code: r.code.to_owned(), item_type: "room".to_owned(), display: r.name.to_owned(), details: Some(r.description.to_owned()), location: format!("zone/{}", r.zone), is_static: true, flags: r.item_flags.clone(), door_states: r.door_states.clone(), ..Item::default() }), extra_items_on_create: Box::new(|_| Box::new(std::iter::empty())), })) } pub fn resolve_exit(room: &Room, exit: &Exit) -> Option<&'static Room> { match exit.target { ExitTarget::Custom(ref t) => t.split_once("/").and_then(|(t, c)| { if t != "room" { None } else { room_map_by_code().get(c).map(|r| *r) } }), ExitTarget::UseGPS => room_map_by_zloc() .get(&(&room.zone, &room.grid_coords.apply(&exit.direction))) .map(|r| *r), } } pub async fn refresh_room_exits(trans: &DBTrans, template: &Item) -> DResult<()> { let mut target_mut = match trans .find_item_by_type_code(&template.item_type, &template.item_code) .await? { None => return Ok(()), Some(v) => (*v).clone(), }; let mut need_save: bool = false; if let None = target_mut.door_states.as_ref() { target_mut.door_states = Some(BTreeMap::new()); } if let Some(target_states) = target_mut.door_states.as_mut() { if let Some(template_states) = template.door_states.as_ref() { let target_keys = target_states .keys() .map(|v| v.clone()) .collect::>(); let template_keys = template_states .keys() .map(|v| v.clone()) .collect::>(); for extra in &target_keys - &template_keys { target_states.remove(&extra); need_save = true; } for missing in &template_keys - &target_keys { if let Some(template) = template_states.get(&missing) { target_states.insert(missing, template.clone()); need_save = true; } } for same in template_keys.intersection(&target_keys) { if let Some(target) = target_states.get_mut(&same) { if let Some(template) = template_states.get(&same) { if &template.description != &target.description { // We do this whole thing so we preserve the open state, // but change the description. target.description = template.description.clone(); need_save = true; } } } } } } if need_save { trans.save_item_model(&target_mut).await?; } Ok(()) } pub async fn check_for_exit_action( ctx: &mut QueuedCommandContext<'_>, exit_type: &str, exit_code: &str, ) -> UResult<()> { if exit_type != "room" { return Ok(()); } let room = match room_map_by_code().get(exit_code) { Some(r) => r, _ => return Ok(()), }; match room.exit_trigger { Some(ref trigger) => trigger.handle_exit(ctx, room).await?, _ => {} } Ok(()) } pub async fn check_for_enter_action(ctx: &mut QueuedCommandContext<'_>) -> UResult<()> { let room_code = match ctx.item.location.split_once("/") { Some((loc_type, _)) if loc_type != "room" => return Ok(()), Some((_, room_code)) => room_code, _ => return Ok(()), }; let room = match room_map_by_code().get(room_code) { Some(r) => r, _ => return Ok(()), }; match room.journal { Some(ref journal) if ctx.item.item_type == "player" => { if let Some(mut user) = ctx.trans.find_by_username(&ctx.item.item_code).await? { if award_journal_if_needed(&ctx.trans, &mut user, &mut ctx.item, journal.clone()) .await? { ctx.trans.save_user_model(&user).await?; } } } _ => {} } match room.enter_trigger { Some(ref trigger) => trigger.handle_enter(ctx, room).await?, _ => {} } Ok(()) } #[cfg(test)] mod test { use super::super::scavtable::scavtable_map; use super::*; use itertools::Itertools; #[test] fn room_zones_should_exist() { for room in room_list() { zone_details().get(room.zone.as_str()).expect(&format!( "zone {} for room {} should exist", room.zone, room.code )); } } #[test] fn room_shorts_should_be_display_length_2() { assert_eq!( room_list() .iter() .map(|r| ( r.code.as_str(), ansi::strip_special_characters(r.short.as_str()) )) .filter(|(_c, s)| s.len() != 2) .collect::>(), vec![] ); } #[test] fn room_map_by_code_should_have_repro_xv_chargen() { assert_eq!( room_map_by_code() .get("repro_xv_chargen") .expect("repro_xv_chargen to exist") .code, "repro_xv_chargen" ); } #[test] fn grid_coords_should_be_unique_in_zone() { let mut roomlist: Vec<&'static Room> = room_list().iter().collect(); roomlist .sort_unstable_by(|a, b| a.grid_coords.cmp(&b.grid_coords).then(a.zone.cmp(&b.zone))); let dups: Vec> = roomlist .iter() .group_by(|x| (&x.grid_coords, x.zone.as_str())) .into_iter() .map(|((coord, zone), rg)| { rg.map(|r| (r.name.as_str(), coord, zone)) .collect::>() }) .filter(|x| x.len() > 1) .collect(); assert_eq!(dups, Vec::>::new()); } #[test] fn parse_direction_should_work() { assert_eq!(Direction::parse("southeast"), Some(Direction::SOUTHEAST)); } #[test] fn parse_and_describe_direction_should_roundtrip() { let examples = vec![ "north", "south", "east", "west", "northeast", "southeast", "northwest", "southwest", "up", "down", "in there", ]; for example in examples { assert_eq!( Direction::parse(example).map(|v| v.describe()), Some(example.to_owned()) ); } } #[test] fn rooms_reference_valid_scavtables() { let bad_scav_rooms: Vec = room_list() .iter() .filter_map(|r| match scavtable_map().get(&r.scavtable) { Some(_) => None, None => Some(r.code.clone()), }) .collect::>(); assert_eq!(bad_scav_rooms, Vec::::new()); } #[test] fn exits_resolve() { let rl = room_list(); let unresolved_exits: Vec = rl .iter() .flat_map(|r: &'static Room| { let r2: &'static Room = r; r.exits.iter().map(move |ex| (r2, ex)) }) .filter_map(|(r, ex)| match resolve_exit(r, ex) { Some(_) => None, None => Some(format!("{} {}", r.code, &ex.direction.describe())), }) .collect::>(); assert_eq!(unresolved_exits, Vec::::new()); } }