Skip to main content

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