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