veloren_rtsim/rule/npc_ai/
mod.rs

1//! This rule is by far the most significant rule in rtsim to date and governs
2//! the behaviour of rtsim NPCs. It uses a novel combinator-based API to express
3//! long-running NPC actions in a manner that's halfway between [async/coroutine programming](https://en.wikipedia.org/wiki/Coroutine) and traditional
4//! [AI decision trees](https://en.wikipedia.org/wiki/Decision_tree).
5//!
6//! It may feel unintuitive when you first work with it, but trust us:
7//! expressing your AI behaviour in this way brings radical advantages and will
8//! simplify your code and make debugging exponentially easier.
9//!
10//! The fundamental abstraction is that of [`Action`]s. [`Action`]s, somewhat
11//! like [`core::future::Future`], represent a long-running behaviour performed
12//! by an NPC. See [`Action`] for a deeper explanation of actions and the
13//! methods that can be used to combine them together.
14//!
15//! NPC actions act upon the NPC's [`crate::data::npc::Controller`]. This type
16//! represent the immediate behavioural intentions of the NPC during simulation,
17//! such as by specifying a location to walk to, an action to perform, speech to
18//! say, or some persistent state to change (like the NPC's home site).
19//!
20//! After brain simulation has occurred, the resulting controller state is
21//! passed to either rtsim's internal NPC simulation rule
22//! ([`crate::rule::simulate_npcs`]) or, if the chunk the NPC is loaded, are
23//! passed to the Veloren server's agent system which attempts to act in
24//! accordance with it.
25
26mod airship_ai;
27#[cfg(feature = "airship_log")]
28mod airship_logger;
29pub mod dialogue;
30pub mod movement;
31pub mod util;
32
33use std::{collections::VecDeque, hash::BuildHasherDefault, sync::Arc};
34
35use crate::{
36    RtState, Rule, RuleError,
37    ai::{
38        Action, NpcCtx, State, casual, choose, finish, important, just, now,
39        predicate::{Chance, EveryRange, Predicate, every_range, timeout},
40        seq, until,
41    },
42    data::{
43        ReportKind, Sentiment, Sites,
44        npc::{Brain, DialogueSession, PathData, SimulationMode},
45    },
46    event::OnTick,
47};
48use common::{
49    assets::AssetExt,
50    astar::{Astar, PathResult},
51    comp::{
52        self, Content, bird_large,
53        compass::{Direction, Distance},
54        item::ItemDef,
55    },
56    match_some,
57    path::Path,
58    rtsim::{
59        Actor, ChunkResource, DialogueKind, NpcInput, PersonalityTrait, Profession, Response, Role,
60        SiteId,
61    },
62    spiral::Spiral2d,
63    store::Id,
64    terrain::{CoordinateConversions, TerrainChunkSize, sprite},
65    time::DayPeriod,
66    util::Dir,
67};
68use core::ops::ControlFlow;
69use fxhash::FxHasher64;
70use itertools::{Either, Itertools};
71use rand::prelude::*;
72use rand_chacha::ChaChaRng;
73use rayon::iter::{IntoParallelRefMutIterator, ParallelIterator};
74use vek::*;
75use world::{
76    IndexRef, World,
77    civ::{self, Track},
78    site::{
79        self, PlotKind, Site as WorldSite, SiteKind, TileKind,
80        plot::{PlotKindMeta, tavern},
81    },
82    util::NEIGHBORS,
83};
84
85use self::{
86    movement::{
87        follow_actor, goto, goto_2d, goto_2d_flying, goto_actor, travel_to_point, travel_to_site,
88    },
89    util::do_dialogue,
90};
91
92/// How many ticks should pass between running NPC AI.
93/// Note that this only applies to simulated NPCs: loaded NPCs have their AI
94/// code run every tick. This means that AI code should be broadly
95/// DT-independent.
96const SIMULATED_TICK_SKIP: u64 = 10;
97
98pub struct NpcAi;
99
100#[derive(Clone)]
101struct DefaultState {
102    socialize_timer: EveryRange,
103    move_home_timer: Chance<EveryRange>,
104}
105
106impl Rule for NpcAi {
107    fn start(rtstate: &mut RtState) -> Result<Self, RuleError> {
108        // Keep track of the last `SIMULATED_TICK_SKIP` ticks, to know the deltatime
109        // since the last tick we ran the npc.
110        let mut last_ticks: VecDeque<_> = [1.0 / 30.0; SIMULATED_TICK_SKIP as usize]
111            .into_iter()
112            .collect();
113
114        rtstate.bind::<Self, OnTick>(move |ctx| {
115            last_ticks.push_front(ctx.event.dt);
116            if last_ticks.len() >= SIMULATED_TICK_SKIP as usize {
117                last_ticks.pop_back();
118            }
119            // Temporarily take the brains of NPCs out of their heads to appease the borrow
120            // checker
121            let mut npc_data = {
122                let mut data = ctx.state.data_mut();
123                data.npcs
124                    .iter_mut()
125                    // Don't run AI for dead NPCs
126                    .filter(|(_, npc)| !npc.is_dead() && !matches!(npc.role, Role::Vehicle))
127                    // Don't run AI for simulated NPCs every tick
128                    .filter(|(_, npc)| matches!(npc.mode, SimulationMode::Loaded) || (npc.seed as u64 + ctx.event.tick) % SIMULATED_TICK_SKIP == 0)
129                    .map(|(npc_id, npc)| {
130                        let controller = std::mem::take(&mut npc.controller);
131                        let inbox = std::mem::take(&mut npc.inbox);
132                        let sentiments = std::mem::take(&mut npc.sentiments);
133                        let known_reports = std::mem::take(&mut npc.known_reports);
134                        let brain = npc.brain.take().unwrap_or_else(|| Brain {
135                            action: Box::new(think().repeat().with_state(DefaultState {
136                                socialize_timer: every_range(15.0..30.0),
137                                move_home_timer: every_range(400.0..2000.0).chance(0.5),
138                            })),
139                        });
140                        let npc_dialogue = std::mem::take(&mut npc.npc_dialogue);
141                        (npc_id, controller, inbox, sentiments, known_reports, brain, npc_dialogue)
142                    })
143                    .collect::<Vec<_>>()
144            };
145
146            // The sum of the last `SIMULATED_TICK_SKIP` tick deltatimes is the deltatime since
147            // simulated npcs ran this tick had their ai ran.
148            let simulated_dt = last_ticks.iter().sum::<f32>();
149
150            // Do a little thinking
151            {
152                let data = &*ctx.state.data();
153
154                npc_data
155                    .par_iter_mut()
156                    .for_each(|(npc_id, controller, inbox, sentiments, known_reports, brain, npc_dialogue)| {
157                        let npc = &data.npcs[*npc_id];
158
159                        controller.reset();
160
161                        brain.action.tick(&mut NpcCtx {
162                            state: ctx.state,
163                            world: ctx.world,
164                            index: ctx.index,
165                            time_of_day: ctx.event.time_of_day,
166                            time: ctx.event.time,
167                            npc,
168                            npc_id: *npc_id,
169                            controller,
170                            npc_dialogue,
171                            inbox,
172                            known_reports,
173                            sentiments,
174                            dt: if matches!(npc.mode, SimulationMode::Loaded) {
175                                ctx.event.dt
176                            } else {
177                                simulated_dt
178                            },
179                            rng: ChaChaRng::from_seed(thread_rng().gen::<[u8; 32]>()),
180                            system_data: &*ctx.system_data,
181                        }, &mut ());
182
183                        // If an input wasn't processed by the brain, we no longer have a use for it
184                        inbox.clear();
185                    });
186            }
187
188            // Reinsert NPC brains
189            let mut data = ctx.state.data_mut();
190            let mut to_update = Vec::with_capacity(npc_data.len());
191            for (npc_id, controller, inbox, sentiments, known_reports, brain, npc_dialogue) in npc_data {
192                to_update.push(npc_id);
193                data.npcs[npc_id].controller = controller;
194                data.npcs[npc_id].brain = Some(brain);
195                data.npcs[npc_id].inbox = inbox;
196                data.npcs[npc_id].sentiments = sentiments;
197                data.npcs[npc_id].known_reports = known_reports;
198                data.npcs[npc_id].npc_dialogue = npc_dialogue;
199            }
200
201            for npc_id in to_update {
202                let v = std::mem::take(&mut data.npcs[npc_id].controller.npc_actions);
203                for (target, action) in v {
204                    if let Some(npc) = data.npcs.get_mut(target) {
205                        npc.npc_dialogue.push_back((npc_id, action));
206                    }
207                }
208            }
209        });
210
211        Ok(Self)
212    }
213}
214
215fn idle<S: State>() -> impl Action<S> + Clone {
216    just(|ctx, _| ctx.controller.do_idle()).debug(|| "idle")
217}
218
219fn talk_to<S: State>(tgt: Actor) -> impl Action<S> {
220    now(move |ctx, _| {
221        if ctx.sentiments.toward(tgt).is(Sentiment::ENEMY) {
222            just(move |ctx, _| {
223                ctx.controller
224                    .say(tgt, Content::localized("npc-speech-reject_rival"))
225            })
226            .boxed()
227        } else if matches!(tgt, Actor::Character(_)) {
228            do_dialogue(tgt, move |session| dialogue::general(tgt, session)).boxed()
229        } else {
230            smalltalk_to(tgt).boxed()
231        }
232    })
233}
234
235fn tell_site_content(ctx: &NpcCtx, site: SiteId) -> Option<Content> {
236    if let Some(world_site) = ctx.state.data().sites.get(site)
237        && let Some(site_name) = util::site_name(ctx, site)
238    {
239        Some(Content::localized_with_args("npc-speech-tell_site", [
240            ("site", Content::Plain(site_name)),
241            (
242                "dir",
243                Direction::from_dir(world_site.wpos.as_() - ctx.npc.wpos.xy()).localize_npc(),
244            ),
245            (
246                "dist",
247                Distance::from_length(world_site.wpos.as_().distance(ctx.npc.wpos.xy()) as i32)
248                    .localize_npc(),
249            ),
250        ]))
251    } else {
252        None
253    }
254}
255
256fn smalltalk_to<S: State>(tgt: Actor) -> impl Action<S> {
257    now(move |ctx, _| {
258        if matches!(tgt, Actor::Npc(_)) && ctx.rng.gen_bool(0.2) {
259            // Cut off the conversation sometimes to avoid infinite conversations (but only
260            // if the target is an NPC!) TODO: Don't special case this, have
261            // some sort of 'bored of conversation' system
262            idle().boxed()
263        } else {
264            // Mention nearby sites
265            let comment = if ctx.rng.gen_bool(0.3)
266                && let Some(current_site) = ctx.npc.current_site
267                && let Some(current_site) = ctx.state.data().sites.get(current_site)
268                && let Some(mention_site) = current_site.nearby_sites_by_size.choose(&mut ctx.rng)
269                && let Some(content) = tell_site_content(ctx, *mention_site)
270            {
271                content
272            // Mention current site
273            } else if ctx.rng.gen_bool(0.3)
274                && let Some(current_site) = ctx.npc.current_site
275                && let Some(current_site_name) = util::site_name(ctx, current_site)
276            {
277                Content::localized_with_args("npc-speech-site", [(
278                    "site",
279                    Content::Plain(current_site_name),
280                )])
281
282            // Mention nearby monsters
283            } else if ctx.rng.gen_bool(0.3)
284                && let Some(monster) = ctx
285                    .state
286                    .data()
287                    .npcs
288                    .values()
289                    .filter(|other| matches!(&other.role, Role::Monster))
290                    .min_by_key(|other| other.wpos.xy().distance(ctx.npc.wpos.xy()) as i32)
291            {
292                Content::localized_with_args("npc-speech-tell_monster", [
293                    ("body", monster.body.localize_npc()),
294                    (
295                        "dir",
296                        Direction::from_dir(monster.wpos.xy() - ctx.npc.wpos.xy()).localize_npc(),
297                    ),
298                    (
299                        "dist",
300                        Distance::from_length(monster.wpos.xy().distance(ctx.npc.wpos.xy()) as i32)
301                            .localize_npc(),
302                    ),
303                ])
304            // Specific night dialog
305            } else if ctx.rng.gen_bool(0.6) && DayPeriod::from(ctx.time_of_day.0).is_dark() {
306                Content::localized("npc-speech-night")
307            } else if ctx.rng.gen_bool(0.3)
308                && let Some(profession_comment) = match_some!(ctx.npc.profession(),
309                    Some(Profession::Pirate(_)) => Content::localized("npc-speech-pirate"),
310                )
311            {
312                profession_comment
313            } else {
314                ctx.npc.personality.get_generic_comment(&mut ctx.rng)
315            };
316            // TODO: Don't special-case players
317            let wait = if matches!(tgt, Actor::Character(_)) {
318                0.0
319            } else {
320                1.5
321            };
322            idle()
323                .repeat()
324                .stop_if(timeout(wait))
325                .then(just(move |ctx, _| ctx.controller.say(tgt, comment.clone())))
326                .boxed()
327        }
328    })
329}
330
331fn socialize() -> impl Action<EveryRange> {
332    now(move |ctx, socialize: &mut EveryRange| {
333        // Skip most socialising actions if we're not loaded
334        if matches!(ctx.npc.mode, SimulationMode::Loaded)
335            && socialize.should(ctx)
336            && !ctx.npc.personality.is(PersonalityTrait::Introverted)
337        {
338            // Sometimes dance
339            if ctx.rng.gen_bool(0.15) {
340                return just(|ctx, _| ctx.controller.do_dance(None))
341                    .repeat()
342                    .stop_if(timeout(6.0))
343                    .debug(|| "dancing")
344                    .map(|_, _| ())
345                    .l()
346                    .l();
347            // Talk to nearby NPCs
348            } else if let Some(other) = ctx
349                .state
350                .data()
351                .npcs
352                .nearby(Some(ctx.npc_id), ctx.npc.wpos, 8.0)
353                .choose(&mut ctx.rng)
354            {
355                return smalltalk_to(other)
356                    // After talking, wait for a while
357                    .then(idle().repeat().stop_if(timeout(4.0)))
358                    .map(|_, _| ())
359                    .r().l();
360            }
361        }
362        idle().r()
363    })
364}
365
366fn pirate(is_leader: bool) -> impl Action<DefaultState> {
367    choose(move |ctx, _| {
368        let data = ctx.state.data();
369        if is_leader
370            && let Some(home) = ctx.npc.home
371            && ctx.npc.current_site == Some(home)
372            && let Some(site) = data.sites.get(home)
373            && let Some(faction) = ctx.npc.faction
374            // Approx. once an hour.
375            && ctx.chance(1.0 / 3600.0)
376            && let Some(site_to_raid) = site
377                .nearby_sites_by_size
378                .iter()
379                .filter(|site| {
380                    data.sites.get(**site).is_some_and(|site| {
381                        // Don't go further than 10km
382                        site.wpos.as_::<f32>().distance_squared(ctx.npc.wpos.xy())
383                            < 10000.0f32.powi(2)
384                    })
385                })
386                .choose(&mut ctx.rng)
387                .copied()
388            && site
389                .population
390                .iter()
391                .filter(|npc_id| {
392                    data.npcs.get(**npc_id).is_some_and(|npc| {
393                        !npc.is_dead()
394                            && npc.current_site == Some(home)
395                            && npc.faction == Some(faction)
396                            && npc.hiring.is_none()
397                            && matches!(npc.role, Role::Civilised(Some(Profession::Pirate(false))))
398                    })
399                })
400                .count()
401                > 3
402        {
403            important(
404                now(move |ctx, _| {
405                    let data = ctx.state.data();
406                    if let Some(site) = data.sites.get(home)
407                        && let Some(npc) = site
408                            .population
409                            .iter()
410                            .filter(|npc_id| {
411                                data.npcs.get(**npc_id).is_some_and(|npc| {
412                                    !npc.is_dead()
413                                        && npc.current_site == Some(home)
414                                        && npc.faction == Some(faction)
415                                        && npc.hiring.is_none()
416                                        && matches!(
417                                            npc.role,
418                                            Role::Civilised(Some(Profession::Pirate(false)))
419                                        )
420                                })
421                            })
422                            .choose(&mut ctx.rng)
423                    {
424                        let npc = *npc;
425                        follow_actor(Actor::Npc(npc), 5.0)
426                            .stop_if(move |ctx: &mut NpcCtx| {
427                                let data = ctx.state.data();
428                                let Some(follow_npc) = data.npcs.get(npc) else {
429                                    return true;
430                                };
431                                ctx.npc.wpos.distance_squared(follow_npc.wpos) < 6.0f32.powi(2)
432                            })
433                            .then(just(move |ctx, _| {
434                                let leader = ctx.npc_id;
435                                ctx.controller.npc_dialogue(
436                                    npc,
437                                    Content::localized_with_args("npc-speech-pirate_raid", [(
438                                        "site",
439                                        util::site_name(ctx, site_to_raid).unwrap_or_default(),
440                                    )]),
441                                    idle().repeat().stop_if(timeout(2.0)).then(just(
442                                        move |ctx, _| {
443                                            let target = Actor::Npc(leader);
444                                            ctx.controller.say(
445                                                target,
446                                                Content::localized("npc-response-accept_hire"),
447                                            );
448                                            ctx.controller.hiring = Some(Some((
449                                                target,
450                                                common::resources::Time(f64::INFINITY),
451                                            )));
452                                        },
453                                    )),
454                                );
455                            }))
456                            .debug(|| "inviting raid participant")
457                            .l()
458                    } else {
459                        idle().r()
460                    }
461                })
462                .repeat()
463                .stop_if(move |ctx: &mut NpcCtx| {
464                    let data = ctx.state.data();
465                    if let Some(site) = data.sites.get(home) {
466                        let hired_count = site
467                            .population
468                            .iter()
469                            .filter(|npc_id| {
470                                data.npcs.get(**npc_id).is_some_and(|npc| {
471                                    !npc.is_dead()
472                                        && npc
473                                            .hiring
474                                            .is_some_and(|(a, _)| a == Actor::Npc(ctx.npc_id))
475                                })
476                            })
477                            .count();
478
479                        let unhired_count = site
480                            .population
481                            .iter()
482                            .filter(|npc_id| {
483                                data.npcs.get(**npc_id).is_some_and(|npc| {
484                                    !npc.is_dead()
485                                        && npc.current_site == Some(home)
486                                        && npc.faction == Some(faction)
487                                        && npc.hiring.is_none()
488                                        && matches!(
489                                            npc.role,
490                                            Role::Civilised(Some(Profession::Pirate(false)))
491                                        )
492                                })
493                            })
494                            .count();
495
496                        if unhired_count == 0 {
497                            return true;
498                        }
499
500                        let chance = match hired_count {
501                            0..=3 => 0.0,
502                            _ => (hired_count - 3) as f64 * 1.0 / 1200.0,
503                        } / unhired_count as f64;
504
505                        ctx.chance(chance)
506                    } else {
507                        true
508                    }
509                })
510                .debug(|| "preparing for raid")
511                .then(travel_to_site(site_to_raid, 0.8).debug(|| "travel to raid site"))
512                .then(
513                    // TODO: Replace this with raiding stuff
514                    villager(site_to_raid)
515                        .stop_if(timeout(ctx.rng.gen_range(60.0..120.0)))
516                        .debug(|| "raiding"),
517                )
518                .then(travel_to_site(home, 0.6).debug(|| "traveling home from raid"))
519                // End hiring of hirlings
520                .then(just(|ctx, _| {
521                    let data = ctx.state.data();
522                    if let Some(site) = ctx.npc.home
523                        && let Some(site) = data.sites.get(site)
524                    {
525                        for &npc_id in site.population.iter() {
526                            if let Some(npc) = data.npcs.get(npc_id)
527                                && npc
528                                    .hiring
529                                    .is_some_and(|(actor, _)| actor == Actor::Npc(ctx.npc_id))
530                            {
531                                ctx.controller
532                                    .npc_action(npc_id, just(|ctx, _| ctx.controller.end_hiring()));
533                            }
534                        }
535                    }
536                }))
537                .map(|_, _| ()),
538            )
539        } else if let Some((leader, _)) = ctx.npc.hiring {
540            important(
541                follow_actor(leader, 5.0)
542                    .stop_if(move |ctx: &mut NpcCtx| {
543                        ctx.npc.hiring.is_none_or(move |(actor, _)| actor != leader)
544                    })
545                    .map(|_, _| ()),
546            )
547        } else if let Some(home) = ctx.npc.home {
548            casual(now(move |ctx, _| {
549                let data = ctx.state.data();
550                let pos = data.sites.get(home).and_then(|site| {
551                    let ws = ctx.index.sites.get(site.world_site?);
552                    let plot = ws
553                        .filter_plots(|plot| matches!(plot.kind(), PlotKind::PirateHideout(_)))
554                        .choose(&mut ctx.rng)?;
555                    let tile = plot.tiles().choose(&mut ctx.rng)?;
556                    let wpos = ws.tile_center_wpos(tile);
557
558                    Some(wpos.as_())
559                });
560                // Choose a plaza in the site we're visiting to walk to
561                if let Some(new_pos) = pos {
562                    // Walk to a point in the hideout...
563                    Either::Left(travel_to_point(new_pos, 0.5)
564                        .debug(|| "walk to pirate hideout"))
565                } else {
566                    // If there is no pirate hideout, unset the home.
567                    ctx.controller.set_new_home(None);
568                    Either::Right(finish())
569                }
570                    // ...then socialize for some time before moving on
571                    .then(socialize()
572                        .repeat()
573                        .map_state(|state: &mut DefaultState| &mut state.socialize_timer)
574                        .stop_if(timeout(ctx.rng.gen_range(30.0..90.0)))
575                        .debug(|| "wait at pirate hideout"))
576                    .map(|_, _| ())
577            }))
578        } else {
579            // Find new home
580            important(just(move |ctx, _| {
581                let data = ctx.state.data();
582                if let Some((site, _)) =
583                    data.sites
584                        .iter()
585                        .filter(|(_, site)| {
586                            site.world_site.is_some_and(|ws| {
587                                ctx.index.sites.get(ws).any_plot(|plot| {
588                                    matches!(plot.kind(), PlotKind::PirateHideout(_))
589                                })
590                            })
591                        })
592                        .min_by_key(|(_, site)| {
593                            site.wpos
594                                .as_::<i64>()
595                                .distance_squared(ctx.npc.wpos.xy().as_())
596                        })
597                {
598                    ctx.controller.set_new_home(site);
599                }
600            }))
601        }
602    })
603}
604
605fn adventure() -> impl Action<DefaultState> {
606    choose(|ctx, _| {
607        // Choose a random site that's fairly close by
608        if let Some(tgt_site) = ctx
609            .state
610            .data()
611            .sites
612            .iter()
613            .filter(|(site_id, site)| {
614                site.world_site.is_some_and(|ws| ctx.index.sites.get(ws).any_plot(|plot| matches!(plot.meta(), Some(PlotKindMeta::Workshop { .. })))) && (ctx.npc.current_site != Some(*site_id))
615                    && ctx.rng.gen_bool(0.25)
616            })
617            .min_by_key(|(_, site)| site.wpos.as_().distance(ctx.npc.wpos.xy()) as i32)
618            .map(|(site_id, _)| site_id)
619        {
620            let wait_time = if matches!(ctx.npc.profession(), Some(Profession::Merchant)) {
621                60.0 * 15.0
622            } else {
623                60.0 * 3.0
624            };
625            let site_name = util::site_name(ctx, tgt_site).unwrap_or_default();
626            // Travel to the site
627            important(just(move |ctx, _| ctx.controller.say(None, Content::localized_with_args("npc-speech-moving_on", [("site", site_name.clone())])))
628                          .then(travel_to_site(tgt_site, 0.6))
629                          // Stop for a few minutes
630                          .then(villager(tgt_site).repeat().stop_if(timeout(wait_time)))
631                          .map(|_, _| ())
632                          .boxed(),
633            )
634        } else {
635            casual(finish().boxed())
636        }
637    })
638    .debug(move || "adventure")
639}
640
641fn hired(tgt: Actor) -> impl Action<DefaultState> {
642    follow_actor(tgt, 5.0)
643        // Stop following if we're no longer hired
644        .stop_if(move |ctx: &mut NpcCtx| ctx.npc.hiring.is_none_or(|(a, _)| a != tgt))
645        .debug(move|| format!("hired by {tgt:?}"))
646        .interrupt_with(move |ctx, _| {
647            // End hiring for various reasons
648            if let Some((tgt, expires)) = ctx.npc.hiring {
649                // Hiring period has expired
650                if ctx.time > expires {
651                    ctx.controller.end_hiring();
652                    // If the actor exists, tell them that the hiring is over
653                    if util::actor_exists(ctx, tgt) {
654                        return Some(goto_actor(tgt, 2.0)
655                            .then(do_dialogue(tgt, |session| {
656                                session.say_statement(Content::localized("npc-dialogue-hire_expired"))
657                            }))
658                            .boxed());
659                    }
660                }
661
662                if ctx.sentiments.toward(tgt).is(Sentiment::RIVAL) {
663                    ctx.controller.end_hiring();
664                    // If the actor exists, tell them that the hiring is over
665                    if util::actor_exists(ctx, tgt) {
666                        return Some(goto_actor(tgt, 2.0)
667                            .then(do_dialogue(tgt, |session| {
668                                session.say_statement(Content::localized(
669                                    "npc-dialogue-hire_cancelled_unhappy",
670                                ))
671                            }))
672                            .boxed());
673                    }
674                }
675
676                let data = ctx.state.data();
677
678                if let Some(visiting) = ctx.npc.current_site &&
679                   let Some(visiting_site) = data.sites.get(visiting) &&
680                   let Some(visiting_ws) = visiting_site.world_site &&
681                   let Some(pos) = util::locate_actor(ctx, tgt) &&
682                   let Some(chunk) = ctx.world.sim().get_wpos(pos.xy().as_()) &&
683                   chunk.sites.contains(&visiting_ws) &&
684                   let Some((pid, tavern)) = ctx.index.sites.get(visiting_ws).plots.iter().filter_map(|(pid, plot)| match_some!(plot.kind(), PlotKind::Tavern(t) => (pid, t))).choose(&mut ctx.npc.rng(14))
685                   {
686                    let tavern_name = tavern.name.clone();
687                    return Some(just(move |ctx, _| {
688                        ctx.controller.say(
689                            tgt,
690                            Content::localized_with_args(
691                                "npc-dialogue-hire_arrive_tavern",
692                                [("tavern", Content::Plain(tavern_name.clone()))]
693                            )
694                        )
695                    })
696                    .then(
697                        go_to_tavern(visiting, pid).stop_if(move |ctx: &mut NpcCtx<'_, '_>| {
698                            ctx.npc.hiring.is_none_or(|(tgt, _)| {
699                                util::locate_actor(ctx, tgt).is_none_or(|pos|
700                                    ctx.world.sim()
701                                        .get_wpos(pos.xy().as_())
702                                        .is_none_or(|chunk|
703                                            !chunk.sites.contains(&visiting_ws)
704                                        )
705                                )
706                            })
707                        })
708                    )
709                    .map(|_, _| ())
710                    .boxed());
711                }
712            }
713
714            None
715        })
716        .map(|_, _| ())
717}
718
719fn gather_ingredients<S: State>() -> impl Action<S> {
720    just(|ctx, _| {
721        ctx.controller.do_gather(
722            &[
723                ChunkResource::Fruit,
724                ChunkResource::Mushroom,
725                ChunkResource::Plant,
726            ][..],
727        )
728    })
729    .debug(|| "gather ingredients")
730}
731
732fn hunt_animals<S: State>() -> impl Action<S> {
733    just(|ctx, _| ctx.controller.do_hunt_animals()).debug(|| "hunt_animals")
734}
735
736fn find_forest(ctx: &mut NpcCtx) -> Option<Vec2<f32>> {
737    let chunk_pos = ctx.npc.wpos.xy().as_().wpos_to_cpos();
738    Spiral2d::new()
739        .skip(ctx.rng.gen_range(1..=64))
740        .take(24)
741        .map(|rpos| chunk_pos + rpos)
742        .find(|cpos| {
743            ctx.world
744                .sim()
745                .get(*cpos)
746                .is_some_and(|c| c.tree_density > 0.75 && c.surface_veg > 0.5)
747        })
748        .map(|chunk| TerrainChunkSize::center_wpos(chunk).as_())
749}
750
751fn find_farm(ctx: &mut NpcCtx, site: SiteId) -> Option<Vec2<f32>> {
752    ctx.state.data().sites.get(site).and_then(|site| {
753        let site = ctx.index.sites.get(site.world_site?);
754        let farm = site
755            .filter_plots(|p| matches!(p.kind(), PlotKind::FarmField(_)))
756            .choose(&mut ctx.rng)?;
757
758        Some(site.tile_center_wpos(farm.root_tile()).as_())
759    })
760}
761
762fn choose_plaza(ctx: &mut NpcCtx, site: SiteId) -> Option<Vec2<f32>> {
763    ctx.state.data().sites.get(site).and_then(|site| {
764        let site = ctx.index.sites.get(site.world_site?);
765        let plaza = &site.plots[site.plazas().choose(&mut ctx.rng)?];
766        let tile = plaza
767            .tiles()
768            .choose(&mut ctx.rng)
769            .unwrap_or_else(|| plaza.root_tile());
770        Some(site.tile_center_wpos(tile).as_())
771    })
772}
773
774const WALKING_SPEED: f32 = 0.35;
775
776fn villager(visiting_site: SiteId) -> impl Action<DefaultState> {
777    choose(move |ctx, state: &mut DefaultState| {
778        // Consider moving home if the home site gets too full
779        if state.move_home_timer.should(ctx)
780            && let Some(home) = ctx.npc.home
781            && Some(home) == ctx.npc.current_site
782            && let Some(home_pop_ratio) = ctx.state.data().sites.get(home)
783                .and_then(|site| Some((site, ctx.index.sites.get(site.world_site?))))
784                .and_then(|(site, world_site)| { let houses = world_site.filter_plots(|p| matches!(p.meta(), Some(PlotKindMeta::House { .. }))).count(); if houses == 0 { return None } Some(site.population.len() as f32 / houses as f32) } )
785                // Only consider moving if the population is more than 1.5x the number of homes
786                .filter(|pop_ratio| *pop_ratio > 1.5)
787            && let Some(new_home) = ctx
788                .state
789                .data()
790                .sites
791                .iter()
792                // Don't try to move to the site that's currently our home
793                .filter(|(site_id, _)| Some(*site_id) != ctx.npc.home)
794                // Only consider towns as potential homes
795                .filter_map(|(site_id, site)| {
796                    let world_site = site.world_site.map(|ws| ctx.index.sites.get(ws))?;
797                    let house_count = world_site.filter_plots(|p| matches!(p.meta(), Some(PlotKindMeta::House { .. }))).count();
798
799                    if house_count == 0 {
800                        return None;
801                    }
802                    Some((site_id, site, house_count))
803                })
804                // Only select sites that are less densely populated than our own
805                .filter(|(_, site, houses)| (site.population.len() as f32 / *houses as f32) < home_pop_ratio)
806                // Find the closest of the candidate sites
807                .min_by_key(|(_, site, _)| site.wpos.as_().distance(ctx.npc.wpos.xy()) as i32)
808                .map(|(site_id, _, _)| site_id)
809        {
810            let site_name = util::site_name(ctx, new_home);
811            return important(just(move |ctx, _| {
812                if let Some(site_name) = &site_name {
813                    ctx.controller.say(None, Content::localized_with_args("npc-speech-migrating", [("site", site_name.clone())]))
814                }
815            })
816                .then(travel_to_site(new_home, 0.5))
817                .then(just(move |ctx, _| ctx.controller.set_new_home(new_home))));
818        }
819        let day_period = DayPeriod::from(ctx.time_of_day.0);
820        let is_weekend = ctx.time_of_day.day() as u64 % 6 == 0;
821        let is_evening = day_period == DayPeriod::Evening;
822
823        let is_free_time = is_weekend || is_evening;
824
825        let is_raining = ctx.system_data.weather_grid.is_raining(ctx.npc.wpos.xy());
826
827        // Go to a house if it's dark
828        if day_period.is_dark()
829            && !matches!(ctx.npc.profession(), Some(Profession::Guard))
830        {
831            return important(
832                now(move |ctx, _| {
833                    if let Some(house_wpos) = ctx
834                        .state
835                        .data()
836                        .sites
837                        .get(visiting_site)
838                        .and_then(|site| Some(ctx.index.sites.get(site.world_site?)))
839                        .and_then(|site| {
840                            // Find a house in the site we're visiting
841                            let house = site
842                                .plots()
843                                .filter(|p| matches!(p.kind().meta(), Some(PlotKindMeta::House { .. })))
844                                .choose(&mut ctx.rng)?;
845                            Some(site.tile_center_wpos(house.root_tile()).as_())
846                        })
847                    {
848                        just(|ctx, _| {
849                            ctx.controller
850                                .say(None, Content::localized("npc-speech-night_time"))
851                        })
852                        .then(travel_to_point(house_wpos, 0.65))
853                        .debug(|| "walk to house")
854                        .then(socialize().repeat().map_state(|state: &mut DefaultState| &mut state.socialize_timer).debug(|| "wait in house"))
855                        .stop_if(|ctx: &mut NpcCtx| DayPeriod::from(ctx.time_of_day.0).is_light())
856                        .then(just(|ctx, _| {
857                            ctx.controller
858                                .say(None, Content::localized("npc-speech-day_time"))
859                        }))
860                        .map(|_, _| ())
861                        .boxed()
862                    } else {
863                        finish().boxed()
864                    }
865                })
866                .debug(|| "find somewhere to sleep"),
867            );
868        }
869        // Go to a house if its raining
870        else if is_raining
871            && !matches!(ctx.npc.profession(), Some(Profession::Guard))
872        {
873            return important(
874                now(move |ctx, _| {
875                    if let Some(house_wpos) = ctx
876                        .state
877                        .data()
878                        .sites
879                        .get(visiting_site)
880                        .and_then(|site| Some(ctx.index.sites.get(site.world_site?)))
881                        .and_then(|site| {
882                            // Find a house in the site we're visiting
883                            let house = site
884                                .plots()
885                                .filter(|p| matches!(p.kind().meta(), Some(PlotKindMeta::House { .. })))
886                                .choose(&mut ctx.rng)?;
887                            Some(site.tile_center_wpos(house.root_tile()).as_())
888                        })
889                    {
890                        just(|ctx, _| {
891                                ctx.controller.say(None, Content::localized("npc-speech-seeking_shelter_rain"))
892                        })
893                        .then(travel_to_point(house_wpos, 0.65))
894                        .debug(|| "walk to house (rain)")
895                        .then(socialize().repeat().map_state(|state: &mut DefaultState| &mut state.socialize_timer).debug(|| "wait in house (rain)"))
896                        .stop_if(|ctx: &mut NpcCtx| {
897                                    let is_raining = ctx.system_data.weather_grid.is_raining(ctx.npc.wpos.xy());
898                                    !is_raining
899                    })
900                        .then(just(|ctx, _| {
901                                ctx.controller.say(None, Content::localized("npc-speech-rain_stopped"))
902                        }))
903                        .map(|_, _| ())
904                        .boxed()
905                        } else {
906                        finish().boxed()
907                    }
908                })
909                .debug(|| "find somewhere to wait (rain)"),
910            );
911        }
912        // Go do something fun on evenings and holidays, or on random days.
913        else if
914            // Ain't no rest for the wicked
915            !matches!(ctx.npc.profession(), Some(Profession::Guard | Profession::Chef))
916            && (matches!(day_period, DayPeriod::Evening) || is_free_time || ctx.rng.gen_bool(0.05)) {
917            let mut fun_activities = Vec::new();
918
919            if let Some(ws_id) = ctx.state.data().sites[visiting_site].world_site {
920                let ws = ctx.index.sites.get(ws_id);
921                if let Some(arena) = ws.plots().find_map(|p| match_some!(p.kind(), PlotKind::DesertCityArena(a) => a)) {
922                    let wait_time = ctx.rng.gen_range(100.0..300.0);
923                    // We don't use Z coordinates for seats because they are complicated to calculate from the Ramp procedural generation
924                    // and using goto_2d seems to work just fine. However it also means that NPC will never go seat on the stands
925                    // on the first floor of the arena. This is a compromise that was made because in the current arena procedural generation
926                    // there is also no pathways to the stands on the first floor for NPCs.
927                    let arena_center = Vec3::new(arena.center.x, arena.center.y, arena.base).as_::<f32>();
928                    let stand_dist = arena.stand_dist as f32;
929                    let seat_var_width = ctx.rng.gen_range(0..arena.stand_width) as f32;
930                    let seat_var_length = ctx.rng.gen_range(-arena.stand_length..arena.stand_length) as f32;
931                    // Select a seat on one of the 4 arena stands
932                    let seat = match ctx.rng.gen_range(0..4) {
933                        0 => Vec3::new(arena_center.x - stand_dist + seat_var_width, arena_center.y + seat_var_length, arena_center.z),
934                        1 => Vec3::new(arena_center.x + stand_dist - seat_var_width, arena_center.y + seat_var_length, arena_center.z),
935                        2 => Vec3::new(arena_center.x + seat_var_length, arena_center.y - stand_dist + seat_var_width, arena_center.z),
936                        _ => Vec3::new(arena_center.x + seat_var_length, arena_center.y + stand_dist - seat_var_width, arena_center.z),
937                    };
938                    let look_dir = Dir::from_unnormalized(arena_center - seat);
939                    // Walk to an arena seat, cheer, sit and dance
940                    let action = casual(just(move |ctx, _| ctx.controller.say(None, Content::localized("npc-speech-arena")))
941                            .then(goto_2d(seat.xy(), 0.6, 1.0).debug(|| "go to arena"))
942                            // Turn toward the centre of the arena and watch the action!
943                            .then(choose(move |ctx, _| if ctx.rng.gen_bool(0.3) {
944                                casual(just(move |ctx,_| ctx.controller.do_cheer(look_dir)).repeat().stop_if(timeout(5.0)))
945                            } else if ctx.rng.gen_bool(0.15) {
946                                casual(just(move |ctx,_| ctx.controller.do_dance(look_dir)).repeat().stop_if(timeout(5.0)))
947                            } else {
948                                casual(just(move |ctx,_| ctx.controller.do_sit(look_dir, None)).repeat().stop_if(timeout(15.0)))
949                            })
950                                .repeat()
951                                .stop_if(timeout(wait_time)))
952                            .map(|_, _| ())
953                            .boxed());
954                    fun_activities.push(action);
955                }
956                if let Some(tavern) = ws.plots.iter().filter_map(|(pid, p)| match_some!(p.kind(), PlotKind::Tavern(_) => pid)).choose(&mut ctx.rng) {
957                    let wait_time = ctx.rng.gen_range(100.0..300.0);
958                    let action = go_to_tavern(visiting_site, tavern).stop_if(timeout(wait_time)).map(|_, _| ());
959
960                    fun_activities.push(casual(action));
961                }
962            }
963
964
965            if !fun_activities.is_empty() {
966                let i = ctx.rng.gen_range(0..fun_activities.len());
967                return fun_activities.swap_remove(i);
968            }
969        }
970        // Villagers with roles should perform those roles
971        else if matches!(ctx.npc.profession(), Some(Profession::Herbalist)) && ctx.rng.gen_bool(0.8)
972        {
973            if let Some(forest_wpos) = find_forest(ctx) {
974                return casual(
975                    travel_to_point(forest_wpos, 0.5)
976                        .debug(|| "walk to forest")
977                        .then({
978                            let wait_time = ctx.rng.gen_range(10.0..30.0);
979                            gather_ingredients().repeat().stop_if(timeout(wait_time))
980                        })
981                        .map(|_, _| ()),
982                );
983            }
984        } else if matches!(ctx.npc.profession(), Some(Profession::Farmer)) && ctx.rng.gen_bool(0.8)
985        {
986            if let Some(farm_wpos) = find_farm(ctx, visiting_site) {
987                return casual(
988                    travel_to_point(farm_wpos, 0.5)
989                        .debug(|| "walk to farm")
990                        .then({
991                            let wait_time = ctx.rng.gen_range(30.0..120.0);
992                            gather_ingredients().repeat().stop_if(timeout(wait_time))
993                        })
994                        .map(|_, _| ()),
995                );
996            }
997        } else if matches!(ctx.npc.profession(), Some(Profession::Hunter)) && ctx.rng.gen_bool(0.8) {
998            if let Some(forest_wpos) = find_forest(ctx) {
999                return casual(
1000                    just(|ctx, _| {
1001                        ctx.controller
1002                            .say(None, Content::localized("npc-speech-start_hunting"))
1003                    })
1004                    .then(travel_to_point(forest_wpos, 0.75))
1005                    .debug(|| "walk to forest")
1006                    .then({
1007                        let wait_time = ctx.rng.gen_range(30.0..60.0);
1008                        hunt_animals().repeat().stop_if(timeout(wait_time))
1009                    })
1010                    .map(|_, _| ()),
1011                );
1012            }
1013        } else if matches!(ctx.npc.profession(), Some(Profession::Guard)) && ctx.rng.gen_bool(0.7) {
1014            if let Some(plaza_wpos) = choose_plaza(ctx, visiting_site) {
1015                return casual(
1016                    travel_to_point(plaza_wpos, 0.4)
1017                        .debug(|| "patrol")
1018                        .interrupt_with(move |ctx, _| {
1019                            if ctx.rng.gen_bool(0.0003) {
1020                                Some(just(move |ctx, _| {
1021                                    ctx.controller
1022                                        .say(None, Content::localized("npc-speech-guard_thought"))
1023                                }))
1024                            } else {
1025                                None
1026                            }
1027                        })
1028                        .map(|_, _| ()),
1029                );
1030            }
1031        } else if matches!(ctx.npc.profession(), Some(Profession::Merchant)) && ctx.rng.gen_bool(0.8)
1032        {
1033            return casual(
1034                just(|ctx, _| {
1035                    // Try to direct our speech at nearby actors, if there are any
1036                    let (target, phrase) = if ctx.rng.gen_bool(0.3) && let Some(other) = ctx
1037                        .state
1038                        .data()
1039                        .npcs
1040                        .nearby(Some(ctx.npc_id), ctx.npc.wpos, 8.0)
1041                        .choose(&mut ctx.rng)
1042                    {
1043                        (Some(other), "npc-speech-merchant_sell_directed")
1044                    } else {
1045                        // Otherwise, resort to generic expressions
1046                        (None, "npc-speech-merchant_sell_undirected")
1047                    };
1048
1049                    ctx.controller.say(target, Content::localized(phrase));
1050                })
1051                .then(idle().repeat().stop_if(timeout(8.0)))
1052                .repeat()
1053                .stop_if(timeout(60.0))
1054                .debug(|| "sell wares")
1055                .map(|_, _| ()),
1056            );
1057        } else if matches!(ctx.npc.profession(), Some(Profession::Chef))
1058            && ctx.rng.gen_bool(0.8)
1059            && let Some(ws_id) = ctx.state.data().sites[visiting_site].world_site
1060            && let Some(tavern) = ctx.index.sites.get(ws_id).plots().filter_map(|p| match_some!(p.kind(), PlotKind::Tavern(a) => a)).choose(&mut ctx.rng)
1061            && let Some((bar_pos, room_center)) = tavern.rooms.values().flat_map(|room|
1062                room.details.iter().filter_map(|detail| match_some!(detail,
1063                    tavern::Detail::Bar { aabr } => {
1064                        let center = aabr.center();
1065                        (center.with_z(room.bounds.min.z), room.bounds.center().xy())
1066                    },
1067                ))
1068            ).choose(&mut ctx.rng) {
1069
1070            let face_dir = Dir::from_unnormalized((room_center - bar_pos).as_::<f32>().with_z(0.0)).unwrap_or_else(|| Dir::random_2d(&mut ctx.rng));
1071
1072            return casual(
1073                travel_to_point(tavern.door_wpos.xy().as_(), 0.5)
1074                    .then(goto(bar_pos.as_() + Vec2::new(0.5, 0.5), WALKING_SPEED, 2.0))
1075                    // TODO: Just dance there for now, in the future do other stuff.
1076                    .then(just(move |ctx, _| ctx.controller.do_dance(Some(face_dir))).repeat().stop_if(timeout(60.0)))
1077                    .debug(|| "cook food").map(|_, _| ())
1078            )
1079        }
1080
1081        // If nothing else needs doing, walk between plazas and socialize
1082        casual(now(move |ctx, _| {
1083            // Choose a plaza in the site we're visiting to walk to
1084            if let Some(plaza_wpos) = choose_plaza(ctx, visiting_site) {
1085                // Walk to the plaza...
1086                Either::Left(travel_to_point(plaza_wpos, 0.5)
1087                    .debug(|| "walk to plaza"))
1088            } else {
1089                // No plazas? :(
1090                Either::Right(finish())
1091            }
1092                // ...then socialize for some time before moving on
1093                .then(socialize()
1094                    .repeat()
1095                    .map_state(|state: &mut DefaultState| &mut state.socialize_timer)
1096                    .stop_if(timeout(ctx.rng.gen_range(30.0..90.0)))
1097                    .debug(|| "wait at plaza"))
1098                .map(|_, _| ())
1099        }))
1100    })
1101    .debug(move || format!("villager at site {:?}", visiting_site))
1102}
1103
1104fn go_to_tavern(site_id: SiteId, tavern_plot: Id<site::Plot>) -> impl Action<DefaultState> {
1105    now(move |ctx, _| {
1106        let data = ctx.state.data();
1107        if let Some(site) = data.sites.get(site_id)
1108            && let Some(ws) = site.world_site
1109            && let PlotKind::Tavern(tavern) = ctx.index.sites.get(ws).plots.get(tavern_plot).kind()
1110        {
1111            let tavern_name = tavern.name.clone();
1112            let (stage_aabr, stage_z) = tavern
1113                .rooms
1114                .values()
1115                .flat_map(|room| {
1116                    room.details.iter().filter_map(|detail| {
1117                        match_some!(detail, tavern::Detail::Stage { aabr } => (*aabr, room.bounds.min.z + 1))
1118                    })
1119                })
1120                .choose(&mut ctx.rng)
1121                .unwrap_or((tavern.bounds, tavern.door_wpos.z));
1122
1123            let bar_pos = tavern
1124                .rooms
1125                .values()
1126                .flat_map(|room| {
1127                    room.details.iter().filter_map(|detail| match_some!(detail,
1128                        tavern::Detail::Bar { aabr } => {
1129                            let side = site::util::Dir::from_vec2(
1130                                room.bounds.center().xy() - aabr.center(),
1131                            );
1132                            let pos = side.select_aabr_with(*aabr, aabr.center()) + side.to_vec2();
1133
1134                            pos.with_z(room.bounds.min.z)
1135                        },
1136                    ))
1137                })
1138                .choose(&mut ctx.rng)
1139                .unwrap_or(stage_aabr.center().with_z(stage_z));
1140
1141            // Pick a chair that is theirs for the stay
1142            let chair_pos = tavern.rooms.values().flat_map(|room| {
1143            let z = room.bounds.min.z;
1144            room.details.iter().filter_map(move |detail| match_some!(detail,
1145                tavern::Detail::Table { pos, chairs } => chairs.into_iter().map(move |dir| pos.with_z(z) + dir.to_vec2())
1146            ))
1147            .flatten()
1148        }
1149        ).choose(&mut ctx.rng)
1150        // This path is possible, but highly unlikely.
1151        .unwrap_or(bar_pos);
1152
1153            let stage_aabr = stage_aabr.as_::<f32>();
1154            let stage_z = stage_z as f32;
1155
1156            travel_to_point(tavern.door_wpos.xy().as_() + 0.5, 0.8).then(choose(move |ctx, (last_action, _)| {
1157                let action = [0, 1, 2].into_iter().filter(|i| *last_action != Some(*i)).choose(&mut ctx.rng).expect("We have at least 2 elements");
1158                let socialize_repeat = || socialize().map_state(|(_, timer)| timer).repeat();
1159                match action {
1160                    // Go and dance on a stage.
1161                    0 => {
1162                        casual(
1163                            now(move |ctx, (last_action, _)| {
1164                                *last_action = Some(action);
1165                                goto(stage_aabr.min.map2(stage_aabr.max, |a, b| ctx.rng.gen_range(a..b)).with_z(stage_z), WALKING_SPEED, 1.0)
1166                            })
1167                            .then(just(move |ctx,_| ctx.controller.do_dance(None)).repeat().stop_if(timeout(ctx.rng.gen_range(20.0..30.0))))
1168                            .map(|_, _| ())
1169                            .debug(|| "Dancing on the stage")
1170                        )
1171                    },
1172                    // Go and sit at a table.
1173                    1 => {
1174                        casual(
1175                            now(move |ctx, (last_action, _)| {
1176                                *last_action = Some(action);
1177                                goto(chair_pos.as_() + 0.5, WALKING_SPEED, 1.0)
1178                                    .then(just(move |ctx, _| ctx.controller.do_sit(None, Some(chair_pos)))
1179                                        // .then(socialize().map_state(|(_, timer)| timer))
1180                                        .repeat().stop_if(timeout(ctx.rng.gen_range(30.0..60.0)))
1181                                    )
1182                                    .map(|_, _| ())
1183                            })
1184                            .debug(move || format!("Sitting in a chair at {} {} {}", chair_pos.x, chair_pos.y, chair_pos.z))
1185                        )
1186                    },
1187                    // Go to the bar.
1188                    _ => {
1189                        casual(
1190                            now(move |ctx, (last_action, _)| {
1191                                *last_action = Some(action);
1192                                goto(bar_pos.as_() + 0.5, WALKING_SPEED, 1.0).then(socialize_repeat().stop_if(timeout(ctx.rng.gen_range(10.0..25.0)))).map(|_, _| ())
1193                            }).debug(|| "At the bar")
1194                        )
1195                    },
1196                }
1197            })
1198            .with_state((None::<u32>, every_range(5.0..10.0)))
1199            .repeat())
1200            .map(|_, _| ())
1201            .debug(move || format!("At the tavern '{}'", tavern_name))
1202            .l()
1203        } else {
1204            just(|_, _| {}).r()
1205        }
1206    })
1207}
1208
1209fn pilot<S: State>(ship: common::comp::ship::Body) -> impl Action<S> {
1210    // Travel between different towns in a straight line
1211    now(move |ctx, _| {
1212        let data = &*ctx.state.data();
1213        let station_wpos = data
1214            .sites
1215            .iter()
1216            .filter(|(id, _)| Some(*id) != ctx.npc.current_site)
1217            .filter_map(|(_, site)| Some(ctx.index.sites.get(site.world_site?)))
1218            .flat_map(|site| {
1219                site.filter_plots(|plot| {
1220                    matches!(plot.kind().meta(), Some(PlotKindMeta::AirshipDock { .. }))
1221                })
1222                .map(|plot| site.tile_center_wpos(plot.root_tile()))
1223            })
1224            .choose(&mut ctx.rng);
1225        if let Some(station_wpos) = station_wpos {
1226            Either::Right(
1227                goto_2d_flying(
1228                    station_wpos.as_(),
1229                    1.0,
1230                    50.0,
1231                    150.0,
1232                    110.0,
1233                    ship.flying_height(),
1234                )
1235                .then(goto_2d_flying(
1236                    station_wpos.as_(),
1237                    1.0,
1238                    10.0,
1239                    32.0,
1240                    16.0,
1241                    30.0,
1242                )),
1243            )
1244        } else {
1245            Either::Left(finish())
1246        }
1247    })
1248    .repeat()
1249    .map(|_, _| ())
1250}
1251
1252fn captain<S: State>() -> impl Action<S> {
1253    // For now just randomly travel the sea
1254    now(|ctx, _| {
1255        let chunk = ctx.npc.wpos.xy().as_().wpos_to_cpos();
1256        if let Some(chunk) = NEIGHBORS
1257            .into_iter()
1258            .map(|neighbor| chunk + neighbor)
1259            .filter(|neighbor| {
1260                ctx.world
1261                    .sim()
1262                    .get(*neighbor)
1263                    .is_some_and(|c| c.river.river_kind.is_some())
1264            })
1265            .choose(&mut ctx.rng)
1266        {
1267            let wpos = TerrainChunkSize::center_wpos(chunk);
1268            let wpos = wpos.as_().with_z(
1269                ctx.world
1270                    .sim()
1271                    .get_interpolated(wpos, |chunk| chunk.water_alt)
1272                    .unwrap_or(0.0),
1273            );
1274            goto(wpos, 0.7, 5.0).boxed()
1275        } else {
1276            idle().boxed()
1277        }
1278    })
1279    .repeat()
1280    .map(|_, _| ())
1281}
1282
1283fn check_inbox<S: State>(ctx: &mut NpcCtx) -> Option<impl Action<S> + use<S>> {
1284    let mut action = None;
1285    ctx.inbox.retain(|input| {
1286        match input {
1287            NpcInput::Report(report_id) if !ctx.known_reports.contains(report_id) => {
1288                let data = ctx.state.data();
1289                let Some(report) = data.reports.get(*report_id) else {
1290                    return false;
1291                };
1292
1293                const REPORT_RESPONSE_TIME: f64 = 60.0 * 5.0;
1294
1295                match report.kind {
1296                    ReportKind::Death { killer, actor, .. }
1297                        if matches!(&ctx.npc.role, Role::Civilised(_)) =>
1298                    {
1299                        // TODO: Don't report self
1300                        let phrase = if let Some(killer) = killer {
1301                            // TODO: For now, we don't make sentiment changes if the killer was an
1302                            // NPC in some cases because some NPCs can't hurt one-another.
1303                            // This should be changed in the future.
1304                            let can_damage_killer = if let Actor::Npc(killer) = killer {
1305                                data.npcs.get(killer).is_some_and(|killer| {
1306                                    match (&ctx.npc.role, &killer.role) {
1307                                        (Role::Vehicle, _) | (_, Role::Vehicle) => false,
1308                                        (Role::Civilised(prof_a), Role::Civilised(prof_b)) => {
1309                                            match (prof_a, prof_b) {
1310                                                (
1311                                                    Some(
1312                                                        Profession::Pirate(_) | Profession::Cultist,
1313                                                    ),
1314                                                    Some(
1315                                                        Profession::Pirate(_) | Profession::Cultist,
1316                                                    ),
1317                                                ) => false,
1318                                                (
1319                                                    Some(
1320                                                        Profession::Pirate(_) | Profession::Cultist,
1321                                                    ),
1322                                                    _,
1323                                                )
1324                                                | (
1325                                                    _,
1326                                                    Some(
1327                                                        Profession::Pirate(_) | Profession::Cultist,
1328                                                    ),
1329                                                ) => true,
1330
1331                                                _ => false,
1332                                            }
1333                                        },
1334                                        (Role::Civilised(_), _) => true,
1335                                        (Role::Wild, Role::Wild) => false,
1336                                        (Role::Wild, _) => true,
1337                                        (Role::Monster, Role::Monster) => false,
1338                                        (Role::Monster, _) => true,
1339                                    }
1340                                })
1341                            } else {
1342                                true
1343                            };
1344
1345                            // TODO: Roles themselves are kind of a hack, and so is this. This is
1346                            // mostly a fix for npcs getting angry if you kill for example an ogre.
1347                            let is_victim_inherent_enemy = if let Actor::Npc(victim) = actor {
1348                                data.npcs.get(victim).is_some_and(|victim| {
1349                                    match (&ctx.npc.role, &victim.role) {
1350                                        (Role::Civilised(prof), Role::Civilised(victim_prof)) => {
1351                                            match (prof, victim_prof) {
1352                                                (
1353                                                    Some(
1354                                                        Profession::Pirate(_) | Profession::Cultist,
1355                                                    ),
1356                                                    Some(
1357                                                        Profession::Pirate(_) | Profession::Cultist,
1358                                                    ),
1359                                                ) => false,
1360                                                (
1361                                                    Some(
1362                                                        Profession::Pirate(_) | Profession::Cultist,
1363                                                    ),
1364                                                    _,
1365                                                )
1366                                                | (
1367                                                    _,
1368                                                    Some(
1369                                                        Profession::Pirate(_) | Profession::Cultist,
1370                                                    ),
1371                                                ) => true,
1372
1373                                                _ => false,
1374                                            }
1375                                        },
1376
1377                                        (Role::Civilised(_), Role::Monster) => true,
1378                                        _ => false,
1379                                    }
1380                                })
1381                            } else {
1382                                false
1383                            };
1384
1385                            let is_victim_enemy = is_victim_inherent_enemy
1386                                || ctx.sentiments.toward(actor).is(Sentiment::ENEMY);
1387
1388                            if can_damage_killer {
1389                                // TODO: Don't hard-code sentiment change
1390                                let change = if is_victim_enemy {
1391                                    // Like the killer if we have negative sentiment towards the
1392                                    // killed.
1393                                    0.25
1394                                } else {
1395                                    -0.75
1396                                };
1397                                ctx.sentiments
1398                                    .toward_mut(killer)
1399                                    .change_by(change, Sentiment::VILLAIN);
1400                            }
1401
1402                            // This is a murder of a player. Feel bad for the player and stop
1403                            // attacking them.
1404                            if let Actor::Character(_) = actor {
1405                                ctx.sentiments
1406                                    .toward_mut(actor)
1407                                    .limit_below(Sentiment::ENEMY)
1408                            }
1409
1410                            if is_victim_enemy {
1411                                "npc-speech-witness_enemy_murder"
1412                            } else {
1413                                "npc-speech-witness_murder"
1414                            }
1415                        } else {
1416                            "npc-speech-witness_death"
1417                        };
1418                        ctx.known_reports.insert(*report_id);
1419
1420                        if ctx.time_of_day.0 - report.at_tod.0 < REPORT_RESPONSE_TIME {
1421                            action = Some(
1422                                just(move |ctx, _| {
1423                                    ctx.controller.say(killer, Content::localized(phrase))
1424                                })
1425                                .l()
1426                                .l(),
1427                            );
1428                        }
1429                        false
1430                    },
1431                    ReportKind::Theft {
1432                        thief,
1433                        site,
1434                        sprite,
1435                    } => {
1436                        // Check if this happened at home, where we know what belongs to who
1437                        if let Some(site) = site
1438                            && ctx.npc.home == Some(site)
1439                        {
1440                            // TODO: Don't hardcode sentiment change.
1441                            ctx.sentiments
1442                                .toward_mut(thief)
1443                                .change_by(-0.2, Sentiment::ENEMY);
1444                            ctx.known_reports.insert(*report_id);
1445
1446                            let phrase = if matches!(ctx.npc.profession(), Some(Profession::Farmer))
1447                                && matches!(sprite.category(), sprite::Category::Plant)
1448                            {
1449                                "npc-speech-witness_theft_owned"
1450                            } else {
1451                                "npc-speech-witness_theft"
1452                            };
1453
1454                            if ctx.time_of_day.0 - report.at_tod.0 < REPORT_RESPONSE_TIME {
1455                                action = Some(
1456                                    just(move |ctx, _| {
1457                                        ctx.controller.say(thief, Content::localized(phrase))
1458                                    })
1459                                    .r()
1460                                    .l(),
1461                                );
1462                            }
1463                        }
1464                        false
1465                    },
1466                    // We don't care about deaths of non-civilians
1467                    ReportKind::Death { .. } => false,
1468                }
1469            },
1470            NpcInput::Report(_) => false, // Reports we already know of are ignored
1471            NpcInput::Interaction(by) => {
1472                action = Some(talk_to(*by).r());
1473                false
1474            },
1475            // Dialogue inputs get retained because they're handled by specific conversation actions
1476            // later
1477            NpcInput::Dialogue(_, _) => true,
1478        }
1479    });
1480
1481    action
1482}
1483
1484fn check_for_enemies<S: State>(ctx: &mut NpcCtx) -> Option<impl Action<S> + use<S>> {
1485    // TODO: Instead of checking all nearby actors every tick, it would be more
1486    // effective to have the actor grid generate a per-tick diff so that we only
1487    // need to check new actors in the local area. Be careful though:
1488    // implementing this means accounting for changes in sentiment (that could
1489    // suddenly make a nearby actor an enemy) as well as variable NPC tick
1490    // rates!
1491    ctx.state
1492        .data()
1493        .npcs
1494        .nearby(Some(ctx.npc_id), ctx.npc.wpos, 24.0)
1495        .find(|actor| ctx.sentiments.toward(*actor).is(Sentiment::ENEMY))
1496        .map(|enemy| just(move |ctx, _| ctx.controller.attack(enemy)))
1497}
1498
1499fn react_to_events<S: State>(ctx: &mut NpcCtx, _: &mut S) -> Option<impl Action<S> + use<S>> {
1500    check_inbox::<S>(ctx)
1501        .map(|action| action.boxed())
1502        .or_else(|| check_for_enemies(ctx).map(|action| action.boxed()))
1503}
1504
1505fn humanoid() -> impl Action<DefaultState> {
1506    choose(|ctx, _| {
1507        if let Some(riding) = &ctx.state.data().npcs.mounts.get_mount_link(ctx.npc_id) {
1508            if riding.is_steering {
1509                if let Some(vehicle) = ctx.state.data().npcs.get(riding.mount) {
1510                    match vehicle.body {
1511                        comp::Body::Ship(body @ comp::ship::Body::AirBalloon) => {
1512                            important(pilot(body))
1513                        },
1514                        comp::Body::Ship(comp::ship::Body::DefaultAirship) => {
1515                            important(airship_ai::pilot_airship())
1516                        },
1517                        comp::Body::Ship(
1518                            comp::ship::Body::SailBoat | comp::ship::Body::Galleon,
1519                        ) => important(captain()),
1520                        _ => casual(idle()),
1521                    }
1522                } else {
1523                    casual(finish())
1524                }
1525            } else {
1526                important(
1527                    socialize().map_state(|state: &mut DefaultState| &mut state.socialize_timer),
1528                )
1529            }
1530        } else if let Some((tgt, _)) = ctx.npc.hiring
1531            && util::actor_exists(ctx, tgt)
1532        {
1533            important(hired(tgt).interrupt_with(react_to_events))
1534        } else {
1535            let action = match ctx.npc.profession() {
1536                Some(Profession::Adventurer(_) | Profession::Merchant) => adventure().l().l(),
1537                Some(Profession::Pirate(is_leader)) => pirate(is_leader).l().r(),
1538                _ => {
1539                    if let Some(home) = ctx.npc.home {
1540                        villager(home).r().l()
1541                    } else {
1542                        idle().r().r() // Homeless
1543                    }
1544                },
1545            };
1546
1547            casual(action.interrupt_with(react_to_events))
1548        }
1549    })
1550}
1551
1552fn bird_large() -> impl Action<DefaultState> {
1553    now(|ctx, bearing: &mut Vec2<f32>| {
1554        *bearing = bearing
1555            .map(|e| e + ctx.rng.gen_range(-0.1..0.1))
1556            .try_normalized()
1557            .unwrap_or_default();
1558        let bearing_dist = 15.0;
1559        let mut pos = ctx.npc.wpos.xy() + *bearing * bearing_dist;
1560        let is_deep_water =
1561            matches!(ctx.npc.body, common::comp::Body::BirdLarge(b) if matches!(b.species, bird_large::Species::SeaWyvern))
1562                || ctx
1563                .world
1564                .sim()
1565                .get(pos.as_().wpos_to_cpos()).is_none_or(|c| {
1566                    c.alt - c.water_alt < -120.0 && (c.river.is_ocean() || c.river.is_lake())
1567                });
1568        if is_deep_water {
1569            *bearing *= -1.0;
1570            pos = ctx.npc.wpos.xy() + *bearing * bearing_dist;
1571        };
1572        // when high tree_density fly high, otherwise fly low-mid
1573        let npc_pos = ctx.npc.wpos.xy();
1574        let trees = ctx
1575            .world
1576            .sim()
1577            .get(npc_pos.as_().wpos_to_cpos()).is_some_and(|c| c.tree_density > 0.1);
1578        let height_factor = if trees {
1579            2.0
1580        } else {
1581            ctx.rng.gen_range(0.4..0.9)
1582        };
1583
1584        let data = ctx.state.data();
1585        // without destination site fly to next waypoint
1586        let mut dest_site = pos;
1587        if let Some(home) = ctx.npc.home {
1588            let is_home = ctx.npc.current_site == Some(home);
1589            if is_home {
1590                if let Some((id, _)) = data
1591                    .sites
1592                    .iter()
1593                    .filter(|(id, site)| {
1594                        *id != home
1595                            && site.world_site.is_some_and(|site| {
1596                            match ctx.npc.body {
1597                                common::comp::Body::BirdLarge(b) => match b.species {
1598                                    bird_large::Species::Phoenix => matches!(ctx.index.sites.get(site).kind,
1599                                    Some(SiteKind::Terracotta
1600                                    | SiteKind::Haniwa
1601                                    | SiteKind::Myrmidon
1602                                    | SiteKind::Adlet
1603                                    | SiteKind::DwarvenMine
1604                                    | SiteKind::ChapelSite
1605                                    | SiteKind::Cultist
1606                                    | SiteKind::Gnarling
1607                                    | SiteKind::Sahagin
1608                                    | SiteKind::VampireCastle)),
1609                                    bird_large::Species::Cockatrice => matches!(ctx.index.sites.get(site).kind,
1610                                    Some(SiteKind::GiantTree)),
1611                                    bird_large::Species::Roc => matches!(ctx.index.sites.get(site).kind,
1612                                    Some(SiteKind::Haniwa
1613                                    | SiteKind::Cultist)),
1614                                    bird_large::Species::FlameWyvern => matches!(ctx.index.sites.get(site).kind,
1615                                    Some(SiteKind::DwarvenMine
1616                                    | SiteKind::Terracotta)),
1617                                    bird_large::Species::CloudWyvern => matches!(ctx.index.sites.get(site).kind,
1618                                    Some(SiteKind::ChapelSite
1619                                    | SiteKind::Sahagin)),
1620                                    bird_large::Species::FrostWyvern => matches!(ctx.index.sites.get(site).kind,
1621                                    Some(SiteKind::Adlet
1622                                    | SiteKind::Myrmidon)),
1623                                    bird_large::Species::SeaWyvern => matches!(ctx.index.sites.get(site).kind,
1624                                    Some(SiteKind::ChapelSite
1625                                    | SiteKind::Sahagin)),
1626                                    bird_large::Species::WealdWyvern => matches!(ctx.index.sites.get(site).kind,
1627                                    Some(SiteKind::GiantTree
1628                                    | SiteKind::Gnarling)),
1629                                },
1630                                _ => matches!(&ctx.index.sites.get(site).kind, Some(SiteKind::GiantTree)),
1631                            }
1632                        })
1633                    })
1634                    /*choose closest destination:
1635                    .min_by_key(|(_, site)| site.wpos.as_().distance(npc_pos) as i32)*/
1636                //choose random destination:
1637                .choose(&mut ctx.rng)
1638                {
1639                    ctx.controller.set_new_home(id)
1640                }
1641            } else if let Some(site) = data.sites.get(home) {
1642                dest_site = site.wpos.as_::<f32>()
1643            }
1644        }
1645        goto_2d_flying(
1646            pos,
1647            0.2,
1648            bearing_dist,
1649            8.0,
1650            8.0,
1651            ctx.npc.body.flying_height() * height_factor,
1652        )
1653            // If we are too far away from our waypoint position we can stop since we aren't going to a specific place.
1654            // If waypoint position is further away from destination site find a new waypoint
1655            .stop_if(move |ctx: &mut NpcCtx| {
1656                ctx.npc.wpos.xy().distance_squared(pos) > (bearing_dist + 5.0).powi(2)
1657                    || dest_site.distance_squared(pos) > dest_site.distance_squared(npc_pos)
1658            })
1659            // If waypoint position wasn't reached within 10 seconds we're probably stuck and need to find a new waypoint.
1660            .stop_if(timeout(10.0))
1661            .debug({
1662                let bearing = *bearing;
1663                move || format!("Moving with a bearing of {:?}", bearing)
1664            })
1665    })
1666        .repeat()
1667        .with_state(Vec2::<f32>::zero())
1668        .map(|_, _| ())
1669}
1670
1671fn monster() -> impl Action<DefaultState> {
1672    now(|ctx, bearing: &mut Vec2<f32>| {
1673        *bearing = bearing
1674            .map(|e| e + ctx.rng.gen_range(-0.1..0.1))
1675            .try_normalized()
1676            .unwrap_or_default();
1677        let bearing_dist = 24.0;
1678        let mut pos = ctx.npc.wpos.xy() + *bearing * bearing_dist;
1679        let is_deep_water = ctx
1680            .world
1681            .sim()
1682            .get(pos.as_().wpos_to_cpos())
1683            .is_none_or(|c| {
1684                c.alt - c.water_alt < -10.0 && (c.river.is_ocean() || c.river.is_lake())
1685            });
1686        if !is_deep_water {
1687            goto_2d(pos, 0.7, 8.0)
1688        } else {
1689            *bearing *= -1.0;
1690
1691            pos = ctx.npc.wpos.xy() + *bearing * 24.0;
1692
1693            goto_2d(pos, 0.7, 8.0)
1694        }
1695        // If we are too far away from our goal position we can stop since we aren't going to a specific place.
1696        .stop_if(move |ctx: &mut NpcCtx| {
1697            ctx.npc.wpos.xy().distance_squared(pos) > (bearing_dist + 5.0).powi(2)
1698        })
1699        .debug({
1700            let bearing = *bearing;
1701            move || format!("Moving with a bearing of {:?}", bearing)
1702        })
1703    })
1704    .repeat()
1705    .with_state(Vec2::<f32>::zero())
1706    .map(|_, _| ())
1707}
1708
1709fn think() -> impl Action<DefaultState> {
1710    now(|ctx, _| match ctx.npc.body {
1711        common::comp::Body::Humanoid(_) => humanoid().l().l().l(),
1712        common::comp::Body::BirdLarge(_) => bird_large().r().l().l(),
1713        _ => match &ctx.npc.role {
1714            Role::Civilised(_) => socialize()
1715                .map_state(|state: &mut DefaultState| &mut state.socialize_timer)
1716                .l()
1717                .r()
1718                .l(),
1719            Role::Monster => monster().r().r().l(),
1720            Role::Wild => idle().r(),
1721            Role::Vehicle => idle().r(),
1722        },
1723    })
1724    .interrupt_with(|ctx, _| {
1725        if let Some((_from, action)) = ctx.npc_dialogue.pop_front() {
1726            Some(action.with_state(()))
1727        } else {
1728            None
1729        }
1730    })
1731}