1mod airship_ai;
27pub mod dialogue;
28pub mod movement;
29pub mod util;
30
31use std::{collections::VecDeque, hash::BuildHasherDefault, sync::Arc};
32
33use crate::{
34 RtState, Rule, RuleError,
35 ai::{
36 Action, NpcCtx, State, casual, choose, finish, important, just, now,
37 predicate::{Chance, EveryRange, Predicate, every_range, timeout},
38 seq, until,
39 },
40 data::{
41 ReportKind, Sentiment, Sites,
42 npc::{Brain, DialogueSession, PathData, SimulationMode},
43 },
44 event::OnTick,
45};
46use common::{
47 assets::AssetExt,
48 astar::{Astar, PathResult},
49 comp::{
50 self, Content, bird_large,
51 compass::{Direction, Distance},
52 item::ItemDef,
53 },
54 path::Path,
55 rtsim::{
56 Actor, ChunkResource, DialogueKind, NpcInput, PersonalityTrait, Profession, Response, Role,
57 SiteId,
58 },
59 spiral::Spiral2d,
60 store::Id,
61 terrain::{CoordinateConversions, TerrainChunkSize, sprite},
62 time::DayPeriod,
63 util::Dir,
64};
65use core::ops::ControlFlow;
66use fxhash::FxHasher64;
67use itertools::{Either, Itertools};
68use rand::prelude::*;
69use rand_chacha::ChaChaRng;
70use rayon::iter::{IntoParallelRefMutIterator, ParallelIterator};
71use vek::*;
72use world::{
73 IndexRef, World,
74 civ::{self, Track},
75 site::{
76 self, PlotKind, Site as WorldSite, SiteKind, TileKind,
77 plot::{PlotKindMeta, tavern},
78 },
79 util::NEIGHBORS,
80};
81
82use self::{
83 movement::{
84 follow_actor, goto, goto_2d, goto_2d_flying, goto_actor, travel_to_point, travel_to_site,
85 },
86 util::do_dialogue,
87};
88
89const SIMULATED_TICK_SKIP: u64 = 10;
94
95pub struct NpcAi;
96
97#[derive(Clone)]
98struct DefaultState {
99 socialize_timer: EveryRange,
100 move_home_timer: Chance<EveryRange>,
101}
102
103impl Rule for NpcAi {
104 fn start(rtstate: &mut RtState) -> Result<Self, RuleError> {
105 let mut last_ticks: VecDeque<_> = [1.0 / 30.0; SIMULATED_TICK_SKIP as usize]
108 .into_iter()
109 .collect();
110
111 rtstate.bind::<Self, OnTick>(move |ctx| {
112 last_ticks.push_front(ctx.event.dt);
113 if last_ticks.len() >= SIMULATED_TICK_SKIP as usize {
114 last_ticks.pop_back();
115 }
116 let mut npc_data = {
119 let mut data = ctx.state.data_mut();
120 data.npcs
121 .iter_mut()
122 .filter(|(_, npc)| !npc.is_dead() && !matches!(npc.role, Role::Vehicle))
124 .filter(|(_, npc)| matches!(npc.mode, SimulationMode::Loaded) || (npc.seed as u64 + ctx.event.tick) % SIMULATED_TICK_SKIP == 0)
126 .map(|(npc_id, npc)| {
127 let controller = std::mem::take(&mut npc.controller);
128 let inbox = std::mem::take(&mut npc.inbox);
129 let sentiments = std::mem::take(&mut npc.sentiments);
130 let known_reports = std::mem::take(&mut npc.known_reports);
131 let brain = npc.brain.take().unwrap_or_else(|| Brain {
132 action: Box::new(think().repeat().with_state(DefaultState {
133 socialize_timer: every_range(15.0..30.0),
134 move_home_timer: every_range(400.0..2000.0).chance(0.5),
135 })),
136 });
137 (npc_id, controller, inbox, sentiments, known_reports, brain)
138 })
139 .collect::<Vec<_>>()
140 };
141
142 let simulated_dt = last_ticks.iter().sum::<f32>();
145
146 {
148 let data = &*ctx.state.data();
149
150 npc_data
151 .par_iter_mut()
152 .for_each(|(npc_id, controller, inbox, sentiments, known_reports, brain)| {
153 let npc = &data.npcs[*npc_id];
154
155 controller.reset();
156
157 brain.action.tick(&mut NpcCtx {
158 state: ctx.state,
159 world: ctx.world,
160 index: ctx.index,
161 time_of_day: ctx.event.time_of_day,
162 time: ctx.event.time,
163 npc,
164 npc_id: *npc_id,
165 controller,
166 inbox,
167 known_reports,
168 sentiments,
169 dt: if matches!(npc.mode, SimulationMode::Loaded) {
170 ctx.event.dt
171 } else {
172 simulated_dt
173 },
174 rng: ChaChaRng::from_seed(thread_rng().gen::<[u8; 32]>()),
175 system_data: &*ctx.system_data,
176 }, &mut ());
177
178 inbox.clear();
180 });
181 }
182
183 let mut data = ctx.state.data_mut();
185 for (npc_id, controller, inbox, sentiments, known_reports, brain) in npc_data {
186 data.npcs[npc_id].controller = controller;
187 data.npcs[npc_id].brain = Some(brain);
188 data.npcs[npc_id].inbox = inbox;
189 data.npcs[npc_id].sentiments = sentiments;
190 data.npcs[npc_id].known_reports = known_reports;
191 }
192 });
193
194 Ok(Self)
195 }
196}
197
198fn idle<S: State>() -> impl Action<S> + Clone {
199 just(|ctx, _| ctx.controller.do_idle()).debug(|| "idle")
200}
201
202fn talk_to<S: State>(tgt: Actor) -> impl Action<S> {
203 now(move |ctx, _| {
204 if ctx.sentiments.toward(tgt).is(Sentiment::ENEMY) {
205 just(move |ctx, _| {
206 ctx.controller
207 .say(tgt, Content::localized("npc-speech-reject_rival"))
208 })
209 .boxed()
210 } else if matches!(tgt, Actor::Character(_)) {
211 do_dialogue(tgt, move |session| dialogue::general(tgt, session)).boxed()
212 } else {
213 smalltalk_to(tgt).boxed()
214 }
215 })
216}
217
218fn tell_site_content(ctx: &NpcCtx, site: SiteId) -> Option<Content> {
219 if let Some(world_site) = ctx.state.data().sites.get(site)
220 && let Some(site_name) = util::site_name(ctx, site)
221 {
222 Some(Content::localized_with_args("npc-speech-tell_site", [
223 ("site", Content::Plain(site_name)),
224 (
225 "dir",
226 Direction::from_dir(world_site.wpos.as_() - ctx.npc.wpos.xy()).localize_npc(),
227 ),
228 (
229 "dist",
230 Distance::from_length(world_site.wpos.as_().distance(ctx.npc.wpos.xy()) as i32)
231 .localize_npc(),
232 ),
233 ]))
234 } else {
235 None
236 }
237}
238
239fn smalltalk_to<S: State>(tgt: Actor) -> impl Action<S> {
240 now(move |ctx, _| {
241 if matches!(tgt, Actor::Npc(_)) && ctx.rng.gen_bool(0.2) {
242 idle().boxed()
246 } else {
247 let comment = if ctx.rng.gen_bool(0.3)
249 && let Some(current_site) = ctx.npc.current_site
250 && let Some(current_site) = ctx.state.data().sites.get(current_site)
251 && let Some(mention_site) = current_site.nearby_sites_by_size.choose(&mut ctx.rng)
252 && let Some(content) = tell_site_content(ctx, *mention_site)
253 {
254 content
255 } else if ctx.rng.gen_bool(0.3)
257 && let Some(current_site) = ctx.npc.current_site
258 && let Some(current_site_name) = util::site_name(ctx, current_site)
259 {
260 Content::localized_with_args("npc-speech-site", [(
261 "site",
262 Content::Plain(current_site_name),
263 )])
264
265 } else if ctx.rng.gen_bool(0.3)
267 && let Some(monster) = ctx
268 .state
269 .data()
270 .npcs
271 .values()
272 .filter(|other| matches!(&other.role, Role::Monster))
273 .min_by_key(|other| other.wpos.xy().distance(ctx.npc.wpos.xy()) as i32)
274 {
275 Content::localized_with_args("npc-speech-tell_monster", [
276 ("body", monster.body.localize_npc()),
277 (
278 "dir",
279 Direction::from_dir(monster.wpos.xy() - ctx.npc.wpos.xy()).localize_npc(),
280 ),
281 (
282 "dist",
283 Distance::from_length(monster.wpos.xy().distance(ctx.npc.wpos.xy()) as i32)
284 .localize_npc(),
285 ),
286 ])
287 } else if ctx.rng.gen_bool(0.6) && DayPeriod::from(ctx.time_of_day.0).is_dark() {
289 Content::localized("npc-speech-night")
290 } else {
291 ctx.npc.personality.get_generic_comment(&mut ctx.rng)
292 };
293 let wait = if matches!(tgt, Actor::Character(_)) {
295 0.0
296 } else {
297 1.5
298 };
299 idle()
300 .repeat()
301 .stop_if(timeout(wait))
302 .then(just(move |ctx, _| ctx.controller.say(tgt, comment.clone())))
303 .boxed()
304 }
305 })
306}
307
308fn socialize() -> impl Action<EveryRange> {
309 now(move |ctx, socialize: &mut EveryRange| {
310 if matches!(ctx.npc.mode, SimulationMode::Loaded)
312 && socialize.should(ctx)
313 && !ctx.npc.personality.is(PersonalityTrait::Introverted)
314 {
315 if ctx.rng.gen_bool(0.15) {
317 return just(|ctx, _| ctx.controller.do_dance(None))
318 .repeat()
319 .stop_if(timeout(6.0))
320 .debug(|| "dancing")
321 .map(|_, _| ())
322 .l()
323 .l();
324 } else if let Some(other) = ctx
326 .state
327 .data()
328 .npcs
329 .nearby(Some(ctx.npc_id), ctx.npc.wpos, 8.0)
330 .choose(&mut ctx.rng)
331 {
332 return smalltalk_to(other)
333 .then(idle().repeat().stop_if(timeout(4.0)))
335 .map(|_, _| ())
336 .r().l();
337 }
338 }
339 idle().r()
340 })
341}
342
343fn adventure() -> impl Action<DefaultState> {
344 choose(|ctx, _| {
345 if let Some(tgt_site) = ctx
347 .state
348 .data()
349 .sites
350 .iter()
351 .filter(|(site_id, site)| {
352 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))
353 && ctx.rng.gen_bool(0.25)
354 })
355 .min_by_key(|(_, site)| site.wpos.as_().distance(ctx.npc.wpos.xy()) as i32)
356 .map(|(site_id, _)| site_id)
357 {
358 let wait_time = if matches!(ctx.npc.profession(), Some(Profession::Merchant)) {
359 60.0 * 15.0
360 } else {
361 60.0 * 3.0
362 };
363 let site_name = util::site_name(ctx, tgt_site).unwrap_or_default();
364 important(just(move |ctx, _| ctx.controller.say(None, Content::localized_with_args("npc-speech-moving_on", [("site", site_name.clone())])))
366 .then(travel_to_site(tgt_site, 0.6))
367 .then(villager(tgt_site).repeat().stop_if(timeout(wait_time)))
369 .map(|_, _| ())
370 .boxed(),
371 )
372 } else {
373 casual(finish().boxed())
374 }
375 })
376 .debug(move || "adventure")
377}
378
379fn hired<S: State>(tgt: Actor) -> impl Action<S> {
380 follow_actor(tgt, 5.0)
381 .stop_if(move |ctx: &mut NpcCtx| ctx.npc.hiring.is_none_or(|(a, _)| a != tgt))
383 .debug(move|| format!("hired by {tgt:?}"))
384 .interrupt_with(move |ctx, _| {
385 if let Some((tgt, expires)) = ctx.npc.hiring {
387 if ctx.time > expires {
389 ctx.controller.end_hiring();
390 if util::actor_exists(ctx, tgt) {
392 return Some(goto_actor(tgt, 2.0)
393 .then(do_dialogue(tgt, |session| {
394 session.say_statement(Content::localized("npc-dialogue-hire_expired"))
395 }))
396 .boxed());
397 }
398 }
399
400 if ctx.sentiments.toward(tgt).is(Sentiment::RIVAL) {
401 ctx.controller.end_hiring();
402 if util::actor_exists(ctx, tgt) {
404 return Some(goto_actor(tgt, 2.0)
405 .then(do_dialogue(tgt, |session| {
406 session.say_statement(Content::localized(
407 "npc-dialogue-hire_cancelled_unhappy",
408 ))
409 }))
410 .boxed());
411 }
412 }
413 }
414
415 None
416 })
417 .map(|_, _| ())
418}
419
420fn gather_ingredients<S: State>() -> impl Action<S> {
421 just(|ctx, _| {
422 ctx.controller.do_gather(
423 &[
424 ChunkResource::Fruit,
425 ChunkResource::Mushroom,
426 ChunkResource::Plant,
427 ][..],
428 )
429 })
430 .debug(|| "gather ingredients")
431}
432
433fn hunt_animals<S: State>() -> impl Action<S> {
434 just(|ctx, _| ctx.controller.do_hunt_animals()).debug(|| "hunt_animals")
435}
436
437fn find_forest(ctx: &mut NpcCtx) -> Option<Vec2<f32>> {
438 let chunk_pos = ctx.npc.wpos.xy().as_().wpos_to_cpos();
439 Spiral2d::new()
440 .skip(ctx.rng.gen_range(1..=64))
441 .take(24)
442 .map(|rpos| chunk_pos + rpos)
443 .find(|cpos| {
444 ctx.world
445 .sim()
446 .get(*cpos)
447 .is_some_and(|c| c.tree_density > 0.75 && c.surface_veg > 0.5)
448 })
449 .map(|chunk| TerrainChunkSize::center_wpos(chunk).as_())
450}
451
452fn find_farm(ctx: &mut NpcCtx, site: SiteId) -> Option<Vec2<f32>> {
453 ctx.state.data().sites.get(site).and_then(|site| {
454 let site = ctx.index.sites.get(site.world_site?);
455 let farm = site
456 .filter_plots(|p| matches!(p.kind(), PlotKind::FarmField(_)))
457 .choose(&mut ctx.rng)?;
458
459 Some(site.tile_center_wpos(farm.root_tile()).as_())
460 })
461}
462
463fn choose_plaza(ctx: &mut NpcCtx, site: SiteId) -> Option<Vec2<f32>> {
464 ctx.state.data().sites.get(site).and_then(|site| {
465 let site = ctx.index.sites.get(site.world_site?);
466 let plaza = &site.plots[site.plazas().choose(&mut ctx.rng)?];
467 let tile = plaza
468 .tiles()
469 .choose(&mut ctx.rng)
470 .unwrap_or_else(|| plaza.root_tile());
471 Some(site.tile_center_wpos(tile).as_())
472 })
473}
474
475const WALKING_SPEED: f32 = 0.35;
476
477fn villager(visiting_site: SiteId) -> impl Action<DefaultState> {
478 choose(move |ctx, state: &mut DefaultState| {
479 if state.move_home_timer.should(ctx)
481 && let Some(home) = ctx.npc.home
482 && Some(home) == ctx.npc.current_site
483 && let Some(home_pop_ratio) = ctx.state.data().sites.get(home)
484 .and_then(|site| Some((site, ctx.index.sites.get(site.world_site?))))
485 .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) } )
486 .filter(|pop_ratio| *pop_ratio > 1.5)
488 && let Some(new_home) = ctx
489 .state
490 .data()
491 .sites
492 .iter()
493 .filter(|(site_id, _)| Some(*site_id) != ctx.npc.home)
495 .filter_map(|(site_id, site)| {
497 let world_site = site.world_site.map(|ws| ctx.index.sites.get(ws))?;
498 let house_count = world_site.filter_plots(|p| matches!(p.meta(), Some(PlotKindMeta::House { .. }))).count();
499
500 if house_count == 0 {
501 return None;
502 }
503 Some((site_id, site, house_count))
504 })
505 .filter(|(_, site, houses)| (site.population.len() as f32 / *houses as f32) < home_pop_ratio)
507 .min_by_key(|(_, site, _)| site.wpos.as_().distance(ctx.npc.wpos.xy()) as i32)
509 .map(|(site_id, _, _)| site_id)
510 {
511 let site_name = util::site_name(ctx, new_home);
512 return important(just(move |ctx, _| {
513 if let Some(site_name) = &site_name {
514 ctx.controller.say(None, Content::localized_with_args("npc-speech-migrating", [("site", site_name.clone())]))
515 }
516 })
517 .then(travel_to_site(new_home, 0.5))
518 .then(just(move |ctx, _| ctx.controller.set_new_home(new_home))));
519 }
520 let day_period = DayPeriod::from(ctx.time_of_day.0);
521 let is_weekend = ctx.time_of_day.day() as u64 % 6 == 0;
522 let is_evening = day_period == DayPeriod::Evening;
523
524 let is_free_time = is_weekend || is_evening;
525
526 let is_raining = ctx.system_data.weather_grid.is_raining(ctx.npc.wpos.xy());
527
528 if day_period.is_dark()
530 && !matches!(ctx.npc.profession(), Some(Profession::Guard))
531 {
532 return important(
533 now(move |ctx, _| {
534 if let Some(house_wpos) = ctx
535 .state
536 .data()
537 .sites
538 .get(visiting_site)
539 .and_then(|site| Some(ctx.index.sites.get(site.world_site?)))
540 .and_then(|site| {
541 let house = site
543 .plots()
544 .filter(|p| matches!(p.kind().meta(), Some(PlotKindMeta::House { .. })))
545 .choose(&mut ctx.rng)?;
546 Some(site.tile_center_wpos(house.root_tile()).as_())
547 })
548 {
549 just(|ctx, _| {
550 ctx.controller
551 .say(None, Content::localized("npc-speech-night_time"))
552 })
553 .then(travel_to_point(house_wpos, 0.65))
554 .debug(|| "walk to house")
555 .then(socialize().repeat().map_state(|state: &mut DefaultState| &mut state.socialize_timer).debug(|| "wait in house"))
556 .stop_if(|ctx: &mut NpcCtx| DayPeriod::from(ctx.time_of_day.0).is_light())
557 .then(just(|ctx, _| {
558 ctx.controller
559 .say(None, Content::localized("npc-speech-day_time"))
560 }))
561 .map(|_, _| ())
562 .boxed()
563 } else {
564 finish().boxed()
565 }
566 })
567 .debug(|| "find somewhere to sleep"),
568 );
569 }
570 else if is_raining
572 && !matches!(ctx.npc.profession(), Some(Profession::Guard))
573 {
574 return important(
575 now(move |ctx, _| {
576 if let Some(house_wpos) = ctx
577 .state
578 .data()
579 .sites
580 .get(visiting_site)
581 .and_then(|site| Some(ctx.index.sites.get(site.world_site?)))
582 .and_then(|site| {
583 let house = site
585 .plots()
586 .filter(|p| matches!(p.kind().meta(), Some(PlotKindMeta::House { .. })))
587 .choose(&mut ctx.rng)?;
588 Some(site.tile_center_wpos(house.root_tile()).as_())
589 })
590 {
591 just(|ctx, _| {
592 ctx.controller.say(None, Content::localized("npc-speech-seeking_shelter_rain"))
593 })
594 .then(travel_to_point(house_wpos, 0.65))
595 .debug(|| "walk to house (rain)")
596 .then(socialize().repeat().map_state(|state: &mut DefaultState| &mut state.socialize_timer).debug(|| "wait in house (rain)"))
597 .stop_if(|ctx: &mut NpcCtx| {
598 let is_raining = ctx.system_data.weather_grid.is_raining(ctx.npc.wpos.xy());
599 !is_raining
600 })
601 .then(just(|ctx, _| {
602 ctx.controller.say(None, Content::localized("npc-speech-rain_stopped"))
603 }))
604 .map(|_, _| ())
605 .boxed()
606 } else {
607 finish().boxed()
608 }
609 })
610 .debug(|| "find somewhere to wait (rain)"),
611 );
612 }
613 else if
615 !matches!(ctx.npc.profession(), Some(Profession::Guard | Profession::Chef))
617 && (matches!(day_period, DayPeriod::Evening) || is_free_time || ctx.rng.gen_bool(0.05)) {
618 let mut fun_activities = Vec::new();
619
620 if let Some(ws_id) = ctx.state.data().sites[visiting_site].world_site {
621 let ws = ctx.index.sites.get(ws_id);
622 if let Some(arena) = ws.plots().find_map(|p| match p.kind() { PlotKind::DesertCityArena(a) => Some(a), _ => None}) {
623 let wait_time = ctx.rng.gen_range(100.0..300.0);
624 let arena_center = Vec3::new(arena.center.x, arena.center.y, arena.base).as_::<f32>();
629 let stand_dist = arena.stand_dist as f32;
630 let seat_var_width = ctx.rng.gen_range(0..arena.stand_width) as f32;
631 let seat_var_length = ctx.rng.gen_range(-arena.stand_length..arena.stand_length) as f32;
632 let seat = match ctx.rng.gen_range(0..4) {
634 0 => Vec3::new(arena_center.x - stand_dist + seat_var_width, arena_center.y + seat_var_length, arena_center.z),
635 1 => Vec3::new(arena_center.x + stand_dist - seat_var_width, arena_center.y + seat_var_length, arena_center.z),
636 2 => Vec3::new(arena_center.x + seat_var_length, arena_center.y - stand_dist + seat_var_width, arena_center.z),
637 _ => Vec3::new(arena_center.x + seat_var_length, arena_center.y + stand_dist - seat_var_width, arena_center.z),
638 };
639 let look_dir = Dir::from_unnormalized(arena_center - seat);
640 let action = casual(just(move |ctx, _| ctx.controller.say(None, Content::localized("npc-speech-arena")))
642 .then(goto_2d(seat.xy(), 0.6, 1.0).debug(|| "go to arena"))
643 .then(choose(move |ctx, _| if ctx.rng.gen_bool(0.3) {
645 casual(just(move |ctx,_| ctx.controller.do_cheer(look_dir)).repeat().stop_if(timeout(5.0)))
646 } else if ctx.rng.gen_bool(0.15) {
647 casual(just(move |ctx,_| ctx.controller.do_dance(look_dir)).repeat().stop_if(timeout(5.0)))
648 } else {
649 casual(just(move |ctx,_| ctx.controller.do_sit(look_dir, None)).repeat().stop_if(timeout(15.0)))
650 })
651 .repeat()
652 .stop_if(timeout(wait_time)))
653 .map(|_, _| ())
654 .boxed());
655 fun_activities.push(action);
656 }
657 if let Some(tavern) = ws.plots().filter_map(|p| match p.kind() { PlotKind::Tavern(a) => Some(a), _ => None }).choose(&mut ctx.rng) {
658 let tavern_name = tavern.name.clone();
659 let wait_time = ctx.rng.gen_range(100.0..300.0);
660
661 let (stage_aabr, stage_z) = tavern.rooms.values().flat_map(|room| {
662 room.details.iter().filter_map(|detail| match detail {
663 tavern::Detail::Stage { aabr } => Some((*aabr, room.bounds.min.z + 1)),
664 _ => None,
665 })
666 }).choose(&mut ctx.rng).unwrap_or((tavern.bounds, tavern.door_wpos.z));
667
668 let bar_pos = tavern.rooms.values().flat_map(|room|
669 room.details.iter().filter_map(|detail| match detail {
670 tavern::Detail::Bar { aabr } => {
671 let side = site::util::Dir::from_vec2(room.bounds.center().xy() - aabr.center());
672 let pos = side.select_aabr_with(*aabr, aabr.center()) + side.to_vec2();
673
674 Some(pos.with_z(room.bounds.min.z))
675 }
676 _ => None,
677 })
678 ).choose(&mut ctx.rng).unwrap_or(stage_aabr.center().with_z(stage_z));
679
680 let chair_pos = tavern.rooms.values().flat_map(|room| {
682 let z = room.bounds.min.z;
683 room.details.iter().filter_map(move |detail| match detail {
684 tavern::Detail::Table { pos, chairs } => Some(chairs.into_iter().map(move |dir| pos.with_z(z) + dir.to_vec2())),
685 _ => None,
686 })
687 .flatten()
688 }
689 ).choose(&mut ctx.rng)
690 .unwrap_or(bar_pos);
692
693 let stage_aabr = stage_aabr.as_::<f32>();
694 let stage_z = stage_z as f32;
695
696 let action = casual(travel_to_point(tavern.door_wpos.xy().as_() + 0.5, 0.8).then(choose(move |ctx, (last_action, _)| {
697 let action = [0, 1, 2].into_iter().filter(|i| *last_action != Some(*i)).choose(&mut ctx.rng).expect("We have at least 2 elements");
698 let socialize_repeat = || socialize().map_state(|(_, timer)| timer).repeat();
699 match action {
700 0 => {
702 casual(
703 now(move |ctx, (last_action, _)| {
704 *last_action = Some(action);
705 goto(stage_aabr.min.map2(stage_aabr.max, |a, b| ctx.rng.gen_range(a..b)).with_z(stage_z), WALKING_SPEED, 1.0)
706 })
707 .then(just(move |ctx,_| ctx.controller.do_dance(None)).repeat().stop_if(timeout(ctx.rng.gen_range(20.0..30.0))))
708 .map(|_, _| ())
709 .debug(|| "Dancing on the stage")
710 )
711 },
712 1 => {
714 casual(
715 now(move |ctx, (last_action, _)| {
716 *last_action = Some(action);
717 goto(chair_pos.as_() + 0.5, WALKING_SPEED, 1.0)
718 .then(just(move |ctx, _| ctx.controller.do_sit(None, Some(chair_pos)))
719 .repeat().stop_if(timeout(ctx.rng.gen_range(30.0..60.0)))
721 )
722 .map(|_, _| ())
723 })
724 .debug(move || format!("Sitting in a chair at {} {} {}", chair_pos.x, chair_pos.y, chair_pos.z))
725 )
726 },
727 _ => {
729 casual(
730 now(move |ctx, (last_action, _)| {
731 *last_action = Some(action);
732 goto(bar_pos.as_() + 0.5, WALKING_SPEED, 1.0).then(socialize_repeat().stop_if(timeout(ctx.rng.gen_range(10.0..25.0)))).map(|_, _| ())
733 }).debug(|| "At the bar")
734 )
735 },
736 }
737 })
738 .with_state((None::<u32>, every_range(5.0..10.0)))
739 .repeat()
740 .stop_if(timeout(wait_time)))
741 .map(|_, _| ())
742 .debug(move || format!("At the tavern '{}'", tavern_name))
743 .boxed()
744 );
745
746 fun_activities.push(action);
747 }
748 }
749
750
751 if !fun_activities.is_empty() {
752 let i = ctx.rng.gen_range(0..fun_activities.len());
753 return fun_activities.swap_remove(i);
754 }
755 }
756 else if matches!(ctx.npc.profession(), Some(Profession::Herbalist)) && ctx.rng.gen_bool(0.8)
758 {
759 if let Some(forest_wpos) = find_forest(ctx) {
760 return casual(
761 travel_to_point(forest_wpos, 0.5)
762 .debug(|| "walk to forest")
763 .then({
764 let wait_time = ctx.rng.gen_range(10.0..30.0);
765 gather_ingredients().repeat().stop_if(timeout(wait_time))
766 })
767 .map(|_, _| ()),
768 );
769 }
770 } else if matches!(ctx.npc.profession(), Some(Profession::Farmer)) && ctx.rng.gen_bool(0.8)
771 {
772 if let Some(farm_wpos) = find_farm(ctx, visiting_site) {
773 return casual(
774 travel_to_point(farm_wpos, 0.5)
775 .debug(|| "walk to farm")
776 .then({
777 let wait_time = ctx.rng.gen_range(30.0..120.0);
778 gather_ingredients().repeat().stop_if(timeout(wait_time))
779 })
780 .map(|_, _| ()),
781 );
782 }
783 } else if matches!(ctx.npc.profession(), Some(Profession::Hunter)) && ctx.rng.gen_bool(0.8) {
784 if let Some(forest_wpos) = find_forest(ctx) {
785 return casual(
786 just(|ctx, _| {
787 ctx.controller
788 .say(None, Content::localized("npc-speech-start_hunting"))
789 })
790 .then(travel_to_point(forest_wpos, 0.75))
791 .debug(|| "walk to forest")
792 .then({
793 let wait_time = ctx.rng.gen_range(30.0..60.0);
794 hunt_animals().repeat().stop_if(timeout(wait_time))
795 })
796 .map(|_, _| ()),
797 );
798 }
799 } else if matches!(ctx.npc.profession(), Some(Profession::Guard)) && ctx.rng.gen_bool(0.7) {
800 if let Some(plaza_wpos) = choose_plaza(ctx, visiting_site) {
801 return casual(
802 travel_to_point(plaza_wpos, 0.4)
803 .debug(|| "patrol")
804 .interrupt_with(move |ctx, _| {
805 if ctx.rng.gen_bool(0.0003) {
806 Some(just(move |ctx, _| {
807 ctx.controller
808 .say(None, Content::localized("npc-speech-guard_thought"))
809 }))
810 } else {
811 None
812 }
813 })
814 .map(|_, _| ()),
815 );
816 }
817 } else if matches!(ctx.npc.profession(), Some(Profession::Merchant)) && ctx.rng.gen_bool(0.8)
818 {
819 return casual(
820 just(|ctx, _| {
821 let (target, phrase) = if ctx.rng.gen_bool(0.3) && let Some(other) = ctx
823 .state
824 .data()
825 .npcs
826 .nearby(Some(ctx.npc_id), ctx.npc.wpos, 8.0)
827 .choose(&mut ctx.rng)
828 {
829 (Some(other), "npc-speech-merchant_sell_directed")
830 } else {
831 (None, "npc-speech-merchant_sell_undirected")
833 };
834
835 ctx.controller.say(target, Content::localized(phrase));
836 })
837 .then(idle().repeat().stop_if(timeout(8.0)))
838 .repeat()
839 .stop_if(timeout(60.0))
840 .debug(|| "sell wares")
841 .map(|_, _| ()),
842 );
843 } else if matches!(ctx.npc.profession(), Some(Profession::Chef))
844 && ctx.rng.gen_bool(0.8)
845 && let Some(ws_id) = ctx.state.data().sites[visiting_site].world_site
846 && let Some(tavern) = ctx.index.sites.get(ws_id).plots().filter_map(|p| match p.kind() { PlotKind::Tavern(a) => Some(a), _ => None }).choose(&mut ctx.rng)
847 && let Some((bar_pos, room_center)) = tavern.rooms.values().flat_map(|room|
848 room.details.iter().filter_map(|detail| match detail {
849 tavern::Detail::Bar { aabr } => {
850 let center = aabr.center();
851 Some((center.with_z(room.bounds.min.z), room.bounds.center().xy()))
852 }
853 _ => None,
854 })
855 ).choose(&mut ctx.rng) {
856
857 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));
858
859 return casual(
860 travel_to_point(tavern.door_wpos.xy().as_(), 0.5)
861 .then(goto(bar_pos.as_() + Vec2::new(0.5, 0.5), WALKING_SPEED, 2.0))
862 .then(just(move |ctx, _| ctx.controller.do_dance(Some(face_dir))).repeat().stop_if(timeout(60.0)))
864 .debug(|| "cook food").map(|_, _| ())
865 )
866 }
867
868 casual(now(move |ctx, _| {
870 if let Some(plaza_wpos) = choose_plaza(ctx, visiting_site) {
872 Either::Left(travel_to_point(plaza_wpos, 0.5)
874 .debug(|| "walk to plaza"))
875 } else {
876 Either::Right(finish())
878 }
879 .then(socialize()
881 .repeat()
882 .map_state(|state: &mut DefaultState| &mut state.socialize_timer)
883 .stop_if(timeout(ctx.rng.gen_range(30.0..90.0)))
884 .debug(|| "wait at plaza"))
885 .map(|_, _| ())
886 }))
887 })
888 .debug(move || format!("villager at site {:?}", visiting_site))
889}
890
891fn pilot<S: State>(ship: common::comp::ship::Body) -> impl Action<S> {
892 now(move |ctx, _| {
894 let data = &*ctx.state.data();
895 let station_wpos = data
896 .sites
897 .iter()
898 .filter(|(id, _)| Some(*id) != ctx.npc.current_site)
899 .filter_map(|(_, site)| Some(ctx.index.sites.get(site.world_site?)))
900 .flat_map(|site| {
901 site.filter_plots(|plot| {
902 matches!(plot.kind().meta(), Some(PlotKindMeta::AirshipDock { .. }))
903 })
904 .map(|plot| site.tile_center_wpos(plot.root_tile()))
905 })
906 .choose(&mut ctx.rng);
907 if let Some(station_wpos) = station_wpos {
908 Either::Right(
909 goto_2d_flying(
910 station_wpos.as_(),
911 1.0,
912 50.0,
913 150.0,
914 110.0,
915 ship.flying_height(),
916 )
917 .then(goto_2d_flying(
918 station_wpos.as_(),
919 1.0,
920 10.0,
921 32.0,
922 16.0,
923 30.0,
924 )),
925 )
926 } else {
927 Either::Left(finish())
928 }
929 })
930 .repeat()
931 .map(|_, _| ())
932}
933
934fn captain<S: State>() -> impl Action<S> {
935 now(|ctx, _| {
937 let chunk = ctx.npc.wpos.xy().as_().wpos_to_cpos();
938 if let Some(chunk) = NEIGHBORS
939 .into_iter()
940 .map(|neighbor| chunk + neighbor)
941 .filter(|neighbor| {
942 ctx.world
943 .sim()
944 .get(*neighbor)
945 .is_some_and(|c| c.river.river_kind.is_some())
946 })
947 .choose(&mut ctx.rng)
948 {
949 let wpos = TerrainChunkSize::center_wpos(chunk);
950 let wpos = wpos.as_().with_z(
951 ctx.world
952 .sim()
953 .get_interpolated(wpos, |chunk| chunk.water_alt)
954 .unwrap_or(0.0),
955 );
956 goto(wpos, 0.7, 5.0).boxed()
957 } else {
958 idle().boxed()
959 }
960 })
961 .repeat()
962 .map(|_, _| ())
963}
964
965fn check_inbox<S: State>(ctx: &mut NpcCtx) -> Option<impl Action<S> + use<S>> {
966 let mut action = None;
967 ctx.inbox.retain(|input| {
968 match input {
969 NpcInput::Report(report_id) if !ctx.known_reports.contains(report_id) => {
970 let data = ctx.state.data();
971 let Some(report) = data.reports.get(*report_id) else {
972 return false;
973 };
974
975 const REPORT_RESPONSE_TIME: f64 = 60.0 * 5.0;
976
977 match report.kind {
978 ReportKind::Death { killer, actor, .. }
979 if matches!(&ctx.npc.role, Role::Civilised(_)) =>
980 {
981 let phrase = if let Some(killer) = killer {
983 if !matches!(killer, Actor::Npc(_)) {
987 let change = if ctx.sentiments.toward(actor).is(Sentiment::ENEMY) {
989 0.25
992 } else {
993 -0.75
994 };
995 ctx.sentiments
996 .toward_mut(killer)
997 .change_by(change, Sentiment::VILLAIN);
998 }
999
1000 if let Actor::Character(_) = actor {
1003 ctx.sentiments
1004 .toward_mut(actor)
1005 .limit_below(Sentiment::ENEMY)
1006 }
1007
1008 if ctx.sentiments.toward(actor).is(Sentiment::ENEMY) {
1009 "npc-speech-witness_enemy_murder"
1010 } else {
1011 "npc-speech-witness_murder"
1012 }
1013 } else {
1014 "npc-speech-witness_death"
1015 };
1016 ctx.known_reports.insert(*report_id);
1017
1018 if ctx.time_of_day.0 - report.at_tod.0 < REPORT_RESPONSE_TIME {
1019 action = Some(
1020 just(move |ctx, _| {
1021 ctx.controller.say(killer, Content::localized(phrase))
1022 })
1023 .l()
1024 .l(),
1025 );
1026 }
1027 false
1028 },
1029 ReportKind::Theft {
1030 thief,
1031 site,
1032 sprite,
1033 } => {
1034 if let Some(site) = site
1036 && ctx.npc.home == Some(site)
1037 {
1038 ctx.sentiments
1040 .toward_mut(thief)
1041 .change_by(-0.2, Sentiment::ENEMY);
1042 ctx.known_reports.insert(*report_id);
1043
1044 let phrase = if matches!(ctx.npc.profession(), Some(Profession::Farmer))
1045 && matches!(sprite.category(), sprite::Category::Plant)
1046 {
1047 "npc-speech-witness_theft_owned"
1048 } else {
1049 "npc-speech-witness_theft"
1050 };
1051
1052 if ctx.time_of_day.0 - report.at_tod.0 < REPORT_RESPONSE_TIME {
1053 action = Some(
1054 just(move |ctx, _| {
1055 ctx.controller.say(thief, Content::localized(phrase))
1056 })
1057 .r()
1058 .l(),
1059 );
1060 }
1061 }
1062 false
1063 },
1064 ReportKind::Death { .. } => false,
1066 }
1067 },
1068 NpcInput::Report(_) => false, NpcInput::Interaction(by) => {
1070 action = Some(talk_to(*by).r());
1071 false
1072 },
1073 NpcInput::Dialogue(_, _) => true,
1076 }
1077 });
1078
1079 action
1080}
1081
1082fn check_for_enemies<S: State>(ctx: &mut NpcCtx) -> Option<impl Action<S> + use<S>> {
1083 ctx.state
1090 .data()
1091 .npcs
1092 .nearby(Some(ctx.npc_id), ctx.npc.wpos, 24.0)
1093 .find(|actor| ctx.sentiments.toward(*actor).is(Sentiment::ENEMY))
1094 .map(|enemy| just(move |ctx, _| ctx.controller.attack(enemy)))
1095}
1096
1097fn react_to_events<S: State>(ctx: &mut NpcCtx, _: &mut S) -> Option<impl Action<S> + use<S>> {
1098 check_inbox::<S>(ctx)
1099 .map(|action| action.boxed())
1100 .or_else(|| check_for_enemies(ctx).map(|action| action.boxed()))
1101}
1102
1103fn humanoid() -> impl Action<DefaultState> {
1104 choose(|ctx, _| {
1105 if let Some(riding) = &ctx.state.data().npcs.mounts.get_mount_link(ctx.npc_id) {
1106 if riding.is_steering {
1107 if let Some(vehicle) = ctx.state.data().npcs.get(riding.mount) {
1108 match vehicle.body {
1109 comp::Body::Ship(body @ comp::ship::Body::AirBalloon) => {
1110 important(pilot(body))
1111 },
1112 comp::Body::Ship(comp::ship::Body::DefaultAirship) => {
1113 important(airship_ai::pilot_airship())
1114 },
1115 comp::Body::Ship(
1116 comp::ship::Body::SailBoat | comp::ship::Body::Galleon,
1117 ) => important(captain()),
1118 _ => casual(idle()),
1119 }
1120 } else {
1121 casual(finish())
1122 }
1123 } else {
1124 important(
1125 socialize().map_state(|state: &mut DefaultState| &mut state.socialize_timer),
1126 )
1127 }
1128 } else if let Some((tgt, _)) = ctx.npc.hiring
1129 && util::actor_exists(ctx, tgt)
1130 {
1131 important(hired(tgt).interrupt_with(react_to_events))
1132 } else {
1133 let action = if matches!(
1134 ctx.npc.profession(),
1135 Some(Profession::Adventurer(_) | Profession::Merchant)
1136 ) {
1137 adventure().l().l()
1138 } else if let Some(home) = ctx.npc.home {
1139 villager(home).r().l()
1140 } else {
1141 idle().r() };
1143
1144 casual(action.interrupt_with(react_to_events))
1145 }
1146 })
1147}
1148
1149fn bird_large() -> impl Action<DefaultState> {
1150 now(|ctx, bearing: &mut Vec2<f32>| {
1151 *bearing = bearing
1152 .map(|e| e + ctx.rng.gen_range(-0.1..0.1))
1153 .try_normalized()
1154 .unwrap_or_default();
1155 let bearing_dist = 15.0;
1156 let mut pos = ctx.npc.wpos.xy() + *bearing * bearing_dist;
1157 let is_deep_water =
1158 matches!(ctx.npc.body, common::comp::Body::BirdLarge(b) if matches!(b.species, bird_large::Species::SeaWyvern))
1159 || ctx
1160 .world
1161 .sim()
1162 .get(pos.as_().wpos_to_cpos()).is_none_or(|c| {
1163 c.alt - c.water_alt < -120.0 && (c.river.is_ocean() || c.river.is_lake())
1164 });
1165 if is_deep_water {
1166 *bearing *= -1.0;
1167 pos = ctx.npc.wpos.xy() + *bearing * bearing_dist;
1168 };
1169 let npc_pos = ctx.npc.wpos.xy();
1171 let trees = ctx
1172 .world
1173 .sim()
1174 .get(npc_pos.as_().wpos_to_cpos()).is_some_and(|c| c.tree_density > 0.1);
1175 let height_factor = if trees {
1176 2.0
1177 } else {
1178 ctx.rng.gen_range(0.4..0.9)
1179 };
1180
1181 let data = ctx.state.data();
1182 let mut dest_site = pos;
1184 if let Some(home) = ctx.npc.home {
1185 let is_home = ctx.npc.current_site == Some(home);
1186 if is_home {
1187 if let Some((id, _)) = data
1188 .sites
1189 .iter()
1190 .filter(|(id, site)| {
1191 *id != home
1192 && site.world_site.is_some_and(|site| {
1193 match ctx.npc.body {
1194 common::comp::Body::BirdLarge(b) => match b.species {
1195 bird_large::Species::Phoenix => matches!(ctx.index.sites.get(site).kind,
1196 Some(SiteKind::Terracotta
1197 | SiteKind::Haniwa
1198 | SiteKind::Myrmidon
1199 | SiteKind::Adlet
1200 | SiteKind::DwarvenMine
1201 | SiteKind::ChapelSite
1202 | SiteKind::Cultist
1203 | SiteKind::Gnarling
1204 | SiteKind::Sahagin
1205 | SiteKind::VampireCastle)),
1206 bird_large::Species::Cockatrice => matches!(ctx.index.sites.get(site).kind,
1207 Some(SiteKind::GiantTree)),
1208 bird_large::Species::Roc => matches!(ctx.index.sites.get(site).kind,
1209 Some(SiteKind::Haniwa
1210 | SiteKind::Cultist)),
1211 bird_large::Species::FlameWyvern => matches!(ctx.index.sites.get(site).kind,
1212 Some(SiteKind::DwarvenMine
1213 | SiteKind::Terracotta)),
1214 bird_large::Species::CloudWyvern => matches!(ctx.index.sites.get(site).kind,
1215 Some(SiteKind::ChapelSite
1216 | SiteKind::Sahagin)),
1217 bird_large::Species::FrostWyvern => matches!(ctx.index.sites.get(site).kind,
1218 Some(SiteKind::Adlet
1219 | SiteKind::Myrmidon)),
1220 bird_large::Species::SeaWyvern => matches!(ctx.index.sites.get(site).kind,
1221 Some(SiteKind::ChapelSite
1222 | SiteKind::Sahagin)),
1223 bird_large::Species::WealdWyvern => matches!(ctx.index.sites.get(site).kind,
1224 Some(SiteKind::GiantTree
1225 | SiteKind::Gnarling)),
1226 },
1227 _ => matches!(&ctx.index.sites.get(site).kind, Some(SiteKind::GiantTree)),
1228 }
1229 })
1230 })
1231 .choose(&mut ctx.rng)
1235 {
1236 ctx.controller.set_new_home(id)
1237 }
1238 } else if let Some(site) = data.sites.get(home) {
1239 dest_site = site.wpos.as_::<f32>()
1240 }
1241 }
1242 goto_2d_flying(
1243 pos,
1244 0.2,
1245 bearing_dist,
1246 8.0,
1247 8.0,
1248 ctx.npc.body.flying_height() * height_factor,
1249 )
1250 .stop_if(move |ctx: &mut NpcCtx| {
1253 ctx.npc.wpos.xy().distance_squared(pos) > (bearing_dist + 5.0).powi(2)
1254 || dest_site.distance_squared(pos) > dest_site.distance_squared(npc_pos)
1255 })
1256 .stop_if(timeout(10.0))
1258 .debug({
1259 let bearing = *bearing;
1260 move || format!("Moving with a bearing of {:?}", bearing)
1261 })
1262 })
1263 .repeat()
1264 .with_state(Vec2::<f32>::zero())
1265 .map(|_, _| ())
1266}
1267
1268fn monster() -> impl Action<DefaultState> {
1269 now(|ctx, bearing: &mut Vec2<f32>| {
1270 *bearing = bearing
1271 .map(|e| e + ctx.rng.gen_range(-0.1..0.1))
1272 .try_normalized()
1273 .unwrap_or_default();
1274 let bearing_dist = 24.0;
1275 let mut pos = ctx.npc.wpos.xy() + *bearing * bearing_dist;
1276 let is_deep_water = ctx
1277 .world
1278 .sim()
1279 .get(pos.as_().wpos_to_cpos())
1280 .is_none_or(|c| {
1281 c.alt - c.water_alt < -10.0 && (c.river.is_ocean() || c.river.is_lake())
1282 });
1283 if !is_deep_water {
1284 goto_2d(pos, 0.7, 8.0)
1285 } else {
1286 *bearing *= -1.0;
1287
1288 pos = ctx.npc.wpos.xy() + *bearing * 24.0;
1289
1290 goto_2d(pos, 0.7, 8.0)
1291 }
1292 .stop_if(move |ctx: &mut NpcCtx| {
1294 ctx.npc.wpos.xy().distance_squared(pos) > (bearing_dist + 5.0).powi(2)
1295 })
1296 .debug({
1297 let bearing = *bearing;
1298 move || format!("Moving with a bearing of {:?}", bearing)
1299 })
1300 })
1301 .repeat()
1302 .with_state(Vec2::<f32>::zero())
1303 .map(|_, _| ())
1304}
1305
1306fn think() -> impl Action<DefaultState> {
1307 now(|ctx, _| match ctx.npc.body {
1308 common::comp::Body::Humanoid(_) => humanoid().l().l().l(),
1309 common::comp::Body::BirdLarge(_) => bird_large().r().l().l(),
1310 _ => match &ctx.npc.role {
1311 Role::Civilised(_) => socialize()
1312 .map_state(|state: &mut DefaultState| &mut state.socialize_timer)
1313 .l()
1314 .r()
1315 .l(),
1316 Role::Monster => monster().r().r().l(),
1317 Role::Wild => idle().r(),
1318 Role::Vehicle => idle().r(),
1319 },
1320 })
1321}