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