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