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