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}, stand::stand_if_needed, user_error, UResult, UserError, UserVerb, UserVerbRef, VerbContext, }; #[double] use crate::db::DBTrans; use crate::{ language, models::{ consent::ConsentType, effect::EffectType, item::{ ActiveClimb, DoorState, Item, ItemFlag, ItemSpecialData, LocationActionType, SkillType, }, }, regular_tasks::queued_command::{ queue_command, MovementSource, QueueCommand, QueueCommandHandler, QueuedCommandContext, }, services::{ check_consent, check_one_consent, combat::{change_health, handle_resurrect, stop_attacking_mut}, comms::broadcast_to_room, environment::ensure_appropriate_environment_handler_after_movement, sharing::stop_conversation_mut, skills::skill_check_and_grind, urges::{recalculate_urge_growth, thirst_changed}, }, static_content::{ dynzone::{dynzone_by_type, DynzoneType, ExitTarget as DynExitTarget}, npc::check_for_instant_aggro, room::{ self, check_for_enter_action, check_for_exit_action, room_map_by_code, Direction, ExitClimb, ExitType, MaterialType, }, species::species_info_map, }, DResult, }; use ansi::ansi; use async_trait::async_trait; 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, character: &Item, leaving: &Item, arriving: &Item, ) -> DResult<()> { let msg_leaving = format!( "{} departs towards {}.\n", &character.display_for_sentence(1, true), &arriving.display ); broadcast_to_room( trans, &format!("{}/{}", &leaving.item_type, &leaving.item_code), None, &msg_leaving, ) .await?; let msg_arriving = format!( "{} arrives from {}.\n", &character.display_for_sentence(1, true), &leaving.display ); broadcast_to_room( trans, &format!("{}/{}", &arriving.item_type, &arriving.item_code), None, &msg_arriving, ) .await?; Ok(()) } async fn move_to_where( use_location: &str, direction: &Direction, ctx: &mut QueuedCommandContext<'_>, ) -> UResult<(String, Option, Option<&'static ExitClimb>)> { // Firstly check dynamic exits, since they apply to rooms and dynrooms... if let Some(dynroom_result) = ctx .trans .find_exact_dyn_exit(use_location, direction) .await? { return Ok(( format!( "{}/{}", &dynroom_result.item_type, &dynroom_result.item_code ), Some(dynroom_result), None, )); } let (heretype, herecode) = use_location .split_once("/") .unwrap_or(("room", "repro_xv_chargen")); if heretype == "dynroom" { let old_dynroom_item = match ctx.trans.find_item_by_type_code(heretype, herecode).await? { None => user_error("Your current room has vanished!".to_owned())?, Some(v) => v, }; let (dynzone_code, dynroom_code) = match old_dynroom_item.special_data.as_ref() { Some(ItemSpecialData::DynroomData { dynzone_code, dynroom_code, }) => (dynzone_code, dynroom_code), _ => user_error("Your current room is invalid!".to_owned())?, }; let dynzone = dynzone_by_type() .get(&DynzoneType::from_str(dynzone_code).ok_or_else(|| { UserError("The type of your current zone no longer exists".to_owned()) })?) .ok_or_else(|| { UserError("The type of your current zone no longer exists".to_owned()) })?; let dynroom = dynzone .dyn_rooms .get(dynroom_code.as_str()) .ok_or_else(|| UserError("Your current room type no longer exists".to_owned()))?; let exit = dynroom .exits .iter() .find(|ex| ex.direction == *direction) .ok_or_else(|| UserError("There is nothing in that direction".to_owned()))?; return match exit.target { DynExitTarget::ExitZone => { let (zonetype, zonecode) = old_dynroom_item .location .split_once("/") .ok_or_else(|| UserError("Invalid zone for your room".to_owned()))?; let zoneitem = ctx .trans .find_item_by_type_code(zonetype, zonecode) .await? .ok_or_else(|| UserError("Can't find your zone".to_owned()))?; let zone_exit = match zoneitem.special_data.as_ref() { Some(ItemSpecialData::DynzoneData { zone_exit: None, .. }) => user_error("That exit doesn't seem to go anywhere".to_owned())?, Some(ItemSpecialData::DynzoneData { zone_exit: Some(zone_exit), .. }) => zone_exit, _ => user_error( "The zone you are in has invalid data associated with it".to_owned(), )?, }; Ok((zone_exit.to_string(), None, None)) } DynExitTarget::Intrazone { subcode } => { let to_item = ctx .trans .find_item_by_location_dynroom_code(&old_dynroom_item.location, &subcode) .await? .ok_or_else(|| { UserError("Can't find the room in that direction.".to_owned()) })?; Ok(( format!("{}/{}", &to_item.item_type, &to_item.item_code), Some(to_item), None, )) } }; } if heretype != "room" { user_error("Navigating outside rooms not yet supported.".to_owned())? } let room = room::room_map_by_code() .get(herecode) .ok_or_else(|| UserError("Can't find your current location".to_owned()))?; let exit = room .exits .iter() .find(|ex| ex.direction == *direction) .ok_or_else(|| UserError("There is nothing in that direction".to_owned()))?; match exit.exit_type { ExitType::Free => {} ExitType::Blocked(ref blocker) => { if !blocker.attempt_exit(ctx, exit).await? { user_error("Stopping movement".to_owned())?; } } } let new_room = room::resolve_exit(room, exit) .ok_or_else(|| UserError("Can't find that room".to_owned()))?; Ok(( format!("room/{}", new_room.code), None, exit.exit_climb.as_ref(), )) } pub async fn check_room_access(trans: &DBTrans, player: &Item, room: &Item) -> UResult<()> { let (owner_t, owner_c) = match room.owner.as_ref().and_then(|o| o.split_once("/")) { None => return Ok(()), Some(v) => v, }; if owner_t == &player.item_type && owner_c == &player.item_code { return Ok(()); } if owner_t == "corp" { let corp = match trans.find_corp_by_name(owner_c).await? { None => return Ok(()), // Defunct corp HQ somehow... Some(v) => v, }; if trans .find_corp_membership(&corp.0, &player.item_code) .await? .map(|cm| cm.joined_at.is_some()) .unwrap_or(false) { // Corp members pass the check. return Ok(()); } let consent_opt = trans .find_corp_consent_by_consenting_corp_consented_user_type( &corp.0, &player.item_code, &ConsentType::Fight, ) .await?; let mut player_hypothet = (*player).clone(); player_hypothet.location = room.refstr(); if consent_opt .as_ref() .map(|c| check_one_consent(c, "enter", &player_hypothet)) .unwrap_or(false) { return Ok(()); } } else { let owner = match trans.find_item_by_type_code(owner_t, owner_c).await? { None => return Ok(()), Some(v) => v, }; if check_consent(trans, "enter", &ConsentType::Visit, player, &owner).await? { return Ok(()); } let mut player_hypothet = (*player).clone(); // We are asking hypothetically if they entered the room, could they fight // the owner? We won't save this yet. player_hypothet.location = room.refstr(); if check_consent( trans, "enter", &ConsentType::Fight, &player_hypothet, &owner, ) .await? { return Ok(()); } } user_error(ansi!("Your wristpad buzzes and your muscles lock up, stopping you entering. \ It seems this is private property and you haven't been invited here with \ allow visit, nor do you have a allow fight in force with \ the owner here.").to_owned())? } pub async fn handle_fall(trans: &DBTrans, faller: &mut Item, fall_dist: u64) -> UResult { // TODO depend on distance, armour, etc... // This is deliberately less damage than real life for the distance, // since we'll assume the wristpad provides reflexes to buffer some damage. let damage_modifier = match faller.location.split_once("/") { Some((ltype, lcode)) if ltype == "room" => match room::room_map_by_code().get(lcode) { None => 1.0, Some(room) => match room.material_type { MaterialType::WaterSurface | MaterialType::Underwater => { return Ok("lands with a splash".to_owned()); } MaterialType::Soft { damage_modifier } => damage_modifier as f64 / 100.0, MaterialType::Normal => 1.0, }, }, _ => 1.0, }; let modified_safe_distance = 5.0 / damage_modifier; if (fall_dist as f64) < modified_safe_distance { return Ok("lands softly".to_owned()); } // The force is proportional to the square root of the fall distance. let damage = ((fall_dist as f64 - modified_safe_distance).sqrt() * 3.0 * damage_modifier * Normal::new(1.0, 0.3)?.sample(&mut rand::thread_rng())) as i64; if damage > 0 { change_health(trans, -damage, faller, "You fell").await?; } let descriptor = if damage >= 30 { "smashes violently into the ground like a comet with a massive boom" } else if damage >= 25 { "smashes violently into the ground with a very loud bang" } else if damage >= 20 { "smashes violently into the ground with a loud bang" } else if damage >= 15 { "smashes into the ground with a loud thump" } else if damage >= 10 { "smashes into the ground with a thump" } else { "lands with a thump" }; Ok(descriptor.to_owned()) } pub async fn reverse_climb( player: &mut Item, trans: &DBTrans, command: &mut QueueCommand, ) -> UResult { let command_orig = command.clone(); match command { QueueCommand::Movement { direction: ref mut d, .. } => { let loc = player.location.clone(); let mut tmp_ctx = QueuedCommandContext { trans, command: &command_orig, item: player, }; let (new_loc, _item, climb_opt) = move_to_where(&loc, d, &mut tmp_ctx).await?; if let Some(climb) = climb_opt { if let Some(rev_d) = (*d).reverse() { *d = rev_d; player.location = new_loc; if climb.height > 0 { player .active_climb .as_mut() .map(|ac| ac.height = climb.height as u64 - ac.height); Ok("You start climbing back down.\n".to_owned()) } else { player .active_climb .as_mut() .map(|ac| ac.height = (-climb.height) as u64 - ac.height); Ok("You start climbing back up.\n".to_owned()) } } else { user_error("You can't figure out how to climb back.".to_owned())? } } else { user_error("You can't figure out how to climb back.".to_owned())? } } _ => user_error("You can't seem to stop climbing for some reason.".to_owned())?, } } // Calculates the impact of movement, in 1/100ths of a percentage point impact on thirst. fn movement_thirst_impact(into_room: &str) -> u16 { const DEFAULT_IMPACT: u16 = 30; let (r_type, r_code) = match into_room.split_once("/") { None => return DEFAULT_IMPACT, Some(v) => v, }; if r_type != "room" { return DEFAULT_IMPACT; } let room = match room_map_by_code().get(r_code) { None => return DEFAULT_IMPACT, Some(r) => r, }; let temp_room_hundredths: f64 = room.environment.temperature as f64; // Apply the S-shaped logistic function calibrated as follows: // Minimum -> 5, 2000 (20.0 deg C) -> 10, 3700 (37.0 deg C) -> 80, Maximum -> 100 let thirst_factor = 95.0 / (1.0 + (-2.4777221163991086E-3 * (temp_room_hundredths - 3166.543955339416)).exp()) + 5.0; (thirst_factor * 3.0) as u16 } // Returns true if the move is either complete or still in progress. // Returns false if the move failed. pub async fn attempt_move_immediate( direction: &Direction, ctx: &mut QueuedCommandContext<'_>, source: &MovementSource, ) -> 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())?; } "room/repro_xv_respawn".to_owned() } else { ctx.item.location.clone() }; if ctx .item .active_effects .iter() .any(|v| v.0 == EffectType::Stunned) && !ctx.item.death_data.is_some() { user_error("You're too stunned to move.".to_owned())?; } let session = ctx.get_session().await?; match is_door_in_direction(ctx.trans, direction, &use_location).await? { DoorSituation::NoDoor | DoorSituation::DoorOutOfRoom { state: DoorState { open: true, .. }, .. } => {} DoorSituation::DoorIntoRoom { state: DoorState { open: true, .. }, room_with_door, .. } => { check_room_access(ctx.trans, ctx.item, &room_with_door).await?; } _ => { if !species_info_map() .get(&ctx.item.species) .map(|inf| inf.can_open_door) .unwrap_or(false) { return Ok(false); } attempt_open_immediate(ctx, direction).await?; // Players take an extra step. So tell them to come back. ctx.item.queue.push_front(QueueCommand::Movement { direction: direction.clone(), source: MovementSource::Internal { event_id: source.event_id().clone(), }, }); return Ok(true); } } let (new_loc, new_loc_item, climb_opt) = move_to_where(&use_location, direction, ctx).await?; let mut skip_escape_check: bool = false; let mut escape_check_only: bool = false; if let Some(climb) = climb_opt { if let Some(active_climb) = ctx.item.active_climb.clone() { skip_escape_check = true; // Already done if we get here. let skills = skill_check_and_grind( ctx.trans, ctx.item, &SkillType::Climb, climb.difficulty as f64, ) .await?; let mut narrative = String::new(); if skills <= -0.25 { // Crit fail - they have fallen. 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; let descriptor = handle_fall(&ctx.trans, ctx.item, fall_dist).await?; let msg = format!( "{} loses {} grip from {} metres up and {}!\n", ctx.item.display_for_sentence(1, true), ctx.item.pronouns.possessive, fall_dist, &descriptor ); broadcast_to_room(ctx.trans, &from_room, None, &msg).await?; broadcast_to_room(ctx.trans, &to_room, None, &msg).await?; ctx.item.queue.truncate(0); if got_there { if let Some((old_loc_type, old_loc_code)) = use_location.split_once("/") { check_for_exit_action(ctx, old_loc_type, old_loc_code).await?; } check_for_instant_aggro(&ctx.trans, &mut ctx.item).await?; check_for_enter_action(ctx).await?; ensure_appropriate_environment_handler_after_movement(ctx).await?; } 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"); } else { narrative.push_str( "You struggle to find a foothold and reluctantly climb a metre back up", ); } if let Some(ac) = ctx.item.active_climb.as_mut() { if ac.height > 0 { ac.height -= 1; } } } else { if climb.height < 0 { narrative.push_str("You climb down another metre"); } else { narrative.push_str("You climb up another metre"); } if let Some(ac) = ctx.item.active_climb.as_mut() { ac.height += 1; } } if let Some(ac) = ctx.item.active_climb.as_ref() { if climb.height >= 0 && ac.height >= climb.height as u64 { if let Some((sess, _)) = session.as_ref() { ctx.trans .queue_for_session( sess, Some( "You brush yourself off and finish climbing - you \ made it to the top!\n", ), ) .await?; } ctx.item.active_climb = None; } else if climb.height < 0 && ac.height >= (-climb.height) as u64 { if let Some((sess, _)) = session.as_ref() { ctx.trans .queue_for_session( sess, Some( "You brush yourself off and finish climbing - you \ made it down!\n", ), ) .await?; } ctx.item.active_climb = None; } else { let progress_quant = (((ac.height as f64) / (climb.height.abs() as f64)) * 10.0) as u64; if let Some((sess, _)) = session { ctx.trans.queue_for_session( &sess, Some(&format!(ansi!("[{}{}] [{}/{} m] {}\n"), "=".repeat(progress_quant as usize), " ".repeat((10 - progress_quant) as usize), ac.height, climb.height.abs(), &narrative ))).await?; } ctx.item.queue.push_front(QueueCommand::Movement { direction: direction.clone(), source: MovementSource::Internal { event_id: source.event_id().clone(), }, }); return Ok(true); } } } else { let msg = format!( "{} starts climbing {}\n", &ctx.item.display_for_sentence(1, true), &direction.describe_climb(if climb.height > 0 { "up" } else { "down" }) ); broadcast_to_room(&ctx.trans, &use_location, None, &msg).await?; ctx.item.active_climb = Some(ActiveClimb { ..Default::default() }); ctx.item.queue.push_front(QueueCommand::Movement { direction: direction.clone(), source: MovementSource::Internal { event_id: source.event_id().clone(), }, }); escape_check_only = true; } } if !skip_escape_check { match ctx .item .active_combat .as_ref() .and_then(|ac| ac.attacking.clone()) { None => {} Some(old_victim) => { if let Some((vcode, vtype)) = old_victim.split_once("/") { if let Some(vitem) = ctx.trans.find_item_by_type_code(vcode, vtype).await? { let mut vitem_mut = (*vitem).clone(); stop_attacking_mut(ctx.trans, ctx.item, &mut vitem_mut, false).await?; ctx.trans.save_item_model(&vitem_mut).await? } } } } match ctx .item .active_combat .clone() .as_ref() .map(|ac| &ac.attacked_by[..]) { None | Some([]) => {} Some(attackers) => { let mut attacker_names = Vec::new(); let mut attacker_items = Vec::new(); for attacker in &attackers[..] { if let Some((atype, acode)) = attacker.split_once("/") { if let Some(aitem) = ctx.trans.find_item_by_type_code(atype, acode).await? { attacker_names.push(aitem.display_for_sentence(1, false)); // We don't push the actual attacker Item, because another attacker // might re-target this attacker when we escape, causing the structure // to be out of date. Instead, we push the type, code pair and look it // up when we need it. attacker_items.push((atype, acode)); } } } let attacker_names_ref = attacker_names .iter() .map(|n| n.as_str()) .collect::>(); let attacker_names_str = language::join_words(&attacker_names_ref[..]); if skill_check_and_grind( ctx.trans, ctx.item, &SkillType::Dodge, attackers.len() as f64 + 8.0, ) .await? >= 0.0 || ctx.item.flags.contains(&ItemFlag::Invincible) { if let Some((sess, _)) = session.as_ref() { ctx.trans .queue_for_session( sess, Some(&format!( "You successfully get away from {}\n", &attacker_names_str )), ) .await?; } for (item_type, item_code) in &attacker_items[..] { if let Some(item) = ctx .trans .find_item_by_type_code(item_type, item_code) .await? { let mut item_mut = (*item).clone(); stop_attacking_mut(ctx.trans, &mut item_mut, ctx.item, true).await?; ctx.trans.save_item_model(&item_mut).await?; } } } else { if let Some((sess, _)) = session.as_ref() { ctx.trans .queue_for_session( sess, Some(&format!( "You try and fail to run past {}\n", &attacker_names_str )), ) .await?; } ctx.item.queue.truncate(0); return Ok(false); } } } } if escape_check_only { return Ok(true); } if ctx.item.death_data.is_some() { if !handle_resurrect(ctx.trans, ctx.item).await? { user_error("You couldn't be resurrected.".to_string())?; } } recalculate_urge_growth(ctx.trans, &mut ctx.item).await?; if let Some(urges) = ctx.item.urges.as_mut() { urges.thirst.last_value = urges.thirst.value; urges.thirst.value = (urges.thirst.value + movement_thirst_impact(&new_loc)).min(10000); thirst_changed(&ctx.trans, &ctx.item).await?; } ctx.item.location = new_loc.clone(); ctx.item.action_type = LocationActionType::Normal; ctx.item.active_combat = None; if let Some((sess, mut session_dat)) = session { let mut user = ctx.trans.find_by_username(&ctx.item.item_code).await?; // Look reads it, so we ensure we save it first. ctx.trans.save_item_model(&ctx.item).await?; look::VERB .handle( &mut VerbContext { session: &sess, session_dat: &mut session_dat, trans: ctx.trans, user_dat: &mut user, }, "look", "", ) .await?; } if let Some((old_loc_type, old_loc_code)) = use_location.split_once("/") { if let Some(old_room_item) = ctx .trans .find_item_by_type_code(old_loc_type, old_loc_code) .await? { if let Some((new_loc_type, new_loc_code)) = new_loc.split_once("/") { if let Some(new_room_item) = match new_loc_item { None => { ctx.trans .find_item_by_type_code(new_loc_type, new_loc_code) .await? } v => v.map(Arc::new), } { announce_move(&ctx.trans, ctx.item, &old_room_item, &new_room_item).await?; } } } check_for_exit_action(ctx, old_loc_type, old_loc_code).await?; } check_for_instant_aggro(&ctx.trans, &mut ctx.item).await?; check_for_enter_action(ctx).await?; ensure_appropriate_environment_handler_after_movement(ctx).await?; Ok(true) } pub struct QueueHandler; #[async_trait] impl QueueCommandHandler for QueueHandler { 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())?, }; if ctx.item.urges.as_ref().map(|u| u.stress.value).unwrap_or(0) > 9500 { user_error( ansi!( "You are so tired and stressed you can't move. Maybe try to \ sit or recline for a bit!" ) .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?; stand_if_needed(&ctx.trans, &mut ctx.item).await?; propagate_move_to_followers(&ctx.trans, &mut ctx.item, &direction, &source).await?; let mut move_factor: u64 = 1; let mut slow_factors: Vec = vec![]; if let Some(urges) = ctx.item.urges.as_ref() { if urges.hunger.value > 9500 { slow_factors.push("you're starving".to_owned()); move_factor *= 8; } else if urges.hunger.value > 8000 { slow_factors.push("you're very hungry".to_owned()); move_factor *= 4; } else if urges.hunger.value > 5000 { slow_factors.push("you're hungry".to_owned()); move_factor *= 2; } if urges.thirst.value > 9500 { slow_factors.push("your throat is parched with thirst".to_owned()); move_factor *= 8; } else if urges.thirst.value > 8000 { slow_factors.push("you're very thirsty".to_owned()); move_factor *= 4; } else if urges.thirst.value > 5000 { slow_factors.push("you're thirsty".to_owned()); move_factor *= 2; } if urges.stress.value > 9500 { slow_factors.push("you're exhausted".to_owned()); move_factor *= 8; } else if urges.stress.value > 8000 { slow_factors.push("you're very stressed and tired".to_owned()); move_factor *= 4; } else if urges.stress.value > 5000 { slow_factors.push("you're stressed and tired".to_owned()); move_factor *= 2; } } if slow_factors.len() > 0 { if let Some((sess, _)) = ctx.get_session().await? { ctx.trans .queue_for_session( &sess, Some(&format!( "You move slowly because {}.\n", language::join_words( &slow_factors .iter() .map(|f| f.as_str()) .collect::>() ) )), ) .await? } } if move_factor > 16 { move_factor = 16; } Ok(time::Duration::from_secs(move_factor)) } #[allow(unreachable_patterns)] async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> { let (direction, source) = match ctx.command { QueueCommand::Movement { direction, source } => (direction, source), _ => user_error("Unexpected command".to_owned())?, }; 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(()) } } pub struct Verb; #[async_trait] impl UserVerb for Verb { async fn handle( self: &Self, ctx: &mut VerbContext, verb: &str, remaining: &str, ) -> UResult<()> { let dir = Direction::parse(&(verb.to_owned() + " " + remaining.trim()).trim()) .ok_or_else(|| UserError("Unknown direction".to_owned()))?; let mut player_item = (*get_player_item_or_fail(ctx).await?).clone(); suspend_follow_for_independent_move(&mut player_item); queue_command( ctx, &mut player_item, &QueueCommand::Movement { direction: dir.clone(), source: MovementSource::Command { event_id: Uuid::new_v4(), }, }, ) .await?; if player_item.active_conversation.is_some() { stop_conversation_mut( &ctx.trans, &mut player_item, "walks away from sharing knowledge with", ) .await?; } ctx.trans.save_item_model(&player_item).await?; Ok(()) } } static VERB_INT: Verb = Verb; pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;