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: ActorId, 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 =
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                    // Is the monster dead?
87                    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                    // It is possible for a courier quest to start and end with
138                    // the same NPC, so the responses can cover both situations
139                    // simultaneously
140                    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                    // many dialogue options are the same regardless of if
146                    // you're talking to the quest giver or the target
147                    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                        // will need to do a bit of cloning due to all the
151                        // closures we have to enter
152                        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                        // clone these values before entering the next closure
183                        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                        // Determine the "what items are needed again?" dialogue
188                        // items in advance for cleanliness
189                        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                            // For the gnarling carving (or any other
194                            // spot-based) quest, the quest giver can be asked
195                            // for the required items, and the quest giver will
196                            // mark the map with the nearest spot.
197                            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                                            // attempt to provide a map marker that points to
205                                            // the nearest spot. Any spot is sufficient, it
206                                            // doesn't need to be the original one that was given at
207                                            // quest start. In fact, it's more convenient if
208                                            // you get to your location first and the courier
209                                            // recipient also has the ability to point out
210                                            // where the nearest spot might be.
211                                            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                                                // provide a map marker that points to the
228                                                // courier target as a fallback
229                                                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                            // No spot is required for these, so things are much more simple:
242                            None | Some(Payload::LegoomLeaf) => {
243                                responses.push((
244                                    Response::from(dialogue_question),
245                                    session.say_statement(dialogue_response).boxed(),
246                                ));
247                            },
248                        }
249
250                        // Allow asking where the courier target is, but only
251                        // if the NPC is not the target, obviously.
252                        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        // General informational questions
284        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        // Local activities
302        responses.push((
303            Response::from(Content::localized("dialogue-play_game")),
304            dialogue::games(session).boxed(),
305        ));
306        // TODO: Include trading here!
307
308        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 &current_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            // Avoid mentioning too many actors
524            .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}