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