veloren_rtsim/rule/
simulate_npcs.rs

1use crate::{
2    RtState, Rule, RuleError,
3    data::{Npc, Sentiment, npc::SimulationMode},
4    event::{EventCtx, OnDeath, OnHealthChange, OnHelped, OnMountVolume, OnTick},
5};
6use common::{
7    comp::{self, Body},
8    mounting::{Volume, VolumePos},
9    rtsim::{Actor, NpcAction, NpcActivity, Personality},
10    terrain::{CoordinateConversions, TerrainChunkSize},
11    vol::RectVolSize,
12};
13use rand::prelude::*;
14use rand_chacha::ChaChaRng;
15use slotmap::SecondaryMap;
16use tracing::{error, warn};
17use vek::{Clamp, Vec2};
18use world::{CONFIG, site::SiteKind};
19
20pub struct SimulateNpcs;
21
22impl Rule for SimulateNpcs {
23    fn start(rtstate: &mut RtState) -> Result<Self, RuleError> {
24        rtstate.bind(on_death);
25        rtstate.bind(on_helped);
26        rtstate.bind(on_health_changed);
27        rtstate.bind(on_mount_volume);
28        rtstate.bind(on_tick);
29
30        Ok(Self)
31    }
32}
33
34fn on_mount_volume(ctx: EventCtx<SimulateNpcs, OnMountVolume>) {
35    let data = &mut *ctx.state.data_mut();
36
37    // TODO: Add actor to riders.
38    if let VolumePos {
39        kind: Volume::Entity(vehicle),
40        ..
41    } = ctx.event.pos
42        && let Some(link) = data.npcs.mounts.get_steerer_link(vehicle)
43        && let Actor::Npc(driver) = link.rider
44        && let Some(driver) = data.npcs.get_mut(driver)
45    {
46        driver.controller.actions.push(NpcAction::Say(
47            Some(ctx.event.actor),
48            comp::Content::localized("npc-speech-welcome-aboard"),
49        ))
50    }
51}
52
53fn on_death(ctx: EventCtx<SimulateNpcs, OnDeath>) {
54    let data = &mut *ctx.state.data_mut();
55
56    if let Actor::Npc(npc_id) = ctx.event.actor {
57        if let Some(npc) = data.npcs.get(npc_id) {
58            let mut rng = ChaChaRng::from_seed(thread_rng().gen::<[u8; 32]>());
59
60            // Respawn dead NPCs
61            let details = match npc.body {
62                Body::Humanoid(_) => {
63                    if let Some((site_id, site)) = data
64                        .sites
65                        .iter()
66                        .filter(|(id, site)| {
67                            // Don't respawn in the same town
68                            Some(*id) != npc.home
69                                && site.world_site.is_some_and(|s| {
70                                    matches!(
71                                        ctx.index.sites.get(s).kind,
72                                        SiteKind::Refactor(_)
73                                            | SiteKind::CliffTown(_)
74                                            | SiteKind::SavannahTown(_)
75                                            | SiteKind::CoastalTown(_)
76                                            | SiteKind::DesertCity(_)
77                                    )
78                                })
79                        })
80                        .min_by_key(|(_, site)| site.population.len())
81                    {
82                        let rand_wpos = |rng: &mut ChaChaRng| {
83                            let wpos2d = site.wpos.map(|e| e + rng.gen_range(-10..10));
84                            wpos2d
85                                .map(|e| e as f32 + 0.5)
86                                .with_z(ctx.world.sim().get_alt_approx(wpos2d).unwrap_or(0.0))
87                        };
88                        let random_humanoid = |rng: &mut ChaChaRng| {
89                            let species = comp::humanoid::ALL_SPECIES.choose(&mut *rng).unwrap();
90                            Body::Humanoid(comp::humanoid::Body::random_with(rng, species))
91                        };
92                        let npc_id = data.spawn_npc(
93                            Npc::new(
94                                rng.gen(),
95                                rand_wpos(&mut rng),
96                                random_humanoid(&mut rng),
97                                npc.role.clone(),
98                            )
99                            .with_personality(Personality::random(&mut rng))
100                            .with_home(site_id)
101                            .with_faction(npc.faction),
102                        );
103                        Some((npc_id, Some(site_id)))
104                    } else {
105                        warn!("No site found for respawning humanoid");
106                        None
107                    }
108                },
109                body => {
110                    let home = npc.home.and_then(|_| {
111                        data.sites
112                            .iter()
113                            .filter(|(id, site)| {
114                                Some(*id) != npc.home
115                                    && site.world_site.is_some_and(|s| {
116                                        matches!(
117                                            ctx.index.sites.get(s).kind,
118                                            SiteKind::Terracotta(_)
119                                                | SiteKind::Haniwa(_)
120                                                | SiteKind::Myrmidon(_)
121                                                | SiteKind::Adlet(_)
122                                                | SiteKind::DwarvenMine(_)
123                                                | SiteKind::ChapelSite(_)
124                                                | SiteKind::Cultist(_)
125                                                | SiteKind::Gnarling(_)
126                                                | SiteKind::Sahagin(_)
127                                                | SiteKind::VampireCastle(_),
128                                        )
129                                    })
130                            })
131                            .min_by_key(|(_, site)| site.population.len())
132                    });
133
134                    let wpos = if let Some((_, home)) = home {
135                        let wpos2d = home.wpos.map(|e| e + rng.gen_range(-10..10));
136                        wpos2d
137                            .map(|e| e as f32 + 0.5)
138                            .with_z(ctx.world.sim().get_alt_approx(wpos2d).unwrap_or(0.0))
139                    } else {
140                        let is_gigas = matches!(body, Body::BipedLarge(body) if body.species == comp::body::biped_large::Species::Gigasfrost);
141
142                        let pos = (0..(if is_gigas {
143                            /* More attempts for gigas */
144                            100
145                        } else {
146                            10
147                        }))
148                            .map(|_| {
149                                ctx.world
150                                    .sim()
151                                    .get_size()
152                                    .map(|sz| rng.gen_range(0..sz as i32))
153                            })
154                            .find(|pos| {
155                                ctx.world.sim().get(*pos).is_some_and(|c| {
156                                    !c.is_underwater() && (!is_gigas || c.temp < CONFIG.snow_temp)
157                                })
158                            })
159                            .unwrap_or(ctx.world.sim().get_size().as_() / 2);
160                        let wpos2d = pos.cpos_to_wpos_center();
161                        wpos2d
162                            .map(|e| e as f32 + 0.5)
163                            .with_z(ctx.world.sim().get_alt_approx(wpos2d).unwrap_or(0.0))
164                    };
165
166                    let home = home.map(|(site_id, _)| site_id);
167
168                    let npc_id = data.npcs.create_npc(
169                        Npc::new(rng.gen(), wpos, body, npc.role.clone()).with_home(home),
170                    );
171                    Some((npc_id, home))
172                },
173            };
174
175            // Add the NPC to their home site
176            if let Some((npc_id, Some(home_site))) = details {
177                if let Some(home) = data.sites.get_mut(home_site) {
178                    home.population.insert(npc_id);
179                }
180            }
181        } else {
182            error!("Trying to respawn non-existent NPC");
183        }
184    }
185}
186
187fn on_health_changed(ctx: EventCtx<SimulateNpcs, OnHealthChange>) {
188    let data = &mut *ctx.state.data_mut();
189
190    if let Some(cause) = ctx.event.cause
191        && let Actor::Npc(npc) = ctx.event.actor
192        && let Some(npc) = data.npcs.get_mut(npc)
193    {
194        if ctx.event.change < 0.0 {
195            npc.sentiments
196                .toward_mut(cause)
197                .change_by(-0.1, Sentiment::ENEMY);
198        } else if ctx.event.change > 0.0 {
199            npc.sentiments
200                .toward_mut(cause)
201                .change_by(0.05, Sentiment::POSITIVE);
202        }
203    }
204}
205
206fn on_helped(ctx: EventCtx<SimulateNpcs, OnHelped>) {
207    let data = &mut *ctx.state.data_mut();
208
209    if let Some(saver) = ctx.event.saver
210        && let Actor::Npc(npc) = ctx.event.actor
211        && let Some(npc) = data.npcs.get_mut(npc)
212    {
213        npc.controller.actions.push(NpcAction::Say(
214            Some(ctx.event.actor),
215            comp::Content::localized("npc-speech-thank_you"),
216        ));
217        npc.sentiments
218            .toward_mut(saver)
219            .change_by(0.3, Sentiment::FRIEND);
220    }
221}
222
223fn on_tick(ctx: EventCtx<SimulateNpcs, OnTick>) {
224    let data = &mut *ctx.state.data_mut();
225
226    // Maintain links
227    let ids = data.npcs.mounts.ids().collect::<Vec<_>>();
228    let mut mount_activity = SecondaryMap::new();
229    for link_id in ids {
230        if let Some(link) = data.npcs.mounts.get(link_id) {
231            if let Some(mount) = data
232                .npcs
233                .npcs
234                .get(link.mount)
235                .filter(|mount| !mount.is_dead())
236            {
237                let wpos = mount.wpos;
238                if let Actor::Npc(rider) = link.rider {
239                    if let Some(rider) = data
240                        .npcs
241                        .npcs
242                        .get_mut(rider)
243                        .filter(|rider| !rider.is_dead())
244                    {
245                        rider.wpos = wpos;
246                        mount_activity.insert(link.mount, rider.controller.activity);
247                    } else {
248                        data.npcs.mounts.dismount(link.rider)
249                    }
250                }
251            } else {
252                data.npcs.mounts.remove_mount(link.mount)
253            }
254        }
255    }
256
257    for (npc_id, npc) in data.npcs.npcs.iter_mut().filter(|(_, npc)| !npc.is_dead()) {
258        if matches!(npc.mode, SimulationMode::Simulated) {
259            // Consume NPC actions
260            for action in std::mem::take(&mut npc.controller.actions) {
261                match action {
262                    NpcAction::Say(_, _) => {}, // Currently, just swallow interactions
263                    NpcAction::Attack(_) => {}, // TODO: Implement simulated combat
264                    NpcAction::Dialogue(_, _) => {},
265                }
266            }
267
268            let activity = if data.npcs.mounts.get_mount_link(npc_id).is_some() {
269                // We are riding, nothing to do.
270                continue;
271            } else if let Some(activity) = mount_activity.get(npc_id) {
272                *activity
273            } else {
274                npc.controller.activity
275            };
276
277            match activity {
278                // Move NPCs if they have a target destination
279                Some(NpcActivity::Goto(target, speed_factor)) => {
280                    let diff = target - npc.wpos;
281                    let dist2 = diff.magnitude_squared();
282
283                    if dist2 > 0.5f32.powi(2) {
284                        let offset = diff
285                            * (npc.body.max_speed_approx() * speed_factor * ctx.event.dt
286                                / dist2.sqrt())
287                            .min(1.0);
288                        let new_wpos = npc.wpos + offset;
289
290                        let is_valid = match npc.body {
291                            // Don't move water bound bodies outside of water.
292                            Body::Ship(comp::ship::Body::SailBoat | comp::ship::Body::Galleon)
293                            | Body::FishMedium(_)
294                            | Body::FishSmall(_) => {
295                                let chunk_pos = new_wpos.xy().as_().wpos_to_cpos();
296                                ctx.world
297                                    .sim()
298                                    .get(chunk_pos)
299                                    .is_none_or(|f| f.river.river_kind.is_some())
300                            },
301                            Body::Ship(comp::ship::Body::DefaultAirship) => false,
302                            _ => true,
303                        };
304
305                        if is_valid {
306                            npc.wpos = new_wpos;
307                        }
308
309                        npc.dir = (target.xy() - npc.wpos.xy())
310                            .try_normalized()
311                            .unwrap_or(npc.dir);
312                    }
313                },
314                // Move Flying NPCs like airships if they have a target destination
315                Some(NpcActivity::GotoFlying(target, speed_factor, _, dir, _)) => {
316                    let diff = target - npc.wpos;
317                    let dist2 = diff.magnitude_squared();
318
319                    if dist2 > 0.5f32.powi(2) {
320                        match npc.body {
321                            Body::Ship(comp::ship::Body::DefaultAirship) => {
322                                // Don't limit airship movement to 1.0 per axis
323                                // SimulationMode::Simulated treats the Npc dimensions differently
324                                // somehow from when the Npc is
325                                // loaded. The calculation below results in a position
326                                // that is roughly the offset from the ship centerline to the
327                                // docking position and is also off
328                                // by the offset from the ship fore/aft centerline to the docking
329                                // position. The result is that if the player spawns in at a dock
330                                // where an airship is docked, the
331                                // airship will bounce around while seeking the docking position
332                                // in loaded mode.
333                                let offset = (diff - npc.body.mount_offset())
334                                    * (npc.body.max_speed_approx() * speed_factor * ctx.event.dt
335                                        / dist2.sqrt());
336                                npc.wpos += offset;
337                            },
338                            _ => {
339                                let offset = diff
340                                    * (npc.body.max_speed_approx() * speed_factor * ctx.event.dt
341                                        / dist2.sqrt())
342                                    .min(1.0);
343                                let new_wpos = npc.wpos + offset;
344
345                                let is_valid = match npc.body {
346                                    // Don't move water bound bodies outside of water.
347                                    Body::Ship(
348                                        comp::ship::Body::SailBoat | comp::ship::Body::Galleon,
349                                    )
350                                    | Body::FishMedium(_)
351                                    | Body::FishSmall(_) => {
352                                        let chunk_pos = new_wpos.xy().as_().wpos_to_cpos();
353                                        ctx.world
354                                            .sim()
355                                            .get(chunk_pos)
356                                            .is_none_or(|f| f.river.river_kind.is_some())
357                                    },
358                                    _ => true,
359                                };
360
361                                if is_valid {
362                                    npc.wpos = new_wpos;
363                                }
364                            },
365                        }
366
367                        if let Some(dir_override) = dir {
368                            npc.dir = dir_override.xy().try_normalized().unwrap_or(npc.dir);
369                        } else {
370                            npc.dir = (target.xy() - npc.wpos.xy())
371                                .try_normalized()
372                                .unwrap_or(npc.dir);
373                        }
374                    }
375                },
376                Some(
377                    NpcActivity::Gather(_)
378                    | NpcActivity::HuntAnimals
379                    | NpcActivity::Dance(_)
380                    | NpcActivity::Cheer(_)
381                    | NpcActivity::Sit(..)
382                    | NpcActivity::Talk(..),
383                ) => {
384                    // TODO: Maybe they should walk around randomly
385                    // when gathering resources?
386                },
387                None => {},
388            }
389
390            // Make sure NPCs remain in a valid location
391            let clamped_wpos = npc.wpos.xy().clamped(
392                Vec2::zero(),
393                (ctx.world.sim().get_size() * TerrainChunkSize::RECT_SIZE).as_(),
394            );
395            match npc.body {
396                Body::Ship(comp::ship::Body::DefaultAirship | comp::ship::Body::AirBalloon) => {
397                    npc.wpos = clamped_wpos.with_z(
398                        ctx.world
399                            .sim()
400                            .get_surface_alt_approx(clamped_wpos.as_())
401                            .max(npc.wpos.z),
402                    );
403                },
404                _ => {
405                    npc.wpos = clamped_wpos.with_z(
406                        ctx.world.sim().get_surface_alt_approx(clamped_wpos.as_())
407                            + npc.body.flying_height(),
408                    );
409                },
410            }
411        }
412
413        // Move home if required
414        if let Some(new_home) = npc.controller.new_home.take() {
415            // Remove the NPC from their old home population
416            if let Some(old_home) = npc.home {
417                if let Some(old_home) = data.sites.get_mut(old_home) {
418                    old_home.population.remove(&npc_id);
419                }
420            }
421            // Add the NPC to their new home population
422            if let Some(new_home) = data.sites.get_mut(new_home) {
423                new_home.population.insert(npc_id);
424            }
425            npc.home = Some(new_home);
426        }
427
428        // Set hired status if required (I'm a poet and I didn't know it)
429        if let Some(hiring) = npc.controller.hiring.take() {
430            npc.hiring = hiring;
431        }
432    }
433}