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