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