1use crate::{data::quest::Payload, rule::npc_ai::quest::get_nearest_spot};
2
3use super::*;
4
5pub fn general<S: State>(tgt: ActorId, session: DialogueSession) -> impl Action<S> {
6 now(move |ctx, _| {
7 let mut responses = Vec::new();
8
9 match &ctx.npc.job {
11 Some(Job::Hired(by, _)) if *by == tgt => {
13 responses.push((
14 Response::from(Content::localized("dialogue-cancel_hire")),
15 session
16 .say_statement(Content::localized("npc-dialogue-hire_cancelled"))
17 .then(just(move |ctx, _| ctx.controller.end_hiring()))
18 .boxed(),
19 ));
20 },
21 Some(_) => {},
22 None => {
23 responses.push((
24 Response::from(Content::localized("dialogue-question-quest_req")),
25 quest::quest_request(session).boxed(),
26 ));
27
28 let can_be_hired =
29 matches!(ctx.actor.profession(), Some(Profession::Adventurer(_)));
30 if can_be_hired {
31 responses.push((
32 Response::from(Content::localized("dialogue-question-hire")),
33 dialogue::hire(tgt, session).boxed(),
34 ));
35 }
36 },
37 }
38
39 for quest_id in ctx.data.quests.related_to(ctx.actor_id) {
40 let Some(quest) = ctx.data.quests.get(quest_id) else {
41 continue;
42 };
43 match &quest.kind {
44 QuestKind::Escort {
45 escortee,
46 escorter,
47 to,
48 } if *escortee == ctx.actor_id && *escorter == tgt => {
49 let to_name =
50 util::site_name(ctx, *to).unwrap_or_else(|| "<unknown>".to_string());
51 let dst_wpos = ctx
52 .data
53 .sites
54 .get(*to)
55 .map_or(Vec2::zero(), |s| s.wpos.as_());
56 responses.push((
57 Response::from(Content::localized("dialogue-question-quest-escort-where")),
58 session
59 .give_marker(
60 Marker::at(dst_wpos)
61 .with_id(quest_id)
62 .with_label(
63 Content::localized("hud-map-escort-label")
64 .with_arg(
65 "name",
66 ctx.actor
67 .get_name()
68 .unwrap_or_else(|| "<unknown>".to_string()),
69 )
70 .with_arg("place", to_name.clone()),
71 )
72 .with_quest_flag(true),
73 )
74 .then(
75 session.say_statement(
76 Content::localized("npc-response-quest-escort-where")
77 .with_arg("dst", to_name),
78 ),
79 )
80 .boxed(),
81 ));
82 },
83 QuestKind::Slay { target, slayer }
84 if quest.arbiter == ctx.actor_id && *slayer == tgt =>
85 {
86 if let Some(target_npc) = ctx.data.actors.get(*target) {
88 responses.push((
89 Response::from(
90 Content::localized("dialogue-question-quest-slay-where")
91 .with_arg("body", target_npc.body.localize_npc()),
92 ),
93 session
94 .give_marker(
95 Marker::at(target_npc.wpos.xy())
96 .with_id(*target)
97 .with_label(
98 Content::localized("hud-map-creature-label")
99 .with_arg("body", target_npc.body.localize_npc()),
100 )
101 .with_quest_flag(true),
102 )
103 .then(
104 session.say_statement(
105 Content::localized("npc-response-quest-slay-where")
106 .with_arg("body", target_npc.body.localize_npc()),
107 ),
108 )
109 .boxed(),
110 ));
111 } else {
112 responses.push((
113 Response::from(Content::localized(
114 "dialogue-question-quest-slay-claim",
115 )),
116 session
117 .say_statement(Content::localized("npc-response-quest-slay-thanks"))
118 .then(now(move |ctx, _| {
119 if let Ok(deposit) =
120 quest::resolve_take_deposit(ctx, quest_id, true)
121 {
122 session
123 .say_statement_with_gift(
124 Content::localized("npc-response-quest-reward"),
125 deposit,
126 )
127 .boxed()
128 } else {
129 finish().boxed()
130 }
131 }))
132 .boxed(),
133 ));
134 }
135 },
136 QuestKind::Courier { instance } => {
137 let is_talking_to_original_quest_giver =
141 instance.source_actor == ctx.actor_id && instance.messenger == tgt;
142 let is_talking_to_courier_target =
143 quest.arbiter == ctx.actor_id && instance.messenger == tgt;
144
145 if (is_talking_to_original_quest_giver || is_talking_to_courier_target)
148 && let Some(target_npc) = ctx.data.actors.get(quest.arbiter)
149 {
150 let npc_name = target_npc
153 .get_name()
154 .unwrap_or_else(|| "<unknown>".to_string());
155
156 if is_talking_to_courier_target {
157 let quest = *instance;
158 let (claim, thanks) = quest.get_courier_claim_dialogue();
159 responses.push((
160 Response::from(claim),
161 session
162 .say_statement(thanks)
163 .then(now(move |ctx, _| {
164 if quest::finalize_courier_task(ctx, quest_id, false)
165 && let Ok(deposit) =
166 quest::resolve_take_deposit(ctx, quest_id, true)
167 {
168 session
169 .say_statement_with_gift(
170 Content::localized("npc-response-quest-reward"),
171 deposit,
172 )
173 .boxed()
174 } else {
175 session.say_statement(quest.lacks_items()).boxed()
176 }
177 }))
178 .boxed(),
179 ));
180 }
181
182 let target_npc_wpos = target_npc.wpos.xy();
184 let tgt_npc_name = npc_name.clone();
185 let tgt_npc_id = quest.arbiter;
186
187 let (dialogue_question, dialogue_response) = instance
190 .what_items_needed(is_talking_to_courier_target, npc_name.as_str());
191
192 match instance.kind.payload() {
193 Some(Payload::GnarlingCarving) => {
198 let quest = *instance;
199 responses.push((
200 Response::from(dialogue_question),
201 session
202 .say_statement(dialogue_response)
203 .then(now(move |ctx, _| {
204 let tgt_npc_name = tgt_npc_name.as_str();
212 get_nearest_spot(
213 ctx,
214 quest.kind,
215 ctx.actor.wpos.xy().wpos_to_cpos().as_(),
216 )
217 .map(|chunk_pos| {
218 session.give_marker(
219 quest.get_quest_spot_start_marker(
220 chunk_pos.cpos_to_wpos().as_(),
221 tgt_npc_name,
222 quest_id,
223 ),
224 )
225 })
226 .unwrap_or_else(|| {
227 session.give_marker(
230 quest.get_quest_actor_target_marker(
231 target_npc_wpos,
232 tgt_npc_name,
233 tgt_npc_id,
234 ),
235 )
236 })
237 }))
238 .boxed(),
239 ));
240 },
241 None | Some(Payload::LegoomLeaf) => {
243 responses.push((
244 Response::from(dialogue_question),
245 session.say_statement(dialogue_response).boxed(),
246 ));
247 },
248 }
249
250 if is_talking_to_original_quest_giver {
253 let npc_name = npc_name.as_str();
254 if !instance.kind.delivers_to_giver() {
255 let (question, marker, response) = instance
256 .get_dialogue_where_target(
257 npc_name,
258 target_npc.wpos.xy(),
259 quest.arbiter,
260 );
261 responses.push((
262 Response::from(question),
263 session
264 .give_marker(marker)
265 .then(session.say_statement(response))
266 .boxed(),
267 ));
268 }
269 }
270 }
271 },
272 _ => {},
273 }
274 }
275
276 if let Some(Profession::Captain) = &ctx.actor.profession() {
277 responses.push((
278 Response::from(Content::localized("dialogue-question-where-ship-going")),
279 dialogue::where_are_we_going_next(session).boxed(),
280 ));
281 }
282
283 responses.push((
285 Response::from(Content::localized("dialogue-question-directions")),
286 dialogue::directions(session).boxed(),
287 ));
288 responses.push((
289 Response::from(Content::localized("dialogue-question-site")),
290 dialogue::about_site(session).boxed(),
291 ));
292 responses.push((
293 Response::from(Content::localized("dialogue-question-self")),
294 dialogue::about_self(session).boxed(),
295 ));
296 responses.push((
297 Response::from(Content::localized("dialogue-question-sentiment")),
298 dialogue::sentiments(tgt, session).boxed(),
299 ));
300
301 responses.push((
303 Response::from(Content::localized("dialogue-play_game")),
304 dialogue::games(session).boxed(),
305 ));
306 responses.push((
309 Response::from(Content::localized("dialogue-finish")),
310 session
311 .say_statement(Content::localized("npc-goodbye"))
312 .boxed(),
313 ));
314
315 session.ask_question(Content::localized("npc-question-general"), responses)
316 })
317}
318
319fn about_site<S: State>(session: DialogueSession) -> impl Action<S> {
320 now(move |ctx, _| {
321 if let Some(site_name) = util::site_name(ctx, ctx.actor.current_site) {
322 let mut action = session
323 .say_statement(
324 Content::localized("npc-info-current_site").with_arg("site", site_name),
325 )
326 .boxed();
327
328 if let Some(current_site) = ctx.actor.current_site
329 && let Some(current_site) = ctx.data.sites.get(current_site)
330 {
331 for mention_site in ¤t_site.nearby_sites_by_size {
332 if ctx.rng.random_bool(0.5)
333 && let Some(content) = tell_site_content(ctx, *mention_site)
334 {
335 action = action.then(session.say_statement(content)).boxed();
336 }
337 }
338 }
339
340 action
341 } else {
342 session
343 .say_statement(Content::localized("npc-info-unknown"))
344 .boxed()
345 }
346 })
347}
348
349fn about_self<S: State>(session: DialogueSession) -> impl Action<S> {
350 now(move |ctx, _| {
351 let name = Content::localized("npc-info-self_name")
352 .with_arg("name", ctx.actor.get_name().as_deref().unwrap_or("unknown"));
353
354 let job = ctx
355 .actor
356 .profession()
357 .map(|p| match p {
358 Profession::Farmer => "noun-role-farmer",
359 Profession::Hunter => "noun-role-hunter",
360 Profession::Merchant => "noun-role-merchant",
361 Profession::Guard => "noun-role-guard",
362 Profession::Adventurer(_) => "noun-role-adventurer",
363 Profession::Blacksmith => "noun-role-blacksmith",
364 Profession::Chef => "noun-role-chef",
365 Profession::Alchemist => "noun-role-alchemist",
366 Profession::Pirate(_) => "noun-role-pirate",
367 Profession::Cultist => "noun-role-cultist",
368 Profession::Herbalist => "noun-role-herbalist",
369 Profession::Captain => "noun-role-captain",
370 })
371 .map(|p| Content::localized("npc-info-role").with_arg("role", Content::localized(p)))
372 .unwrap_or_else(|| Content::localized("noun-role-none"));
373
374 let home = if let Some(site_name) = util::site_name(ctx, ctx.actor.home) {
375 Content::localized("npc-info-self_home").with_arg("site", site_name)
376 } else {
377 Content::localized("npc-info-self_homeless")
378 };
379
380 session
381 .say_statement(name)
382 .then(session.say_statement(job))
383 .then(session.say_statement(home))
384 })
385}
386
387fn where_are_we_going_next<S: State>(session: DialogueSession) -> impl Action<S> {
388 now(move |ctx, _| match ctx.actor.profession() {
389 Some(Profession::Captain) => {
390 let msg = if let Some(assigned_route) =
391 ctx.data.airship_sim.assigned_routes.get(&ctx.actor_id)
392 {
393 let dests = ctx.data.airship_sim.next_destinations(
394 &ctx.world.civs().airships,
395 &ctx.world.sim().map_size_lg(),
396 assigned_route.0,
397 ctx.controller.current_airship_pilot_leg,
398 );
399
400 if let Some(dests) = dests {
401 let first_site_name = ctx
402 .index
403 .sites
404 .get(dests.0.site_id)
405 .name()
406 .unwrap_or("Unknown Site")
407 .to_string();
408 let next_site_name = ctx
409 .index
410 .sites
411 .get(dests.1.site_id)
412 .name()
413 .unwrap_or("Unknown Site")
414 .to_string();
415
416 let first_site_vec = dests.0.approach_transition_pos - ctx.actor.wpos.xy();
417 let first_site_dir = Direction::from_dir(first_site_vec).localize_npc();
418
419 let next_site_vec = dests.1.approach_transition_pos - ctx.actor.wpos.xy();
420 let next_site_dir = Direction::from_dir(next_site_vec).localize_npc();
421
422 Content::localized("npc-speech-pilot-where_heading_now")
423 .with_arg("dir", first_site_dir)
424 .with_arg("dst", first_site_name)
425 .with_arg("ndir", next_site_dir)
426 .with_arg("ndst", next_site_name)
427 } else {
428 Content::localized("npc-speech-pilot-unknown_destination")
429 }
430 } else {
431 Content::localized("npc-speech-pilot-unknown_destination")
432 };
433
434 session.say_statement(msg)
435 },
436 _ => session.say_statement(Content::localized(
437 "npc-speech-where_are_we_going_wrong_profession",
438 )),
439 })
440}
441
442fn sentiments<S: State>(tgt: ActorId, session: DialogueSession) -> impl Action<S> {
443 session.ask_question(Content::Plain("...".to_string()), [(
444 Content::localized("dialogue-me"),
445 now(move |ctx, _| {
446 if ctx.sentiments.toward(tgt).is(Sentiment::ALLY) {
447 session.say_statement(Content::localized("npc-response-like_you"))
448 } else if ctx.sentiments.toward(tgt).is(Sentiment::RIVAL) {
449 session.say_statement(Content::localized("npc-response-dislike_you"))
450 } else {
451 session.say_statement(Content::localized("npc-response-ambivalent_you"))
452 }
453 }),
454 )])
455}
456
457fn hire<S: State>(tgt: ActorId, session: DialogueSession) -> impl Action<S> {
458 now(move |ctx, _| {
459 if ctx.npc.job.is_none() && ctx.actor.rng(38792).random_bool(0.5) {
460 let hire_level = match ctx.actor.profession() {
461 Some(Profession::Adventurer(l)) => l,
462 _ => 0,
463 };
464 let price_mul = 1u32 << hire_level.min(31);
465 let mut responses = Vec::new();
466 responses.push((
467 Response::from(Content::localized("dialogue-cancel_interaction")),
468 session
469 .say_statement(Content::localized("npc-response-no_problem"))
470 .boxed(),
471 ));
472 let options = [
473 (
474 1.0,
475 60,
476 Content::localized_attr("dialogue-buy_hire_days", "day"),
477 ),
478 (
479 7.0,
480 300,
481 Content::localized_attr("dialogue-buy_hire_days", "week"),
482 ),
483 ];
484 for (days, base_price, msg) in options {
485 responses.push((
486 Response {
487 msg,
488 given_item: Some((
489 Arc::<ItemDef>::load_cloned("common.items.utility.coins").unwrap(),
490 price_mul.saturating_mul(base_price),
491 )),
492 },
493 session
494 .say_statement(Content::localized("npc-response-accept_hire"))
495 .then(just(move |ctx, _| {
496 ctx.controller.set_newly_hired(
497 tgt,
498 ctx.time.add_days(days, &ctx.system_data.server_constants),
499 );
500 }))
501 .boxed(),
502 ));
503 }
504 session
505 .ask_question(Content::localized("npc-response-hire_time"), responses)
506 .boxed()
507 } else {
508 session
509 .say_statement(Content::localized("npc-response-decline_hire"))
510 .boxed()
511 }
512 })
513}
514
515fn directions<S: State>(session: DialogueSession) -> impl Action<S> {
516 now(move |ctx, _| {
517 let mut responses = Vec::new();
518
519 for actor in ctx.data
520 .quests
521 .related_actors(session.target)
522 .filter(|actor| *actor != ctx.actor_id)
523 .take(32)
525 {
526 if let Some(pos) = util::locate_actor(ctx, actor)
527 && let Some(name) = util::actor_name(ctx, actor)
528 {
529 responses.push((
530 Content::localized("dialogue-direction-actor").with_arg("name", name.clone()),
531 session
532 .give_marker(
533 Marker::at(pos.xy())
534 .with_label(
535 Content::localized("hud-map-character-label")
536 .with_arg("name", name.clone()),
537 )
538 .with_kind(MarkerKind::Character)
539 .with_id(actor)
540 .with_quest_flag(true),
541 )
542 .then(session.say_statement(Content::localized("npc-response-directions")))
543 .boxed(),
544 ));
545 }
546 }
547
548 if let Some(current_site) = ctx.actor.current_site
549 && let Some(ws_id) = ctx.data.sites[current_site].world_site
550 {
551 let direction_to_nearest =
552 |f: fn(&&world::site::Plot) -> bool,
553 plot_name: fn(&world::site::Plot) -> Content| {
554 now(move |ctx, _| {
555 let ws = ctx.index.sites.get(ws_id);
556 if let Some(p) = ws.plots().filter(f).min_by_key(|p| {
557 ws.tile_center_wpos(p.root_tile())
558 .distance_squared(ctx.actor.wpos.xy().as_())
559 }) {
560 session
561 .give_marker(
562 Marker::at(ws.tile_center_wpos(p.root_tile()).as_())
563 .with_label(plot_name(p)),
564 )
565 .then(
566 session.say_statement(Content::localized(
567 "npc-response-directions",
568 )),
569 )
570 .boxed()
571 } else {
572 session
573 .say_statement(Content::localized("npc-response-doesnt_exist"))
574 .boxed()
575 }
576 })
577 .boxed()
578 };
579
580 responses.push((
581 Content::localized("dialogue-direction-tavern"),
582 direction_to_nearest(
583 |p| matches!(p.kind(), PlotKind::Tavern(_)),
584 |p| match p.kind() {
585 PlotKind::Tavern(t) => Content::Plain(t.name.clone()),
586 _ => unreachable!(),
587 },
588 ),
589 ));
590 responses.push((
591 Content::localized("dialogue-direction-plaza"),
592 direction_to_nearest(
593 |p| matches!(p.kind(), PlotKind::Plaza(_)),
594 |_| Content::localized("hud-map-plaza"),
595 ),
596 ));
597 responses.push((
598 Content::localized("dialogue-direction-workshop"),
599 direction_to_nearest(
600 |p| p.is_workshop(),
601 |_| Content::localized("hud-map-workshop"),
602 ),
603 ));
604 responses.push((
605 Content::localized("dialogue-direction-airship_dock"),
606 direction_to_nearest(
607 |p| p.airship_dock_info().is_some(),
608 |_| Content::localized("hud-map-airship_dock"),
609 ),
610 ));
611 }
612
613 session.ask_question(Content::localized("npc-question-directions"), responses)
614 })
615}
616
617fn rock_paper_scissors<S: State>(session: DialogueSession) -> impl Action<S> {
618 now(move |ctx, _| {
619 #[derive(PartialEq, Eq, Clone, Copy)]
620 enum RockPaperScissor {
621 Rock,
622 Paper,
623 Scissors,
624 }
625 use RockPaperScissor::*;
626 impl RockPaperScissor {
627 fn i18n_key(&self) -> &'static str {
628 match self {
629 Rock => "dialogue-game-rock",
630 Paper => "dialogue-game-paper",
631 Scissors => "dialogue-game-scissors",
632 }
633 }
634 }
635 fn end<S: State>(
636 session: DialogueSession,
637 our: RockPaperScissor,
638 their: RockPaperScissor,
639 ) -> impl Action<S> {
640 let draw = our == their;
641 let we_win = matches!(
642 (our, their),
643 (Rock, Scissors) | (Paper, Rock) | (Scissors, Paper)
644 );
645 let result = if draw {
646 "dialogue-game-draw"
647 } else if we_win {
648 "dialogue-game-win"
649 } else {
650 "dialogue-game-lose"
651 };
652
653 session
654 .say_statement(Content::localized(our.i18n_key()))
655 .then(session.say_statement(Content::localized(result)))
656 }
657 let choices = [Rock, Paper, Scissors];
658 let our_choice = choices
659 .choose(&mut ctx.rng)
660 .expect("We have a non-empty array");
661
662 let choices = choices.map(|choice| {
663 (
664 Response::from(Content::localized(choice.i18n_key())),
665 end(session, *our_choice, choice),
666 )
667 });
668
669 session.ask_question(
670 Content::localized("dialogue-game-rock_paper_scissors"),
671 choices,
672 )
673 })
674}
675
676fn games<S: State>(session: DialogueSession) -> impl Action<S> {
677 let games = [(
678 Response::from(Content::localized("dialogue-game-rock_paper_scissors")),
679 rock_paper_scissors(session),
680 )];
681
682 session.ask_question(Content::localized("dialogue-game-what_game"), games)
683}