1mod airship_ai;
27#[cfg(feature = "airship_log")]
28mod airship_logger;
29pub mod dialogue;
30pub mod movement;
31pub mod util;
32
33use std::{collections::VecDeque, hash::BuildHasherDefault, sync::Arc};
34
35use crate::{
36 RtState, Rule, RuleError,
37 ai::{
38 Action, NpcCtx, State, casual, choose, finish, important, just, now,
39 predicate::{Chance, EveryRange, Predicate, every_range, timeout},
40 seq, until,
41 },
42 data::{
43 ReportKind, Sentiment, Sites,
44 npc::{Brain, DialogueSession, PathData, SimulationMode},
45 },
46 event::OnTick,
47};
48use common::{
49 assets::AssetExt,
50 astar::{Astar, PathResult},
51 comp::{
52 self, Content, bird_large,
53 compass::{Direction, Distance},
54 item::ItemDef,
55 },
56 match_some,
57 path::Path,
58 rtsim::{
59 Actor, ChunkResource, DialogueKind, NpcInput, PersonalityTrait, Profession, Response, Role,
60 SiteId,
61 },
62 spiral::Spiral2d,
63 store::Id,
64 terrain::{CoordinateConversions, TerrainChunkSize, sprite},
65 time::DayPeriod,
66 util::Dir,
67};
68use core::ops::ControlFlow;
69use fxhash::FxHasher64;
70use itertools::{Either, Itertools};
71use rand::prelude::*;
72use rand_chacha::ChaChaRng;
73use rayon::iter::{IntoParallelRefMutIterator, ParallelIterator};
74use vek::*;
75use world::{
76 IndexRef, World,
77 civ::{self, Track},
78 site::{
79 self, PlotKind, Site as WorldSite, SiteKind, TileKind,
80 plot::{PlotKindMeta, tavern},
81 },
82 util::NEIGHBORS,
83};
84
85use self::{
86 movement::{
87 follow_actor, goto, goto_2d, goto_2d_flying, goto_actor, travel_to_point, travel_to_site,
88 },
89 util::do_dialogue,
90};
91
92const SIMULATED_TICK_SKIP: u64 = 10;
97
98pub struct NpcAi;
99
100#[derive(Clone)]
101struct DefaultState {
102 socialize_timer: EveryRange,
103 move_home_timer: Chance<EveryRange>,
104}
105
106impl Rule for NpcAi {
107 fn start(rtstate: &mut RtState) -> Result<Self, RuleError> {
108 let mut last_ticks: VecDeque<_> = [1.0 / 30.0; SIMULATED_TICK_SKIP as usize]
111 .into_iter()
112 .collect();
113
114 rtstate.bind::<Self, OnTick>(move |ctx| {
115 last_ticks.push_front(ctx.event.dt);
116 if last_ticks.len() >= SIMULATED_TICK_SKIP as usize {
117 last_ticks.pop_back();
118 }
119 let mut npc_data = {
122 let mut data = ctx.state.data_mut();
123 data.npcs
124 .iter_mut()
125 .filter(|(_, npc)| !npc.is_dead() && !matches!(npc.role, Role::Vehicle))
127 .filter(|(_, npc)| matches!(npc.mode, SimulationMode::Loaded) || (npc.seed as u64 + ctx.event.tick) % SIMULATED_TICK_SKIP == 0)
129 .map(|(npc_id, npc)| {
130 let controller = std::mem::take(&mut npc.controller);
131 let inbox = std::mem::take(&mut npc.inbox);
132 let sentiments = std::mem::take(&mut npc.sentiments);
133 let known_reports = std::mem::take(&mut npc.known_reports);
134 let brain = npc.brain.take().unwrap_or_else(|| Brain {
135 action: Box::new(think().repeat().with_state(DefaultState {
136 socialize_timer: every_range(15.0..30.0),
137 move_home_timer: every_range(400.0..2000.0).chance(0.5),
138 })),
139 });
140 let npc_dialogue = std::mem::take(&mut npc.npc_dialogue);
141 (npc_id, controller, inbox, sentiments, known_reports, brain, npc_dialogue)
142 })
143 .collect::<Vec<_>>()
144 };
145
146 let simulated_dt = last_ticks.iter().sum::<f32>();
149
150 {
152 let data = &*ctx.state.data();
153
154 npc_data
155 .par_iter_mut()
156 .for_each(|(npc_id, controller, inbox, sentiments, known_reports, brain, npc_dialogue)| {
157 let npc = &data.npcs[*npc_id];
158
159 controller.reset();
160
161 brain.action.tick(&mut NpcCtx {
162 state: ctx.state,
163 world: ctx.world,
164 index: ctx.index,
165 time_of_day: ctx.event.time_of_day,
166 time: ctx.event.time,
167 npc,
168 npc_id: *npc_id,
169 controller,
170 npc_dialogue,
171 inbox,
172 known_reports,
173 sentiments,
174 dt: if matches!(npc.mode, SimulationMode::Loaded) {
175 ctx.event.dt
176 } else {
177 simulated_dt
178 },
179 rng: ChaChaRng::from_seed(thread_rng().gen::<[u8; 32]>()),
180 system_data: &*ctx.system_data,
181 }, &mut ());
182
183 inbox.clear();
185 });
186 }
187
188 let mut data = ctx.state.data_mut();
190 let mut to_update = Vec::with_capacity(npc_data.len());
191 for (npc_id, controller, inbox, sentiments, known_reports, brain, npc_dialogue) in npc_data {
192 to_update.push(npc_id);
193 data.npcs[npc_id].controller = controller;
194 data.npcs[npc_id].brain = Some(brain);
195 data.npcs[npc_id].inbox = inbox;
196 data.npcs[npc_id].sentiments = sentiments;
197 data.npcs[npc_id].known_reports = known_reports;
198 data.npcs[npc_id].npc_dialogue = npc_dialogue;
199 }
200
201 for npc_id in to_update {
202 let v = std::mem::take(&mut data.npcs[npc_id].controller.npc_actions);
203 for (target, action) in v {
204 if let Some(npc) = data.npcs.get_mut(target) {
205 npc.npc_dialogue.push_back((npc_id, action));
206 }
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: Actor) -> 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 matches!(tgt, Actor::Character(_)) {
228 do_dialogue(tgt, move |session| dialogue::general(tgt, session)).boxed()
229 } else {
230 smalltalk_to(tgt).boxed()
231 }
232 })
233}
234
235fn tell_site_content(ctx: &NpcCtx, site: SiteId) -> Option<Content> {
236 if let Some(world_site) = ctx.state.data().sites.get(site)
237 && let Some(site_name) = util::site_name(ctx, site)
238 {
239 Some(Content::localized_with_args("npc-speech-tell_site", [
240 ("site", Content::Plain(site_name)),
241 (
242 "dir",
243 Direction::from_dir(world_site.wpos.as_() - ctx.npc.wpos.xy()).localize_npc(),
244 ),
245 (
246 "dist",
247 Distance::from_length(world_site.wpos.as_().distance(ctx.npc.wpos.xy()) as i32)
248 .localize_npc(),
249 ),
250 ]))
251 } else {
252 None
253 }
254}
255
256fn smalltalk_to<S: State>(tgt: Actor) -> impl Action<S> {
257 now(move |ctx, _| {
258 if matches!(tgt, Actor::Npc(_)) && ctx.rng.gen_bool(0.2) {
259 idle().boxed()
263 } else {
264 let comment = if ctx.rng.gen_bool(0.3)
266 && let Some(current_site) = ctx.npc.current_site
267 && let Some(current_site) = ctx.state.data().sites.get(current_site)
268 && let Some(mention_site) = current_site.nearby_sites_by_size.choose(&mut ctx.rng)
269 && let Some(content) = tell_site_content(ctx, *mention_site)
270 {
271 content
272 } else if ctx.rng.gen_bool(0.3)
274 && let Some(current_site) = ctx.npc.current_site
275 && let Some(current_site_name) = util::site_name(ctx, current_site)
276 {
277 Content::localized_with_args("npc-speech-site", [(
278 "site",
279 Content::Plain(current_site_name),
280 )])
281
282 } else if ctx.rng.gen_bool(0.3)
284 && let Some(monster) = ctx
285 .state
286 .data()
287 .npcs
288 .values()
289 .filter(|other| matches!(&other.role, Role::Monster))
290 .min_by_key(|other| other.wpos.xy().distance(ctx.npc.wpos.xy()) as i32)
291 {
292 Content::localized_with_args("npc-speech-tell_monster", [
293 ("body", monster.body.localize_npc()),
294 (
295 "dir",
296 Direction::from_dir(monster.wpos.xy() - ctx.npc.wpos.xy()).localize_npc(),
297 ),
298 (
299 "dist",
300 Distance::from_length(monster.wpos.xy().distance(ctx.npc.wpos.xy()) as i32)
301 .localize_npc(),
302 ),
303 ])
304 } else if ctx.rng.gen_bool(0.6) && DayPeriod::from(ctx.time_of_day.0).is_dark() {
306 Content::localized("npc-speech-night")
307 } else if ctx.rng.gen_bool(0.3)
308 && let Some(profession_comment) = match_some!(ctx.npc.profession(),
309 Some(Profession::Pirate(_)) => Content::localized("npc-speech-pirate"),
310 )
311 {
312 profession_comment
313 } else {
314 ctx.npc.personality.get_generic_comment(&mut ctx.rng)
315 };
316 let wait = if matches!(tgt, Actor::Character(_)) {
318 0.0
319 } else {
320 1.5
321 };
322 idle()
323 .repeat()
324 .stop_if(timeout(wait))
325 .then(just(move |ctx, _| ctx.controller.say(tgt, comment.clone())))
326 .boxed()
327 }
328 })
329}
330
331fn socialize() -> impl Action<EveryRange> {
332 now(move |ctx, socialize: &mut EveryRange| {
333 if matches!(ctx.npc.mode, SimulationMode::Loaded)
335 && socialize.should(ctx)
336 && !ctx.npc.personality.is(PersonalityTrait::Introverted)
337 {
338 if ctx.rng.gen_bool(0.15) {
340 return just(|ctx, _| ctx.controller.do_dance(None))
341 .repeat()
342 .stop_if(timeout(6.0))
343 .debug(|| "dancing")
344 .map(|_, _| ())
345 .l()
346 .l();
347 } else if let Some(other) = ctx
349 .state
350 .data()
351 .npcs
352 .nearby(Some(ctx.npc_id), ctx.npc.wpos, 8.0)
353 .choose(&mut ctx.rng)
354 {
355 return smalltalk_to(other)
356 .then(idle().repeat().stop_if(timeout(4.0)))
358 .map(|_, _| ())
359 .r().l();
360 }
361 }
362 idle().r()
363 })
364}
365
366fn pirate(is_leader: bool) -> impl Action<DefaultState> {
367 choose(move |ctx, _| {
368 let data = ctx.state.data();
369 if is_leader
370 && let Some(home) = ctx.npc.home
371 && ctx.npc.current_site == Some(home)
372 && let Some(site) = data.sites.get(home)
373 && let Some(faction) = ctx.npc.faction
374 && ctx.chance(1.0 / 3600.0)
376 && let Some(site_to_raid) = site
377 .nearby_sites_by_size
378 .iter()
379 .filter(|site| {
380 data.sites.get(**site).is_some_and(|site| {
381 site.wpos.as_::<f32>().distance_squared(ctx.npc.wpos.xy())
383 < 10000.0f32.powi(2)
384 })
385 })
386 .choose(&mut ctx.rng)
387 .copied()
388 && site
389 .population
390 .iter()
391 .filter(|npc_id| {
392 data.npcs.get(**npc_id).is_some_and(|npc| {
393 !npc.is_dead()
394 && npc.current_site == Some(home)
395 && npc.faction == Some(faction)
396 && npc.hiring.is_none()
397 && matches!(npc.role, Role::Civilised(Some(Profession::Pirate(false))))
398 })
399 })
400 .count()
401 > 3
402 {
403 important(
404 now(move |ctx, _| {
405 let data = ctx.state.data();
406 if let Some(site) = data.sites.get(home)
407 && let Some(npc) = site
408 .population
409 .iter()
410 .filter(|npc_id| {
411 data.npcs.get(**npc_id).is_some_and(|npc| {
412 !npc.is_dead()
413 && npc.current_site == Some(home)
414 && npc.faction == Some(faction)
415 && npc.hiring.is_none()
416 && matches!(
417 npc.role,
418 Role::Civilised(Some(Profession::Pirate(false)))
419 )
420 })
421 })
422 .choose(&mut ctx.rng)
423 {
424 let npc = *npc;
425 follow_actor(Actor::Npc(npc), 5.0)
426 .stop_if(move |ctx: &mut NpcCtx| {
427 let data = ctx.state.data();
428 let Some(follow_npc) = data.npcs.get(npc) else {
429 return true;
430 };
431 ctx.npc.wpos.distance_squared(follow_npc.wpos) < 6.0f32.powi(2)
432 })
433 .then(just(move |ctx, _| {
434 let leader = ctx.npc_id;
435 ctx.controller.npc_dialogue(
436 npc,
437 Content::localized_with_args("npc-speech-pirate_raid", [(
438 "site",
439 util::site_name(ctx, site_to_raid).unwrap_or_default(),
440 )]),
441 idle().repeat().stop_if(timeout(2.0)).then(just(
442 move |ctx, _| {
443 let target = Actor::Npc(leader);
444 ctx.controller.say(
445 target,
446 Content::localized("npc-response-accept_hire"),
447 );
448 ctx.controller.hiring = Some(Some((
449 target,
450 common::resources::Time(f64::INFINITY),
451 )));
452 },
453 )),
454 );
455 }))
456 .debug(|| "inviting raid participant")
457 .l()
458 } else {
459 idle().r()
460 }
461 })
462 .repeat()
463 .stop_if(move |ctx: &mut NpcCtx| {
464 let data = ctx.state.data();
465 if let Some(site) = data.sites.get(home) {
466 let hired_count = site
467 .population
468 .iter()
469 .filter(|npc_id| {
470 data.npcs.get(**npc_id).is_some_and(|npc| {
471 !npc.is_dead()
472 && npc
473 .hiring
474 .is_some_and(|(a, _)| a == Actor::Npc(ctx.npc_id))
475 })
476 })
477 .count();
478
479 let unhired_count = site
480 .population
481 .iter()
482 .filter(|npc_id| {
483 data.npcs.get(**npc_id).is_some_and(|npc| {
484 !npc.is_dead()
485 && npc.current_site == Some(home)
486 && npc.faction == Some(faction)
487 && npc.hiring.is_none()
488 && matches!(
489 npc.role,
490 Role::Civilised(Some(Profession::Pirate(false)))
491 )
492 })
493 })
494 .count();
495
496 if unhired_count == 0 {
497 return true;
498 }
499
500 let chance = match hired_count {
501 0..=3 => 0.0,
502 _ => (hired_count - 3) as f64 * 1.0 / 1200.0,
503 } / unhired_count as f64;
504
505 ctx.chance(chance)
506 } else {
507 true
508 }
509 })
510 .debug(|| "preparing for raid")
511 .then(travel_to_site(site_to_raid, 0.8).debug(|| "travel to raid site"))
512 .then(
513 villager(site_to_raid)
515 .stop_if(timeout(ctx.rng.gen_range(60.0..120.0)))
516 .debug(|| "raiding"),
517 )
518 .then(travel_to_site(home, 0.6).debug(|| "traveling home from raid"))
519 .then(just(|ctx, _| {
521 let data = ctx.state.data();
522 if let Some(site) = ctx.npc.home
523 && let Some(site) = data.sites.get(site)
524 {
525 for &npc_id in site.population.iter() {
526 if let Some(npc) = data.npcs.get(npc_id)
527 && npc
528 .hiring
529 .is_some_and(|(actor, _)| actor == Actor::Npc(ctx.npc_id))
530 {
531 ctx.controller
532 .npc_action(npc_id, just(|ctx, _| ctx.controller.end_hiring()));
533 }
534 }
535 }
536 }))
537 .map(|_, _| ()),
538 )
539 } else if let Some((leader, _)) = ctx.npc.hiring {
540 important(
541 follow_actor(leader, 5.0)
542 .stop_if(move |ctx: &mut NpcCtx| {
543 ctx.npc.hiring.is_none_or(move |(actor, _)| actor != leader)
544 })
545 .map(|_, _| ()),
546 )
547 } else if let Some(home) = ctx.npc.home {
548 casual(now(move |ctx, _| {
549 let data = ctx.state.data();
550 let pos = data.sites.get(home).and_then(|site| {
551 let ws = ctx.index.sites.get(site.world_site?);
552 let plot = ws
553 .filter_plots(|plot| matches!(plot.kind(), PlotKind::PirateHideout(_)))
554 .choose(&mut ctx.rng)?;
555 let tile = plot.tiles().choose(&mut ctx.rng)?;
556 let wpos = ws.tile_center_wpos(tile);
557
558 Some(wpos.as_())
559 });
560 if let Some(new_pos) = pos {
562 Either::Left(travel_to_point(new_pos, 0.5)
564 .debug(|| "walk to pirate hideout"))
565 } else {
566 ctx.controller.set_new_home(None);
568 Either::Right(finish())
569 }
570 .then(socialize()
572 .repeat()
573 .map_state(|state: &mut DefaultState| &mut state.socialize_timer)
574 .stop_if(timeout(ctx.rng.gen_range(30.0..90.0)))
575 .debug(|| "wait at pirate hideout"))
576 .map(|_, _| ())
577 }))
578 } else {
579 important(just(move |ctx, _| {
581 let data = ctx.state.data();
582 if let Some((site, _)) =
583 data.sites
584 .iter()
585 .filter(|(_, site)| {
586 site.world_site.is_some_and(|ws| {
587 ctx.index.sites.get(ws).any_plot(|plot| {
588 matches!(plot.kind(), PlotKind::PirateHideout(_))
589 })
590 })
591 })
592 .min_by_key(|(_, site)| {
593 site.wpos
594 .as_::<i64>()
595 .distance_squared(ctx.npc.wpos.xy().as_())
596 })
597 {
598 ctx.controller.set_new_home(site);
599 }
600 }))
601 }
602 })
603}
604
605fn adventure() -> impl Action<DefaultState> {
606 choose(|ctx, _| {
607 if let Some(tgt_site) = ctx
609 .state
610 .data()
611 .sites
612 .iter()
613 .filter(|(site_id, site)| {
614 site.world_site.is_some_and(|ws| ctx.index.sites.get(ws).any_plot(|plot| matches!(plot.meta(), Some(PlotKindMeta::Workshop { .. })))) && (ctx.npc.current_site != Some(*site_id))
615 && ctx.rng.gen_bool(0.25)
616 })
617 .min_by_key(|(_, site)| site.wpos.as_().distance(ctx.npc.wpos.xy()) as i32)
618 .map(|(site_id, _)| site_id)
619 {
620 let wait_time = if matches!(ctx.npc.profession(), Some(Profession::Merchant)) {
621 60.0 * 15.0
622 } else {
623 60.0 * 3.0
624 };
625 let site_name = util::site_name(ctx, tgt_site).unwrap_or_default();
626 important(just(move |ctx, _| ctx.controller.say(None, Content::localized_with_args("npc-speech-moving_on", [("site", site_name.clone())])))
628 .then(travel_to_site(tgt_site, 0.6))
629 .then(villager(tgt_site).repeat().stop_if(timeout(wait_time)))
631 .map(|_, _| ())
632 .boxed(),
633 )
634 } else {
635 casual(finish().boxed())
636 }
637 })
638 .debug(move || "adventure")
639}
640
641fn hired(tgt: Actor) -> impl Action<DefaultState> {
642 follow_actor(tgt, 5.0)
643 .stop_if(move |ctx: &mut NpcCtx| ctx.npc.hiring.is_none_or(|(a, _)| a != tgt))
645 .debug(move|| format!("hired by {tgt:?}"))
646 .interrupt_with(move |ctx, _| {
647 if let Some((tgt, expires)) = ctx.npc.hiring {
649 if ctx.time > expires {
651 ctx.controller.end_hiring();
652 if util::actor_exists(ctx, tgt) {
654 return Some(goto_actor(tgt, 2.0)
655 .then(do_dialogue(tgt, |session| {
656 session.say_statement(Content::localized("npc-dialogue-hire_expired"))
657 }))
658 .boxed());
659 }
660 }
661
662 if ctx.sentiments.toward(tgt).is(Sentiment::RIVAL) {
663 ctx.controller.end_hiring();
664 if util::actor_exists(ctx, tgt) {
666 return Some(goto_actor(tgt, 2.0)
667 .then(do_dialogue(tgt, |session| {
668 session.say_statement(Content::localized(
669 "npc-dialogue-hire_cancelled_unhappy",
670 ))
671 }))
672 .boxed());
673 }
674 }
675
676 let data = ctx.state.data();
677
678 if let Some(visiting) = ctx.npc.current_site &&
679 let Some(visiting_site) = data.sites.get(visiting) &&
680 let Some(visiting_ws) = visiting_site.world_site &&
681 let Some(pos) = util::locate_actor(ctx, tgt) &&
682 let Some(chunk) = ctx.world.sim().get_wpos(pos.xy().as_()) &&
683 chunk.sites.contains(&visiting_ws) &&
684 let Some((pid, tavern)) = ctx.index.sites.get(visiting_ws).plots.iter().filter_map(|(pid, plot)| match_some!(plot.kind(), PlotKind::Tavern(t) => (pid, t))).choose(&mut ctx.npc.rng(14))
685 {
686 let tavern_name = tavern.name.clone();
687 return Some(just(move |ctx, _| {
688 ctx.controller.say(
689 tgt,
690 Content::localized_with_args(
691 "npc-dialogue-hire_arrive_tavern",
692 [("tavern", Content::Plain(tavern_name.clone()))]
693 )
694 )
695 })
696 .then(
697 go_to_tavern(visiting, pid).stop_if(move |ctx: &mut NpcCtx<'_, '_>| {
698 ctx.npc.hiring.is_none_or(|(tgt, _)| {
699 util::locate_actor(ctx, tgt).is_none_or(|pos|
700 ctx.world.sim()
701 .get_wpos(pos.xy().as_())
702 .is_none_or(|chunk|
703 !chunk.sites.contains(&visiting_ws)
704 )
705 )
706 })
707 })
708 )
709 .map(|_, _| ())
710 .boxed());
711 }
712 }
713
714 None
715 })
716 .map(|_, _| ())
717}
718
719fn gather_ingredients<S: State>() -> impl Action<S> {
720 just(|ctx, _| {
721 ctx.controller.do_gather(
722 &[
723 ChunkResource::Fruit,
724 ChunkResource::Mushroom,
725 ChunkResource::Plant,
726 ][..],
727 )
728 })
729 .debug(|| "gather ingredients")
730}
731
732fn hunt_animals<S: State>() -> impl Action<S> {
733 just(|ctx, _| ctx.controller.do_hunt_animals()).debug(|| "hunt_animals")
734}
735
736fn find_forest(ctx: &mut NpcCtx) -> Option<Vec2<f32>> {
737 let chunk_pos = ctx.npc.wpos.xy().as_().wpos_to_cpos();
738 Spiral2d::new()
739 .skip(ctx.rng.gen_range(1..=64))
740 .take(24)
741 .map(|rpos| chunk_pos + rpos)
742 .find(|cpos| {
743 ctx.world
744 .sim()
745 .get(*cpos)
746 .is_some_and(|c| c.tree_density > 0.75 && c.surface_veg > 0.5)
747 })
748 .map(|chunk| TerrainChunkSize::center_wpos(chunk).as_())
749}
750
751fn find_farm(ctx: &mut NpcCtx, site: SiteId) -> Option<Vec2<f32>> {
752 ctx.state.data().sites.get(site).and_then(|site| {
753 let site = ctx.index.sites.get(site.world_site?);
754 let farm = site
755 .filter_plots(|p| matches!(p.kind(), PlotKind::FarmField(_)))
756 .choose(&mut ctx.rng)?;
757
758 Some(site.tile_center_wpos(farm.root_tile()).as_())
759 })
760}
761
762fn choose_plaza(ctx: &mut NpcCtx, site: SiteId) -> Option<Vec2<f32>> {
763 ctx.state.data().sites.get(site).and_then(|site| {
764 let site = ctx.index.sites.get(site.world_site?);
765 let plaza = &site.plots[site.plazas().choose(&mut ctx.rng)?];
766 let tile = plaza
767 .tiles()
768 .choose(&mut ctx.rng)
769 .unwrap_or_else(|| plaza.root_tile());
770 Some(site.tile_center_wpos(tile).as_())
771 })
772}
773
774const WALKING_SPEED: f32 = 0.35;
775
776fn villager(visiting_site: SiteId) -> impl Action<DefaultState> {
777 choose(move |ctx, state: &mut DefaultState| {
778 if state.move_home_timer.should(ctx)
780 && let Some(home) = ctx.npc.home
781 && Some(home) == ctx.npc.current_site
782 && let Some(home_pop_ratio) = ctx.state.data().sites.get(home)
783 .and_then(|site| Some((site, ctx.index.sites.get(site.world_site?))))
784 .and_then(|(site, world_site)| { let houses = world_site.filter_plots(|p| matches!(p.meta(), Some(PlotKindMeta::House { .. }))).count(); if houses == 0 { return None } Some(site.population.len() as f32 / houses as f32) } )
785 .filter(|pop_ratio| *pop_ratio > 1.5)
787 && let Some(new_home) = ctx
788 .state
789 .data()
790 .sites
791 .iter()
792 .filter(|(site_id, _)| Some(*site_id) != ctx.npc.home)
794 .filter_map(|(site_id, site)| {
796 let world_site = site.world_site.map(|ws| ctx.index.sites.get(ws))?;
797 let house_count = world_site.filter_plots(|p| matches!(p.meta(), Some(PlotKindMeta::House { .. }))).count();
798
799 if house_count == 0 {
800 return None;
801 }
802 Some((site_id, site, house_count))
803 })
804 .filter(|(_, site, houses)| (site.population.len() as f32 / *houses as f32) < home_pop_ratio)
806 .min_by_key(|(_, site, _)| site.wpos.as_().distance(ctx.npc.wpos.xy()) as i32)
808 .map(|(site_id, _, _)| site_id)
809 {
810 let site_name = util::site_name(ctx, new_home);
811 return important(just(move |ctx, _| {
812 if let Some(site_name) = &site_name {
813 ctx.controller.say(None, Content::localized_with_args("npc-speech-migrating", [("site", site_name.clone())]))
814 }
815 })
816 .then(travel_to_site(new_home, 0.5))
817 .then(just(move |ctx, _| ctx.controller.set_new_home(new_home))));
818 }
819 let day_period = DayPeriod::from(ctx.time_of_day.0);
820 let is_weekend = ctx.time_of_day.day() as u64 % 6 == 0;
821 let is_evening = day_period == DayPeriod::Evening;
822
823 let is_free_time = is_weekend || is_evening;
824
825 let is_raining = ctx.system_data.weather_grid.is_raining(ctx.npc.wpos.xy());
826
827 if day_period.is_dark()
829 && !matches!(ctx.npc.profession(), Some(Profession::Guard))
830 {
831 return important(
832 now(move |ctx, _| {
833 if let Some(house_wpos) = ctx
834 .state
835 .data()
836 .sites
837 .get(visiting_site)
838 .and_then(|site| Some(ctx.index.sites.get(site.world_site?)))
839 .and_then(|site| {
840 let house = site
842 .plots()
843 .filter(|p| matches!(p.kind().meta(), Some(PlotKindMeta::House { .. })))
844 .choose(&mut ctx.rng)?;
845 Some(site.tile_center_wpos(house.root_tile()).as_())
846 })
847 {
848 just(|ctx, _| {
849 ctx.controller
850 .say(None, Content::localized("npc-speech-night_time"))
851 })
852 .then(travel_to_point(house_wpos, 0.65))
853 .debug(|| "walk to house")
854 .then(socialize().repeat().map_state(|state: &mut DefaultState| &mut state.socialize_timer).debug(|| "wait in house"))
855 .stop_if(|ctx: &mut NpcCtx| DayPeriod::from(ctx.time_of_day.0).is_light())
856 .then(just(|ctx, _| {
857 ctx.controller
858 .say(None, Content::localized("npc-speech-day_time"))
859 }))
860 .map(|_, _| ())
861 .boxed()
862 } else {
863 finish().boxed()
864 }
865 })
866 .debug(|| "find somewhere to sleep"),
867 );
868 }
869 else if is_raining
871 && !matches!(ctx.npc.profession(), Some(Profession::Guard))
872 {
873 return important(
874 now(move |ctx, _| {
875 if let Some(house_wpos) = ctx
876 .state
877 .data()
878 .sites
879 .get(visiting_site)
880 .and_then(|site| Some(ctx.index.sites.get(site.world_site?)))
881 .and_then(|site| {
882 let house = site
884 .plots()
885 .filter(|p| matches!(p.kind().meta(), Some(PlotKindMeta::House { .. })))
886 .choose(&mut ctx.rng)?;
887 Some(site.tile_center_wpos(house.root_tile()).as_())
888 })
889 {
890 just(|ctx, _| {
891 ctx.controller.say(None, Content::localized("npc-speech-seeking_shelter_rain"))
892 })
893 .then(travel_to_point(house_wpos, 0.65))
894 .debug(|| "walk to house (rain)")
895 .then(socialize().repeat().map_state(|state: &mut DefaultState| &mut state.socialize_timer).debug(|| "wait in house (rain)"))
896 .stop_if(|ctx: &mut NpcCtx| {
897 let is_raining = ctx.system_data.weather_grid.is_raining(ctx.npc.wpos.xy());
898 !is_raining
899 })
900 .then(just(|ctx, _| {
901 ctx.controller.say(None, Content::localized("npc-speech-rain_stopped"))
902 }))
903 .map(|_, _| ())
904 .boxed()
905 } else {
906 finish().boxed()
907 }
908 })
909 .debug(|| "find somewhere to wait (rain)"),
910 );
911 }
912 else if
914 !matches!(ctx.npc.profession(), Some(Profession::Guard | Profession::Chef))
916 && (matches!(day_period, DayPeriod::Evening) || is_free_time || ctx.rng.gen_bool(0.05)) {
917 let mut fun_activities = Vec::new();
918
919 if let Some(ws_id) = ctx.state.data().sites[visiting_site].world_site {
920 let ws = ctx.index.sites.get(ws_id);
921 if let Some(arena) = ws.plots().find_map(|p| match_some!(p.kind(), PlotKind::DesertCityArena(a) => a)) {
922 let wait_time = ctx.rng.gen_range(100.0..300.0);
923 let arena_center = Vec3::new(arena.center.x, arena.center.y, arena.base).as_::<f32>();
928 let stand_dist = arena.stand_dist as f32;
929 let seat_var_width = ctx.rng.gen_range(0..arena.stand_width) as f32;
930 let seat_var_length = ctx.rng.gen_range(-arena.stand_length..arena.stand_length) as f32;
931 let seat = match ctx.rng.gen_range(0..4) {
933 0 => Vec3::new(arena_center.x - stand_dist + seat_var_width, arena_center.y + seat_var_length, arena_center.z),
934 1 => Vec3::new(arena_center.x + stand_dist - seat_var_width, arena_center.y + seat_var_length, arena_center.z),
935 2 => Vec3::new(arena_center.x + seat_var_length, arena_center.y - stand_dist + seat_var_width, arena_center.z),
936 _ => Vec3::new(arena_center.x + seat_var_length, arena_center.y + stand_dist - seat_var_width, arena_center.z),
937 };
938 let look_dir = Dir::from_unnormalized(arena_center - seat);
939 let action = casual(just(move |ctx, _| ctx.controller.say(None, Content::localized("npc-speech-arena")))
941 .then(goto_2d(seat.xy(), 0.6, 1.0).debug(|| "go to arena"))
942 .then(choose(move |ctx, _| if ctx.rng.gen_bool(0.3) {
944 casual(just(move |ctx,_| ctx.controller.do_cheer(look_dir)).repeat().stop_if(timeout(5.0)))
945 } else if ctx.rng.gen_bool(0.15) {
946 casual(just(move |ctx,_| ctx.controller.do_dance(look_dir)).repeat().stop_if(timeout(5.0)))
947 } else {
948 casual(just(move |ctx,_| ctx.controller.do_sit(look_dir, None)).repeat().stop_if(timeout(15.0)))
949 })
950 .repeat()
951 .stop_if(timeout(wait_time)))
952 .map(|_, _| ())
953 .boxed());
954 fun_activities.push(action);
955 }
956 if let Some(tavern) = ws.plots.iter().filter_map(|(pid, p)| match_some!(p.kind(), PlotKind::Tavern(_) => pid)).choose(&mut ctx.rng) {
957 let wait_time = ctx.rng.gen_range(100.0..300.0);
958 let action = go_to_tavern(visiting_site, tavern).stop_if(timeout(wait_time)).map(|_, _| ());
959
960 fun_activities.push(casual(action));
961 }
962 }
963
964
965 if !fun_activities.is_empty() {
966 let i = ctx.rng.gen_range(0..fun_activities.len());
967 return fun_activities.swap_remove(i);
968 }
969 }
970 else if matches!(ctx.npc.profession(), Some(Profession::Herbalist)) && ctx.rng.gen_bool(0.8)
972 {
973 if let Some(forest_wpos) = find_forest(ctx) {
974 return casual(
975 travel_to_point(forest_wpos, 0.5)
976 .debug(|| "walk to forest")
977 .then({
978 let wait_time = ctx.rng.gen_range(10.0..30.0);
979 gather_ingredients().repeat().stop_if(timeout(wait_time))
980 })
981 .map(|_, _| ()),
982 );
983 }
984 } else if matches!(ctx.npc.profession(), Some(Profession::Farmer)) && ctx.rng.gen_bool(0.8)
985 {
986 if let Some(farm_wpos) = find_farm(ctx, visiting_site) {
987 return casual(
988 travel_to_point(farm_wpos, 0.5)
989 .debug(|| "walk to farm")
990 .then({
991 let wait_time = ctx.rng.gen_range(30.0..120.0);
992 gather_ingredients().repeat().stop_if(timeout(wait_time))
993 })
994 .map(|_, _| ()),
995 );
996 }
997 } else if matches!(ctx.npc.profession(), Some(Profession::Hunter)) && ctx.rng.gen_bool(0.8) {
998 if let Some(forest_wpos) = find_forest(ctx) {
999 return casual(
1000 just(|ctx, _| {
1001 ctx.controller
1002 .say(None, Content::localized("npc-speech-start_hunting"))
1003 })
1004 .then(travel_to_point(forest_wpos, 0.75))
1005 .debug(|| "walk to forest")
1006 .then({
1007 let wait_time = ctx.rng.gen_range(30.0..60.0);
1008 hunt_animals().repeat().stop_if(timeout(wait_time))
1009 })
1010 .map(|_, _| ()),
1011 );
1012 }
1013 } else if matches!(ctx.npc.profession(), Some(Profession::Guard)) && ctx.rng.gen_bool(0.7) {
1014 if let Some(plaza_wpos) = choose_plaza(ctx, visiting_site) {
1015 return casual(
1016 travel_to_point(plaza_wpos, 0.4)
1017 .debug(|| "patrol")
1018 .interrupt_with(move |ctx, _| {
1019 if ctx.rng.gen_bool(0.0003) {
1020 Some(just(move |ctx, _| {
1021 ctx.controller
1022 .say(None, Content::localized("npc-speech-guard_thought"))
1023 }))
1024 } else {
1025 None
1026 }
1027 })
1028 .map(|_, _| ()),
1029 );
1030 }
1031 } else if matches!(ctx.npc.profession(), Some(Profession::Merchant)) && ctx.rng.gen_bool(0.8)
1032 {
1033 return casual(
1034 just(|ctx, _| {
1035 let (target, phrase) = if ctx.rng.gen_bool(0.3) && let Some(other) = ctx
1037 .state
1038 .data()
1039 .npcs
1040 .nearby(Some(ctx.npc_id), ctx.npc.wpos, 8.0)
1041 .choose(&mut ctx.rng)
1042 {
1043 (Some(other), "npc-speech-merchant_sell_directed")
1044 } else {
1045 (None, "npc-speech-merchant_sell_undirected")
1047 };
1048
1049 ctx.controller.say(target, Content::localized(phrase));
1050 })
1051 .then(idle().repeat().stop_if(timeout(8.0)))
1052 .repeat()
1053 .stop_if(timeout(60.0))
1054 .debug(|| "sell wares")
1055 .map(|_, _| ()),
1056 );
1057 } else if matches!(ctx.npc.profession(), Some(Profession::Chef))
1058 && ctx.rng.gen_bool(0.8)
1059 && let Some(ws_id) = ctx.state.data().sites[visiting_site].world_site
1060 && 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)
1061 && let Some((bar_pos, room_center)) = tavern.rooms.values().flat_map(|room|
1062 room.details.iter().filter_map(|detail| match_some!(detail,
1063 tavern::Detail::Bar { aabr } => {
1064 let center = aabr.center();
1065 (center.with_z(room.bounds.min.z), room.bounds.center().xy())
1066 },
1067 ))
1068 ).choose(&mut ctx.rng) {
1069
1070 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));
1071
1072 return casual(
1073 travel_to_point(tavern.door_wpos.xy().as_(), 0.5)
1074 .then(goto(bar_pos.as_() + Vec2::new(0.5, 0.5), WALKING_SPEED, 2.0))
1075 .then(just(move |ctx, _| ctx.controller.do_dance(Some(face_dir))).repeat().stop_if(timeout(60.0)))
1077 .debug(|| "cook food").map(|_, _| ())
1078 )
1079 }
1080
1081 casual(now(move |ctx, _| {
1083 if let Some(plaza_wpos) = choose_plaza(ctx, visiting_site) {
1085 Either::Left(travel_to_point(plaza_wpos, 0.5)
1087 .debug(|| "walk to plaza"))
1088 } else {
1089 Either::Right(finish())
1091 }
1092 .then(socialize()
1094 .repeat()
1095 .map_state(|state: &mut DefaultState| &mut state.socialize_timer)
1096 .stop_if(timeout(ctx.rng.gen_range(30.0..90.0)))
1097 .debug(|| "wait at plaza"))
1098 .map(|_, _| ())
1099 }))
1100 })
1101 .debug(move || format!("villager at site {:?}", visiting_site))
1102}
1103
1104fn go_to_tavern(site_id: SiteId, tavern_plot: Id<site::Plot>) -> impl Action<DefaultState> {
1105 now(move |ctx, _| {
1106 let data = ctx.state.data();
1107 if let Some(site) = data.sites.get(site_id)
1108 && let Some(ws) = site.world_site
1109 && let PlotKind::Tavern(tavern) = ctx.index.sites.get(ws).plots.get(tavern_plot).kind()
1110 {
1111 let tavern_name = tavern.name.clone();
1112 let (stage_aabr, stage_z) = tavern
1113 .rooms
1114 .values()
1115 .flat_map(|room| {
1116 room.details.iter().filter_map(|detail| {
1117 match_some!(detail, tavern::Detail::Stage { aabr } => (*aabr, room.bounds.min.z + 1))
1118 })
1119 })
1120 .choose(&mut ctx.rng)
1121 .unwrap_or((tavern.bounds, tavern.door_wpos.z));
1122
1123 let bar_pos = tavern
1124 .rooms
1125 .values()
1126 .flat_map(|room| {
1127 room.details.iter().filter_map(|detail| match_some!(detail,
1128 tavern::Detail::Bar { aabr } => {
1129 let side = site::util::Dir::from_vec2(
1130 room.bounds.center().xy() - aabr.center(),
1131 );
1132 let pos = side.select_aabr_with(*aabr, aabr.center()) + side.to_vec2();
1133
1134 pos.with_z(room.bounds.min.z)
1135 },
1136 ))
1137 })
1138 .choose(&mut ctx.rng)
1139 .unwrap_or(stage_aabr.center().with_z(stage_z));
1140
1141 let chair_pos = tavern.rooms.values().flat_map(|room| {
1143 let z = room.bounds.min.z;
1144 room.details.iter().filter_map(move |detail| match_some!(detail,
1145 tavern::Detail::Table { pos, chairs } => chairs.into_iter().map(move |dir| pos.with_z(z) + dir.to_vec2())
1146 ))
1147 .flatten()
1148 }
1149 ).choose(&mut ctx.rng)
1150 .unwrap_or(bar_pos);
1152
1153 let stage_aabr = stage_aabr.as_::<f32>();
1154 let stage_z = stage_z as f32;
1155
1156 travel_to_point(tavern.door_wpos.xy().as_() + 0.5, 0.8).then(choose(move |ctx, (last_action, _)| {
1157 let action = [0, 1, 2].into_iter().filter(|i| *last_action != Some(*i)).choose(&mut ctx.rng).expect("We have at least 2 elements");
1158 let socialize_repeat = || socialize().map_state(|(_, timer)| timer).repeat();
1159 match action {
1160 0 => {
1162 casual(
1163 now(move |ctx, (last_action, _)| {
1164 *last_action = Some(action);
1165 goto(stage_aabr.min.map2(stage_aabr.max, |a, b| ctx.rng.gen_range(a..b)).with_z(stage_z), WALKING_SPEED, 1.0)
1166 })
1167 .then(just(move |ctx,_| ctx.controller.do_dance(None)).repeat().stop_if(timeout(ctx.rng.gen_range(20.0..30.0))))
1168 .map(|_, _| ())
1169 .debug(|| "Dancing on the stage")
1170 )
1171 },
1172 1 => {
1174 casual(
1175 now(move |ctx, (last_action, _)| {
1176 *last_action = Some(action);
1177 goto(chair_pos.as_() + 0.5, WALKING_SPEED, 1.0)
1178 .then(just(move |ctx, _| ctx.controller.do_sit(None, Some(chair_pos)))
1179 .repeat().stop_if(timeout(ctx.rng.gen_range(30.0..60.0)))
1181 )
1182 .map(|_, _| ())
1183 })
1184 .debug(move || format!("Sitting in a chair at {} {} {}", chair_pos.x, chair_pos.y, chair_pos.z))
1185 )
1186 },
1187 _ => {
1189 casual(
1190 now(move |ctx, (last_action, _)| {
1191 *last_action = Some(action);
1192 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(|_, _| ())
1193 }).debug(|| "At the bar")
1194 )
1195 },
1196 }
1197 })
1198 .with_state((None::<u32>, every_range(5.0..10.0)))
1199 .repeat())
1200 .map(|_, _| ())
1201 .debug(move || format!("At the tavern '{}'", tavern_name))
1202 .l()
1203 } else {
1204 just(|_, _| {}).r()
1205 }
1206 })
1207}
1208
1209fn pilot<S: State>(ship: common::comp::ship::Body) -> impl Action<S> {
1210 now(move |ctx, _| {
1212 let data = &*ctx.state.data();
1213 let station_wpos = data
1214 .sites
1215 .iter()
1216 .filter(|(id, _)| Some(*id) != ctx.npc.current_site)
1217 .filter_map(|(_, site)| Some(ctx.index.sites.get(site.world_site?)))
1218 .flat_map(|site| {
1219 site.filter_plots(|plot| {
1220 matches!(plot.kind().meta(), Some(PlotKindMeta::AirshipDock { .. }))
1221 })
1222 .map(|plot| site.tile_center_wpos(plot.root_tile()))
1223 })
1224 .choose(&mut ctx.rng);
1225 if let Some(station_wpos) = station_wpos {
1226 Either::Right(
1227 goto_2d_flying(
1228 station_wpos.as_(),
1229 1.0,
1230 50.0,
1231 150.0,
1232 110.0,
1233 ship.flying_height(),
1234 )
1235 .then(goto_2d_flying(
1236 station_wpos.as_(),
1237 1.0,
1238 10.0,
1239 32.0,
1240 16.0,
1241 30.0,
1242 )),
1243 )
1244 } else {
1245 Either::Left(finish())
1246 }
1247 })
1248 .repeat()
1249 .map(|_, _| ())
1250}
1251
1252fn captain<S: State>() -> impl Action<S> {
1253 now(|ctx, _| {
1255 let chunk = ctx.npc.wpos.xy().as_().wpos_to_cpos();
1256 if let Some(chunk) = NEIGHBORS
1257 .into_iter()
1258 .map(|neighbor| chunk + neighbor)
1259 .filter(|neighbor| {
1260 ctx.world
1261 .sim()
1262 .get(*neighbor)
1263 .is_some_and(|c| c.river.river_kind.is_some())
1264 })
1265 .choose(&mut ctx.rng)
1266 {
1267 let wpos = TerrainChunkSize::center_wpos(chunk);
1268 let wpos = wpos.as_().with_z(
1269 ctx.world
1270 .sim()
1271 .get_interpolated(wpos, |chunk| chunk.water_alt)
1272 .unwrap_or(0.0),
1273 );
1274 goto(wpos, 0.7, 5.0).boxed()
1275 } else {
1276 idle().boxed()
1277 }
1278 })
1279 .repeat()
1280 .map(|_, _| ())
1281}
1282
1283fn check_inbox<S: State>(ctx: &mut NpcCtx) -> Option<impl Action<S> + use<S>> {
1284 let mut action = None;
1285 ctx.inbox.retain(|input| {
1286 match input {
1287 NpcInput::Report(report_id) if !ctx.known_reports.contains(report_id) => {
1288 let data = ctx.state.data();
1289 let Some(report) = data.reports.get(*report_id) else {
1290 return false;
1291 };
1292
1293 const REPORT_RESPONSE_TIME: f64 = 60.0 * 5.0;
1294
1295 match report.kind {
1296 ReportKind::Death { killer, actor, .. }
1297 if matches!(&ctx.npc.role, Role::Civilised(_)) =>
1298 {
1299 let phrase = if let Some(killer) = killer {
1301 let can_damage_killer = if let Actor::Npc(killer) = killer {
1305 data.npcs.get(killer).is_some_and(|killer| {
1306 match (&ctx.npc.role, &killer.role) {
1307 (Role::Vehicle, _) | (_, Role::Vehicle) => false,
1308 (Role::Civilised(prof_a), Role::Civilised(prof_b)) => {
1309 match (prof_a, prof_b) {
1310 (
1311 Some(
1312 Profession::Pirate(_) | Profession::Cultist,
1313 ),
1314 Some(
1315 Profession::Pirate(_) | Profession::Cultist,
1316 ),
1317 ) => false,
1318 (
1319 Some(
1320 Profession::Pirate(_) | Profession::Cultist,
1321 ),
1322 _,
1323 )
1324 | (
1325 _,
1326 Some(
1327 Profession::Pirate(_) | Profession::Cultist,
1328 ),
1329 ) => true,
1330
1331 _ => false,
1332 }
1333 },
1334 (Role::Civilised(_), _) => true,
1335 (Role::Wild, Role::Wild) => false,
1336 (Role::Wild, _) => true,
1337 (Role::Monster, Role::Monster) => false,
1338 (Role::Monster, _) => true,
1339 }
1340 })
1341 } else {
1342 true
1343 };
1344
1345 let is_victim_inherent_enemy = if let Actor::Npc(victim) = actor {
1348 data.npcs.get(victim).is_some_and(|victim| {
1349 match (&ctx.npc.role, &victim.role) {
1350 (Role::Civilised(prof), Role::Civilised(victim_prof)) => {
1351 match (prof, victim_prof) {
1352 (
1353 Some(
1354 Profession::Pirate(_) | Profession::Cultist,
1355 ),
1356 Some(
1357 Profession::Pirate(_) | Profession::Cultist,
1358 ),
1359 ) => false,
1360 (
1361 Some(
1362 Profession::Pirate(_) | Profession::Cultist,
1363 ),
1364 _,
1365 )
1366 | (
1367 _,
1368 Some(
1369 Profession::Pirate(_) | Profession::Cultist,
1370 ),
1371 ) => true,
1372
1373 _ => false,
1374 }
1375 },
1376
1377 (Role::Civilised(_), Role::Monster) => true,
1378 _ => false,
1379 }
1380 })
1381 } else {
1382 false
1383 };
1384
1385 let is_victim_enemy = is_victim_inherent_enemy
1386 || ctx.sentiments.toward(actor).is(Sentiment::ENEMY);
1387
1388 if can_damage_killer {
1389 let change = if is_victim_enemy {
1391 0.25
1394 } else {
1395 -0.75
1396 };
1397 ctx.sentiments
1398 .toward_mut(killer)
1399 .change_by(change, Sentiment::VILLAIN);
1400 }
1401
1402 if let Actor::Character(_) = actor {
1405 ctx.sentiments
1406 .toward_mut(actor)
1407 .limit_below(Sentiment::ENEMY)
1408 }
1409
1410 if is_victim_enemy {
1411 "npc-speech-witness_enemy_murder"
1412 } else {
1413 "npc-speech-witness_murder"
1414 }
1415 } else {
1416 "npc-speech-witness_death"
1417 };
1418 ctx.known_reports.insert(*report_id);
1419
1420 if ctx.time_of_day.0 - report.at_tod.0 < REPORT_RESPONSE_TIME {
1421 action = Some(
1422 just(move |ctx, _| {
1423 ctx.controller.say(killer, Content::localized(phrase))
1424 })
1425 .l()
1426 .l(),
1427 );
1428 }
1429 false
1430 },
1431 ReportKind::Theft {
1432 thief,
1433 site,
1434 sprite,
1435 } => {
1436 if let Some(site) = site
1438 && ctx.npc.home == Some(site)
1439 {
1440 ctx.sentiments
1442 .toward_mut(thief)
1443 .change_by(-0.2, Sentiment::ENEMY);
1444 ctx.known_reports.insert(*report_id);
1445
1446 let phrase = if matches!(ctx.npc.profession(), Some(Profession::Farmer))
1447 && matches!(sprite.category(), sprite::Category::Plant)
1448 {
1449 "npc-speech-witness_theft_owned"
1450 } else {
1451 "npc-speech-witness_theft"
1452 };
1453
1454 if ctx.time_of_day.0 - report.at_tod.0 < REPORT_RESPONSE_TIME {
1455 action = Some(
1456 just(move |ctx, _| {
1457 ctx.controller.say(thief, Content::localized(phrase))
1458 })
1459 .r()
1460 .l(),
1461 );
1462 }
1463 }
1464 false
1465 },
1466 ReportKind::Death { .. } => false,
1468 }
1469 },
1470 NpcInput::Report(_) => false, NpcInput::Interaction(by) => {
1472 action = Some(talk_to(*by).r());
1473 false
1474 },
1475 NpcInput::Dialogue(_, _) => true,
1478 }
1479 });
1480
1481 action
1482}
1483
1484fn check_for_enemies<S: State>(ctx: &mut NpcCtx) -> Option<impl Action<S> + use<S>> {
1485 ctx.state
1492 .data()
1493 .npcs
1494 .nearby(Some(ctx.npc_id), ctx.npc.wpos, 24.0)
1495 .find(|actor| ctx.sentiments.toward(*actor).is(Sentiment::ENEMY))
1496 .map(|enemy| just(move |ctx, _| ctx.controller.attack(enemy)))
1497}
1498
1499fn react_to_events<S: State>(ctx: &mut NpcCtx, _: &mut S) -> Option<impl Action<S> + use<S>> {
1500 check_inbox::<S>(ctx)
1501 .map(|action| action.boxed())
1502 .or_else(|| check_for_enemies(ctx).map(|action| action.boxed()))
1503}
1504
1505fn humanoid() -> impl Action<DefaultState> {
1506 choose(|ctx, _| {
1507 if let Some(riding) = &ctx.state.data().npcs.mounts.get_mount_link(ctx.npc_id) {
1508 if riding.is_steering {
1509 if let Some(vehicle) = ctx.state.data().npcs.get(riding.mount) {
1510 match vehicle.body {
1511 comp::Body::Ship(body @ comp::ship::Body::AirBalloon) => {
1512 important(pilot(body))
1513 },
1514 comp::Body::Ship(comp::ship::Body::DefaultAirship) => {
1515 important(airship_ai::pilot_airship())
1516 },
1517 comp::Body::Ship(
1518 comp::ship::Body::SailBoat | comp::ship::Body::Galleon,
1519 ) => important(captain()),
1520 _ => casual(idle()),
1521 }
1522 } else {
1523 casual(finish())
1524 }
1525 } else {
1526 important(
1527 socialize().map_state(|state: &mut DefaultState| &mut state.socialize_timer),
1528 )
1529 }
1530 } else if let Some((tgt, _)) = ctx.npc.hiring
1531 && util::actor_exists(ctx, tgt)
1532 {
1533 important(hired(tgt).interrupt_with(react_to_events))
1534 } else {
1535 let action = match ctx.npc.profession() {
1536 Some(Profession::Adventurer(_) | Profession::Merchant) => adventure().l().l(),
1537 Some(Profession::Pirate(is_leader)) => pirate(is_leader).l().r(),
1538 _ => {
1539 if let Some(home) = ctx.npc.home {
1540 villager(home).r().l()
1541 } else {
1542 idle().r().r() }
1544 },
1545 };
1546
1547 casual(action.interrupt_with(react_to_events))
1548 }
1549 })
1550}
1551
1552fn bird_large() -> impl Action<DefaultState> {
1553 now(|ctx, bearing: &mut Vec2<f32>| {
1554 *bearing = bearing
1555 .map(|e| e + ctx.rng.gen_range(-0.1..0.1))
1556 .try_normalized()
1557 .unwrap_or_default();
1558 let bearing_dist = 15.0;
1559 let mut pos = ctx.npc.wpos.xy() + *bearing * bearing_dist;
1560 let is_deep_water =
1561 matches!(ctx.npc.body, common::comp::Body::BirdLarge(b) if matches!(b.species, bird_large::Species::SeaWyvern))
1562 || ctx
1563 .world
1564 .sim()
1565 .get(pos.as_().wpos_to_cpos()).is_none_or(|c| {
1566 c.alt - c.water_alt < -120.0 && (c.river.is_ocean() || c.river.is_lake())
1567 });
1568 if is_deep_water {
1569 *bearing *= -1.0;
1570 pos = ctx.npc.wpos.xy() + *bearing * bearing_dist;
1571 };
1572 let npc_pos = ctx.npc.wpos.xy();
1574 let trees = ctx
1575 .world
1576 .sim()
1577 .get(npc_pos.as_().wpos_to_cpos()).is_some_and(|c| c.tree_density > 0.1);
1578 let height_factor = if trees {
1579 2.0
1580 } else {
1581 ctx.rng.gen_range(0.4..0.9)
1582 };
1583
1584 let data = ctx.state.data();
1585 let mut dest_site = pos;
1587 if let Some(home) = ctx.npc.home {
1588 let is_home = ctx.npc.current_site == Some(home);
1589 if is_home {
1590 if let Some((id, _)) = data
1591 .sites
1592 .iter()
1593 .filter(|(id, site)| {
1594 *id != home
1595 && site.world_site.is_some_and(|site| {
1596 match ctx.npc.body {
1597 common::comp::Body::BirdLarge(b) => match b.species {
1598 bird_large::Species::Phoenix => matches!(ctx.index.sites.get(site).kind,
1599 Some(SiteKind::Terracotta
1600 | SiteKind::Haniwa
1601 | SiteKind::Myrmidon
1602 | SiteKind::Adlet
1603 | SiteKind::DwarvenMine
1604 | SiteKind::ChapelSite
1605 | SiteKind::Cultist
1606 | SiteKind::Gnarling
1607 | SiteKind::Sahagin
1608 | SiteKind::VampireCastle)),
1609 bird_large::Species::Cockatrice => matches!(ctx.index.sites.get(site).kind,
1610 Some(SiteKind::GiantTree)),
1611 bird_large::Species::Roc => matches!(ctx.index.sites.get(site).kind,
1612 Some(SiteKind::Haniwa
1613 | SiteKind::Cultist)),
1614 bird_large::Species::FlameWyvern => matches!(ctx.index.sites.get(site).kind,
1615 Some(SiteKind::DwarvenMine
1616 | SiteKind::Terracotta)),
1617 bird_large::Species::CloudWyvern => matches!(ctx.index.sites.get(site).kind,
1618 Some(SiteKind::ChapelSite
1619 | SiteKind::Sahagin)),
1620 bird_large::Species::FrostWyvern => matches!(ctx.index.sites.get(site).kind,
1621 Some(SiteKind::Adlet
1622 | SiteKind::Myrmidon)),
1623 bird_large::Species::SeaWyvern => matches!(ctx.index.sites.get(site).kind,
1624 Some(SiteKind::ChapelSite
1625 | SiteKind::Sahagin)),
1626 bird_large::Species::WealdWyvern => matches!(ctx.index.sites.get(site).kind,
1627 Some(SiteKind::GiantTree
1628 | SiteKind::Gnarling)),
1629 },
1630 _ => matches!(&ctx.index.sites.get(site).kind, Some(SiteKind::GiantTree)),
1631 }
1632 })
1633 })
1634 .choose(&mut ctx.rng)
1638 {
1639 ctx.controller.set_new_home(id)
1640 }
1641 } else if let Some(site) = data.sites.get(home) {
1642 dest_site = site.wpos.as_::<f32>()
1643 }
1644 }
1645 goto_2d_flying(
1646 pos,
1647 0.2,
1648 bearing_dist,
1649 8.0,
1650 8.0,
1651 ctx.npc.body.flying_height() * height_factor,
1652 )
1653 .stop_if(move |ctx: &mut NpcCtx| {
1656 ctx.npc.wpos.xy().distance_squared(pos) > (bearing_dist + 5.0).powi(2)
1657 || dest_site.distance_squared(pos) > dest_site.distance_squared(npc_pos)
1658 })
1659 .stop_if(timeout(10.0))
1661 .debug({
1662 let bearing = *bearing;
1663 move || format!("Moving with a bearing of {:?}", bearing)
1664 })
1665 })
1666 .repeat()
1667 .with_state(Vec2::<f32>::zero())
1668 .map(|_, _| ())
1669}
1670
1671fn monster() -> impl Action<DefaultState> {
1672 now(|ctx, bearing: &mut Vec2<f32>| {
1673 *bearing = bearing
1674 .map(|e| e + ctx.rng.gen_range(-0.1..0.1))
1675 .try_normalized()
1676 .unwrap_or_default();
1677 let bearing_dist = 24.0;
1678 let mut pos = ctx.npc.wpos.xy() + *bearing * bearing_dist;
1679 let is_deep_water = ctx
1680 .world
1681 .sim()
1682 .get(pos.as_().wpos_to_cpos())
1683 .is_none_or(|c| {
1684 c.alt - c.water_alt < -10.0 && (c.river.is_ocean() || c.river.is_lake())
1685 });
1686 if !is_deep_water {
1687 goto_2d(pos, 0.7, 8.0)
1688 } else {
1689 *bearing *= -1.0;
1690
1691 pos = ctx.npc.wpos.xy() + *bearing * 24.0;
1692
1693 goto_2d(pos, 0.7, 8.0)
1694 }
1695 .stop_if(move |ctx: &mut NpcCtx| {
1697 ctx.npc.wpos.xy().distance_squared(pos) > (bearing_dist + 5.0).powi(2)
1698 })
1699 .debug({
1700 let bearing = *bearing;
1701 move || format!("Moving with a bearing of {:?}", bearing)
1702 })
1703 })
1704 .repeat()
1705 .with_state(Vec2::<f32>::zero())
1706 .map(|_, _| ())
1707}
1708
1709fn think() -> impl Action<DefaultState> {
1710 now(|ctx, _| match ctx.npc.body {
1711 common::comp::Body::Humanoid(_) => humanoid().l().l().l(),
1712 common::comp::Body::BirdLarge(_) => bird_large().r().l().l(),
1713 _ => match &ctx.npc.role {
1714 Role::Civilised(_) => socialize()
1715 .map_state(|state: &mut DefaultState| &mut state.socialize_timer)
1716 .l()
1717 .r()
1718 .l(),
1719 Role::Monster => monster().r().r().l(),
1720 Role::Wild => idle().r(),
1721 Role::Vehicle => idle().r(),
1722 },
1723 })
1724 .interrupt_with(|ctx, _| {
1725 if let Some((_from, action)) = ctx.npc_dialogue.pop_front() {
1726 Some(action.with_state(()))
1727 } else {
1728 None
1729 }
1730 })
1731}