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