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