Skip to main content

veloren_rtsim/rule/npc_ai/
dialogue.rs

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        // Job-dependent responses
10        match &ctx.npc.job {
11            // TODO: Implement hiring as a quest?
12            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                    // TODO: Work for non-NPCs?
86                    let Actor::Npc(target_npc_id) = target else {
87                        continue;
88                    };
89                    // Is the monster dead?
90                    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                    // It is possible for a courier quest to start and end with
141                    // the same NPC, so the responses can cover both situations
142                    // simultaneously
143                    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                    // many dialogue options are the same regardless of if
150                    // you're talking to the quest giver or the target
151                    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                        // will need to do a bit of cloning due to all the
156                        // closures we have to enter
157                        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                        // clone these values before entering the next closure
188                        let target_npc_wpos = target_npc.wpos.xy();
189                        let tgt_npc_name = npc_name.clone();
190
191                        // Determine the "what items are needed again?" dialogue
192                        // items in advance for cleanliness
193                        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                            // For the gnarling carving (or any other
198                            // spot-based) quest, the quest giver can be asked
199                            // for the required items, and the quest giver will
200                            // mark the map with the nearest spot.
201                            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                                            // attempt to provide a map marker that points to
209                                            // the nearest spot. Any spot is sufficient, it
210                                            // doesn't need to be the original one that was given at
211                                            // quest start. In fact, it's more convenient if
212                                            // you get to your location first and the courier
213                                            // recipient also has the ability to point out
214                                            // where the nearest spot might be.
215                                            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                                                // provide a map marker that points to the
232                                                // courier target as a fallback
233                                                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                            // No spot is required for these, so things are much more simple:
246                            None | Some(Payload::LegoomLeaf) => {
247                                responses.push((
248                                    Response::from(dialogue_question),
249                                    session.say_statement(dialogue_response).boxed(),
250                                ));
251                            },
252                        }
253
254                        // Allow asking where the courier target is, but only
255                        // if the NPC is not the target, obviously.
256                        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        // General informational questions
288        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        // Local activities
306        responses.push((
307            Response::from(Content::localized("dialogue-play_game")),
308            dialogue::games(session).boxed(),
309        ));
310        // TODO: Include trading here!
311
312        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 &current_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            // Avoid mentioning too many actors
528            .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}