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