From daacadaedb2787518730e8692600a55a769433f6 Mon Sep 17 00:00:00 2001 From: Joshua Barretto Date: Thu, 13 Apr 2023 14:34:31 +0100 Subject: [PATCH] Made NPCs give directions to nearby towns, fixed player death propagation --- assets/voxygen/i18n/en/npc.ftl | 12 ++ common/src/comp/chat.rs | 4 + common/src/comp/compass.rs | 16 +- rtsim/src/data/site.rs | 7 + rtsim/src/gen/site.rs | 1 + rtsim/src/rule/npc_ai.rs | 31 +++- rtsim/src/rule/simulate_npcs.rs | 195 ++++++++++++----------- rtsim/src/rule/sync_npcs.rs | 37 +++++ server/src/events/entity_manipulation.rs | 14 +- 9 files changed, 207 insertions(+), 110 deletions(-) diff --git a/assets/voxygen/i18n/en/npc.ftl b/assets/voxygen/i18n/en/npc.ftl index ac7c12fd6e..9f6025afa4 100644 --- a/assets/voxygen/i18n/en/npc.ftl +++ b/assets/voxygen/i18n/en/npc.ftl @@ -250,6 +250,10 @@ npc-speech-merchant_sell_directed = .a0 = You there! Are you in need of a new thingamabob? .a1 = Are you hungry? I'm sure I've got some cheese you can buy. .a2 = You look like you could do with some new armour! +npc-speech-tell_site = + .a0 = Have you visited { $site }? It's just { $dir } of here! + .a1 = You should visit { $site } some time. + .a2 = If you travel { $dir }, you can get to { $site }. npc-speech-witness_murder = .a0 = Murderer! .a1 = How could you do this? @@ -258,3 +262,11 @@ npc-speech-witness_death = .a0 = No! .a1 = This is terrible! .a2 = Oh my goodness! +npc-speech-dir_north = north +npc-speech-dir_north_east = north-east +npc-speech-dir_east = east +npc-speech-dir_south_east = south-east +npc-speech-dir_south = south +npc-speech-dir_south_west = south-west +npc-speech-dir_west = west +npc-speech-dir_north_west = north-west diff --git a/common/src/comp/chat.rs b/common/src/comp/chat.rs index ad5e55daee..4b5e4f73fc 100644 --- a/common/src/comp/chat.rs +++ b/common/src/comp/chat.rs @@ -231,6 +231,10 @@ pub enum LocalizationArg { Nat(u64), } +impl From for LocalizationArg { + fn from(content: Content) -> Self { Self::Content(content) } +} + // TODO: Remove impl and make use of `Content(Plain(...))` explicit (to // discourage it) impl From for LocalizationArg { diff --git a/common/src/comp/compass.rs b/common/src/comp/compass.rs index 4937e87d1c..f5e2ad1a1b 100644 --- a/common/src/comp/compass.rs +++ b/common/src/comp/compass.rs @@ -1,3 +1,4 @@ +use super::Content; use vek::Vec2; // TODO: Move this to common/src/, it's not a component @@ -39,7 +40,7 @@ impl Direction { } } - // TODO: localization + // TODO: Remove this in favour of `Direction::localize_npc`? pub fn name(&self) -> &'static str { match self { Direction::North => "North", @@ -52,6 +53,19 @@ impl Direction { Direction::Northwest => "Northwest", } } + + pub fn localize_npc(&self) -> Content { + Content::localized(match self { + Direction::North => "npc-speech-dir_north", + Direction::Northeast => "npc-speech-dir_north_east", + Direction::East => "npc-speech-dir_east", + Direction::Southeast => "npc-speech-dir_south_east", + Direction::South => "npc-speech-dir_south", + Direction::Southwest => "npc-speech-dir_south_west", + Direction::West => "npc-speech-dir_west", + Direction::Northwest => "npc-speech-dir_north_west", + }) + } } /// Arbitrarily named Distances diff --git a/rtsim/src/data/site.rs b/rtsim/src/data/site.rs index c736e49623..2ae27aa3d1 100644 --- a/rtsim/src/data/site.rs +++ b/rtsim/src/data/site.rs @@ -40,6 +40,13 @@ pub struct Site { // Note: there's currently no guarantee that site populations are non-intersecting #[serde(skip_serializing, skip_deserializing)] pub population: HashSet, + + /// A list of the nearby sites where each elements is both further and + /// larger (currently based on number of plots) than the next. + /// Effectively, this is a list of nearby sites that might be deemed + /// 'important' to the current one + #[serde(skip_serializing, skip_deserializing)] + pub nearby_sites_by_size: Vec, } impl Site { diff --git a/rtsim/src/gen/site.rs b/rtsim/src/gen/site.rs index fd4c09eed5..f398a26aef 100644 --- a/rtsim/src/gen/site.rs +++ b/rtsim/src/gen/site.rs @@ -53,6 +53,7 @@ impl Site { }), population: Default::default(), known_reports: Default::default(), + nearby_sites_by_size: Vec::new(), } } } diff --git a/rtsim/src/rule/npc_ai.rs b/rtsim/src/rule/npc_ai.rs index 409f0b3ed1..dea7394e29 100644 --- a/rtsim/src/rule/npc_ai.rs +++ b/rtsim/src/rule/npc_ai.rs @@ -11,9 +11,9 @@ use crate::{ }; use common::{ astar::{Astar, PathResult}, - comp::Content, + comp::{compass::Direction, Content}, path::Path, - rtsim::{ChunkResource, Profession, SiteId}, + rtsim::{Actor, ChunkResource, Profession, SiteId}, spiral::Spiral2d, store::Id, terrain::{CoordinateConversions, SiteKindMeta, TerrainChunkSize}, @@ -489,7 +489,21 @@ fn socialize() -> impl Action { .choose(&mut ctx.rng) { return Either::Left( - just(move |ctx| ctx.controller.say(other, ctx.npc.personality.get_generic_comment(&mut ctx.rng))) + just(move |ctx| ctx.controller.say(other, if ctx.rng.gen_bool(0.3) + && let Some(current_site) = ctx.npc.current_site + && let Some(current_site) = ctx.state.data().sites.get(current_site) + && let Some(mention_site) = current_site.nearby_sites_by_size.choose(&mut ctx.rng) + && let Some(mention_site) = ctx.state.data().sites.get(*mention_site) + && let Some(mention_site_name) = mention_site.world_site + .map(|ws| ctx.index.sites.get(ws).name().to_string()) + { + Content::localized_with_args("npc-speech-tell_site", [ + ("site", Content::Plain(mention_site_name.clone())), + ("dir", Direction::from_dir(mention_site.wpos.as_() - ctx.npc.wpos.xy()).localize_npc()), + ]) + } else { + ctx.npc.personality.get_generic_comment(&mut ctx.rng) + })) // After greeting the actor, wait for a while .then(idle().repeat().stop_if(timeout(4.0))) .map(|_| ()) @@ -713,7 +727,7 @@ fn villager(visiting_site: SiteId) -> impl Action { .map(|_| ()), ); } - } else if matches!(ctx.npc.profession, Some(Profession::Guard)) && ctx.rng.gen_bool(0.5) { + } else if matches!(ctx.npc.profession, Some(Profession::Guard)) && ctx.rng.gen_bool(0.7) { if let Some(plaza_wpos) = choose_plaza(ctx, visiting_site) { return casual( travel_to_point(plaza_wpos, 0.4) @@ -918,8 +932,13 @@ fn check_inbox(ctx: &mut NpcCtx) -> Option { // TODO: Sentiment should be positive if we didn't like actor that died // TODO: Don't report self let phrase = if let Some(killer) = killer { - // TODO: Don't hard-code sentiment change - ctx.sentiments.change_by(killer, -0.7, Sentiment::VILLAIN); + // TODO: For now, we don't make sentiment changes if the killer was an + // NPC because NPCs can't hurt one-another. + // This should be changed in the future. + if !matches!(killer, Actor::Npc(_)) { + // TODO: Don't hard-code sentiment change + ctx.sentiments.change_by(killer, -0.7, Sentiment::VILLAIN); + } "npc-speech-witness_murder" } else { "npc-speech-witness_death" diff --git a/rtsim/src/rule/simulate_npcs.rs b/rtsim/src/rule/simulate_npcs.rs index 0350a414bb..f319b67985 100644 --- a/rtsim/src/rule/simulate_npcs.rs +++ b/rtsim/src/rule/simulate_npcs.rs @@ -45,107 +45,110 @@ fn on_setup(ctx: EventCtx) { fn on_death(ctx: EventCtx) { let data = &mut *ctx.state.data_mut(); - if let Actor::Npc(npc_id) = ctx.event.actor - && let Some(npc) = data.npcs.get(npc_id) - { - let mut rng = ChaChaRng::from_seed(thread_rng().gen::<[u8; 32]>()); + if let Actor::Npc(npc_id) = ctx.event.actor { + if let Some(npc) = data.npcs.get(npc_id) { + let mut rng = ChaChaRng::from_seed(thread_rng().gen::<[u8; 32]>()); - // Respawn dead NPCs - let details = match npc.body { - Body::Humanoid(_) => { - if let Some((site_id, site)) = data - .sites - .iter() - .filter(|(id, site)| { - Some(*id) != npc.home - && (npc.faction.is_none() || site.faction == npc.faction) - && site.world_site.map_or(false, |s| { - matches!(ctx.index.sites.get(s).kind, SiteKind::Refactor(_) - | SiteKind::CliffTown(_) - | SiteKind::SavannahPit(_) - | SiteKind::DesertCity(_)) - }) - }) - .min_by_key(|(_, site)| site.population.len()) - { - let rand_wpos = |rng: &mut ChaChaRng| { - let wpos2d = site.wpos.map(|e| e + rng.gen_range(-10..10)); - wpos2d - .map(|e| e as f32 + 0.5) - .with_z(ctx.world.sim().get_alt_approx(wpos2d).unwrap_or(0.0)) - }; - let random_humanoid = |rng: &mut ChaChaRng| { - let species = comp::humanoid::ALL_SPECIES.choose(&mut *rng).unwrap(); - Body::Humanoid(comp::humanoid::Body::random_with(rng, species)) - }; - let npc_id = data.spawn_npc( - Npc::new(rng.gen(), rand_wpos(&mut rng), random_humanoid(&mut rng)) - .with_personality(Personality::random(&mut rng)) - .with_home(site_id) - .with_faction(npc.faction) - .with_profession(npc.profession.clone()), - ); - Some((npc_id, site_id)) - } else { - warn!("No site found for respawning humanoid"); + // Respawn dead NPCs + let details = match npc.body { + Body::Humanoid(_) => { + if let Some((site_id, site)) = data + .sites + .iter() + .filter(|(id, site)| { + Some(*id) != npc.home + && (npc.faction.is_none() || site.faction == npc.faction) + && site.world_site.map_or(false, |s| { + matches!( + ctx.index.sites.get(s).kind, + SiteKind::Refactor(_) + | SiteKind::CliffTown(_) + | SiteKind::SavannahPit(_) + | SiteKind::DesertCity(_) + ) + }) + }) + .min_by_key(|(_, site)| site.population.len()) + { + let rand_wpos = |rng: &mut ChaChaRng| { + let wpos2d = site.wpos.map(|e| e + rng.gen_range(-10..10)); + wpos2d + .map(|e| e as f32 + 0.5) + .with_z(ctx.world.sim().get_alt_approx(wpos2d).unwrap_or(0.0)) + }; + let random_humanoid = |rng: &mut ChaChaRng| { + let species = comp::humanoid::ALL_SPECIES.choose(&mut *rng).unwrap(); + Body::Humanoid(comp::humanoid::Body::random_with(rng, species)) + }; + let npc_id = data.spawn_npc( + Npc::new(rng.gen(), rand_wpos(&mut rng), random_humanoid(&mut rng)) + .with_personality(Personality::random(&mut rng)) + .with_home(site_id) + .with_faction(npc.faction) + .with_profession(npc.profession.clone()), + ); + Some((npc_id, site_id)) + } else { + warn!("No site found for respawning humanoid"); + None + } + }, + Body::BirdLarge(_) => { + if let Some((site_id, site)) = data + .sites + .iter() + .filter(|(id, site)| { + Some(*id) != npc.home + && site.world_site.map_or(false, |s| { + matches!(ctx.index.sites.get(s).kind, SiteKind::Dungeon(_)) + }) + }) + .min_by_key(|(_, site)| site.population.len()) + { + let rand_wpos = |rng: &mut ChaChaRng| { + let wpos2d = site.wpos.map(|e| e + rng.gen_range(-10..10)); + wpos2d + .map(|e| e as f32 + 0.5) + .with_z(ctx.world.sim().get_alt_approx(wpos2d).unwrap_or(0.0)) + }; + let species = [ + comp::body::bird_large::Species::Phoenix, + comp::body::bird_large::Species::Cockatrice, + comp::body::bird_large::Species::Roc, + ] + .choose(&mut rng) + .unwrap(); + let npc_id = data.npcs.create_npc( + Npc::new( + rng.gen(), + rand_wpos(&mut rng), + Body::BirdLarge(comp::body::bird_large::Body::random_with( + &mut rng, species, + )), + ) + .with_home(site_id), + ); + Some((npc_id, site_id)) + } else { + warn!("No site found for respawning bird"); + None + } + }, + body => { + error!("Tried to respawn rtsim NPC with invalid body: {:?}", body); None - } - }, - Body::BirdLarge(_) => { - if let Some((site_id, site)) = data - .sites - .iter() - .filter(|(id, site)| { - Some(*id) != npc.home - && site.world_site.map_or(false, |s| { - matches!(ctx.index.sites.get(s).kind, SiteKind::Dungeon(_)) - }) - }) - .min_by_key(|(_, site)| site.population.len()) - { - let rand_wpos = |rng: &mut ChaChaRng| { - let wpos2d = site.wpos.map(|e| e + rng.gen_range(-10..10)); - wpos2d - .map(|e| e as f32 + 0.5) - .with_z(ctx.world.sim().get_alt_approx(wpos2d).unwrap_or(0.0)) - }; - let species = [ - comp::body::bird_large::Species::Phoenix, - comp::body::bird_large::Species::Cockatrice, - comp::body::bird_large::Species::Roc, - ] - .choose(&mut rng) - .unwrap(); - let npc_id = data.npcs.create_npc( - Npc::new( - rng.gen(), - rand_wpos(&mut rng), - Body::BirdLarge(comp::body::bird_large::Body::random_with( - &mut rng, species, - )), - ) - .with_home(site_id), - ); - Some((npc_id, site_id)) - } else { - warn!("No site found for respawning bird"); - None - } - }, - body => { - error!("Tried to respawn rtsim NPC with invalid body: {:?}", body); - None - }, - }; + }, + }; - // Add the NPC to their home site - if let Some((npc_id, home_site)) = details { - if let Some(home) = data.sites.get_mut(home_site) { - home.population.insert(npc_id); + // Add the NPC to their home site + if let Some((npc_id, home_site)) = details { + if let Some(home) = data.sites.get_mut(home_site) { + home.population.insert(npc_id); + } } + } else { + error!("Trying to respawn non-existent NPC"); } - } else { - error!("Trying to respawn non-existent NPC"); } } diff --git a/rtsim/src/rule/sync_npcs.rs b/rtsim/src/rule/sync_npcs.rs index af71b08779..d89ffc3416 100644 --- a/rtsim/src/rule/sync_npcs.rs +++ b/rtsim/src/rule/sync_npcs.rs @@ -28,6 +28,43 @@ fn on_setup(ctx: EventCtx) { home.population.insert(npc_id); } } + + // Update the list of nearest sites by size for each site + let sites_iter = data.sites.iter().filter_map(|(site_id, site)| { + let site2 = site + .world_site + .and_then(|ws| ctx.index.sites.get(ws).site2())?; + Some((site_id, site, site2)) + }); + let nearest_by_size = sites_iter.clone() + .map(|(site_id, site, site2)| { + let mut other_sites = sites_iter.clone() + // Only include sites in the list if they're not the current one and they're more populus + .filter(|(other_id, _, other_site2)| *other_id != site_id && other_site2.plots().len() > site2.plots().len()) + .collect::>(); + other_sites.sort_by_key(|(_, other, _)| other.wpos.distance_squared(site.wpos) as i64); + let mut max_size = 0; + // Remove sites that aren't in increasing order of size (Stalin sort?!) + other_sites.retain(|(_, _, other_site2)| { + if other_site2.plots().len() > max_size { + max_size = other_site2.plots().len(); + true + } else { + false + } + }); + let nearest_by_size = other_sites + .into_iter() + .map(|(site_id, _, _)| site_id) + .collect::>(); + (site_id, nearest_by_size) + }) + .collect::>(); + for (site_id, nearest_by_size) in nearest_by_size { + if let Some(site) = data.sites.get_mut(site_id) { + site.nearby_sites_by_size = nearest_by_size; + } + } } fn on_death(ctx: EventCtx) { diff --git a/server/src/events/entity_manipulation.rs b/server/src/events/entity_manipulation.rs index cde5f31602..9e39fa1198 100644 --- a/server/src/events/entity_manipulation.rs +++ b/server/src/events/entity_manipulation.rs @@ -513,12 +513,11 @@ pub fn handle_destroy(server: &mut Server, entity: EcsEntity, last_change: Healt inventory.damage_items(&ability_map, &msm); } - if should_delete { - if let Some(actor) = state.entity_as_actor(entity) { - state - .ecs() - .write_resource::() - .hook_rtsim_actor_death( + if let Some(actor) = state.entity_as_actor(entity) { + state + .ecs() + .write_resource::() + .hook_rtsim_actor_death( &state.ecs().read_resource::>(), state .ecs() @@ -540,8 +539,9 @@ pub fn handle_destroy(server: &mut Server, entity: EcsEntity, last_change: Healt ) .and_then(|killer| state.entity_as_actor(killer)), ); - } + } + if should_delete { if let Err(e) = state.delete_entity_recorded(entity) { error!(?e, ?entity, "Failed to delete destroyed entity"); }