blastmud/blastmud_game/src/static_content/room.rs

609 lines
19 KiB
Rust

use super::{journals::award_journal_if_needed, possession_type::PossessionType, StaticItem};
#[double]
use crate::db::DBTrans;
use crate::{
message_handler::user_commands::UResult,
models::{
item::{DoorState, Item, ItemFlag},
journal::JournalType,
user::WristpadHack,
},
regular_tasks::queued_command::QueuedCommandContext,
DResult,
};
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;
mod melbs;
mod repro_xv;
mod special;
pub struct Zone {
pub code: &'static str,
pub display: &'static str,
pub outdoors: bool,
}
static STATIC_ZONE_DETAILS: OnceCell<BTreeMap<&'static str, Zone>> = 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,
},
]
.into_iter()
.map(|x| (x.code, x))
.collect()
})
}
#[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Debug)]
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<bool>;
}
pub enum ExitType {
Free, // Anyone can just walk it (subject to any door logic).
Blocked(&'static (dyn ExitBlocker + Sync + Send)), // 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
Serialize::serialize(&self.describe(), serializer)
}
}
impl<'de> Deserialize<'de> for Direction {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<Direction> {
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<Direction> {
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)]
pub enum ExitTarget {
UseGPS,
Custom(&'static str),
}
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<ExitClimb>,
}
impl Default for Exit {
fn default() -> Self {
Self {
direction: Direction::NORTH,
target: ExitTarget::UseGPS,
exit_type: ExitType::Free,
exit_climb: None,
}
}
}
pub struct SecondaryZoneRecord {
pub zone: &'static str,
pub short: &'static str,
pub grid_coords: GridCoords,
pub caption: Option<&'static str>,
}
pub struct RoomStock {
pub possession_type: PossessionType,
pub list_price: u64,
pub poverty_discount: bool,
}
impl Default for RoomStock {
fn default() -> Self {
Self {
possession_type: PossessionType::AntennaWhip,
list_price: 1000000000,
poverty_discount: false,
}
}
}
pub struct RentInfo {
pub rent_what: &'static str,
pub dynzone: super::dynzone::DynzoneType,
pub daily_price: u64,
pub setup_fee: u64,
}
#[allow(unused)]
pub enum MaterialType {
Normal,
WaterSurface,
Underwater,
Soft { damage_modifier: f64 },
}
#[async_trait]
pub trait RoomEnterTrigger {
async fn handle_enter(self: &Self, ctx: &mut QueuedCommandContext, room: &Room) -> UResult<()>;
}
pub struct Room {
pub zone: &'static str,
// Other zones where it can be seen on the map and accessed.
pub secondary_zones: Vec<SecondaryZoneRecord>,
pub code: &'static str,
pub name: &'static str,
pub short: &'static str,
pub grid_coords: GridCoords,
pub description: &'static str,
pub description_less_explicit: Option<&'static str>,
pub exits: Vec<Exit>,
pub should_caption: bool,
pub repel_npc: bool,
pub item_flags: Vec<ItemFlag>,
// Empty means not a shop.
pub stock_list: Vec<RoomStock>,
// What can be rented here...
pub rentable_dynzone: Vec<RentInfo>,
pub material_type: MaterialType,
pub has_power: bool,
pub door_states: Option<BTreeMap<Direction, DoorState>>,
pub wristpad_hack_allowed: Option<WristpadHack>,
pub journal: Option<JournalType>,
pub enter_trigger: Option<&'static (dyn RoomEnterTrigger + Sync + Send)>,
}
impl Default for Room {
fn default() -> Self {
Self {
zone: "default",
secondary_zones: vec![],
code: "default",
name: "default",
short: "DF",
grid_coords: GridCoords { x: 0, y: 0, z: 0 },
description: "default",
description_less_explicit: None,
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,
journal: None,
enter_trigger: None,
}
}
}
static STATIC_ROOM_LIST: OnceCell<Vec<Room>> = OnceCell::new();
pub fn room_list() -> &'static Vec<Room> {
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.into_iter().collect()
})
}
static STATIC_ROOM_MAP_BY_CODE: OnceCell<BTreeMap<&'static str, &'static Room>> = 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, 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, &r.grid_coords), r))
.chain(room_list().iter().flat_map(|r| {
r.secondary_zones
.iter()
.map(|sz| ((sz.zone, &sz.grid_coords), r))
.collect::<Vec<((&'static str, &'static GridCoords), &'static Room)>>()
}))
.collect()
})
}
pub fn room_static_items() -> Box<dyn Iterator<Item = StaticItem>> {
Box::new(room_list().iter().map(|r| StaticItem {
item_code: r.code,
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()),
details_less_explicit: r.description_less_explicit.map(|d| d.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(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::<BTreeSet<Direction>>();
let template_keys = template_states
.keys()
.map(|v| v.clone())
.collect::<BTreeSet<Direction>>();
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_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(trigger) => trigger.handle_enter(ctx, room).await?,
_ => {}
}
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use itertools::Itertools;
#[test]
fn room_zones_should_exist() {
for room in room_list() {
zone_details().get(room.zone).expect(&format!(
"zone {} for room {} should exist",
room.zone, room.code
));
}
}
#[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<Vec<(&'static str, &GridCoords, &'static str)>> = roomlist
.iter()
.group_by(|x| (&x.grid_coords, x.zone))
.into_iter()
.map(|((coord, zone), rg)| {
rg.map(|r| (r.name, coord, zone))
.collect::<Vec<(&str, &GridCoords, &str)>>()
})
.filter(|x| x.len() > 1)
.collect();
assert_eq!(dups, Vec::<Vec<(&str, &GridCoords, &str)>>::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())
);
}
}
}