veloren_rtsim/rule/npc_ai/
dialogue.rs

1use super::*;
2
3pub fn general<S: State>(tgt: Actor, session: DialogueSession) -> impl Action<S> {
4    now(move |ctx, _| {
5        let mut responses = Vec::new();
6
7        // Job-dependent responses
8        match &ctx.npc.job {
9            // TODO: Implement hiring as a quest?
10            Some(Job::Hired(by, _)) if *by == tgt => {
11                responses.push((
12                    Response::from(Content::localized("dialogue-cancel_hire")),
13                    session
14                        .say_statement(Content::localized("npc-dialogue-hire_cancelled"))
15                        .then(just(move |ctx, _| ctx.controller.end_hiring()))
16                        .boxed(),
17                ));
18            },
19            Some(_) => {},
20            None => {
21                responses.push((
22                    Response::from(Content::localized("dialogue-question-quest_req")),
23                    quest::quest_request(session).boxed(),
24                ));
25
26                let can_be_hired = matches!(ctx.npc.profession(), Some(Profession::Adventurer(_)));
27                if can_be_hired {
28                    responses.push((
29                        Response::from(Content::localized("dialogue-question-hire")),
30                        dialogue::hire(tgt, session).boxed(),
31                    ));
32                }
33            },
34        }
35
36        for quest_id in ctx.data.quests.related_to(ctx.npc_id) {
37            let Some(quest) = ctx.data.quests.get(quest_id) else {
38                continue;
39            };
40            match &quest.kind {
41                QuestKind::Escort {
42                    escortee,
43                    escorter,
44                    to,
45                } if *escortee == Actor::Npc(ctx.npc_id) && *escorter == tgt => {
46                    let to_name =
47                        util::site_name(ctx, *to).unwrap_or_else(|| "<unknown>".to_string());
48                    let dst_wpos = ctx
49                        .data
50                        .sites
51                        .get(*to)
52                        .map_or(Vec2::zero(), |s| s.wpos.as_());
53                    responses.push((
54                        Response::from(Content::localized("dialogue-question-quest-escort-where")),
55                        session
56                            .give_marker(
57                                Marker::at(dst_wpos)
58                                    .with_id(quest_id)
59                                    .with_label(
60                                        Content::localized("hud-map-escort-label")
61                                            .with_arg(
62                                                "name",
63                                                ctx.npc
64                                                    .get_name()
65                                                    .unwrap_or_else(|| "<unknown>".to_string()),
66                                            )
67                                            .with_arg("place", to_name.clone()),
68                                    )
69                                    .with_quest_flag(true),
70                            )
71                            .then(
72                                session.say_statement(
73                                    Content::localized("npc-response-quest-escort-where")
74                                        .with_arg("dst", to_name),
75                                ),
76                            )
77                            .boxed(),
78                    ));
79                },
80                QuestKind::Slay { target, slayer }
81                    if quest.arbiter == Actor::Npc(ctx.npc_id) && *slayer == tgt =>
82                {
83                    // TODO: Work for non-NPCs?
84                    let Actor::Npc(target_npc_id) = target else {
85                        continue;
86                    };
87                    // Is the monster dead?
88                    if let Some(target_npc) = ctx.data.npcs.get(*target_npc_id) {
89                        responses.push((
90                            Response::from(
91                                Content::localized("dialogue-question-quest-slay-where")
92                                    .with_arg("body", target_npc.body.localize_npc()),
93                            ),
94                            session
95                                .give_marker(
96                                    Marker::at(target_npc.wpos.xy())
97                                        .with_id(*target)
98                                        .with_label(
99                                            Content::localized("hud-map-creature-label")
100                                                .with_arg("body", target_npc.body.localize_npc()),
101                                        )
102                                        .with_quest_flag(true),
103                                )
104                                .then(
105                                    session.say_statement(
106                                        Content::localized("npc-response-quest-slay-where")
107                                            .with_arg("body", target_npc.body.localize_npc()),
108                                    ),
109                                )
110                                .boxed(),
111                        ));
112                    } else {
113                        responses.push((
114                            Response::from(Content::localized(
115                                "dialogue-question-quest-slay-claim",
116                            )),
117                            session
118                                .say_statement(Content::localized("npc-response-quest-slay-thanks"))
119                                .then(now(move |ctx, _| {
120                                    if let Ok(deposit) =
121                                        quest::resolve_take_deposit(ctx, quest_id, true)
122                                    {
123                                        session
124                                            .say_statement_with_gift(
125                                                Content::localized("npc-response-quest-reward"),
126                                                deposit,
127                                            )
128                                            .boxed()
129                                    } else {
130                                        finish().boxed()
131                                    }
132                                }))
133                                .boxed(),
134                        ));
135                    }
136                },
137                _ => {},
138            }
139        }
140
141        if let Some(Profession::Captain) = &ctx.npc.profession() {
142            responses.push((
143                Response::from(Content::localized("dialogue-question-where-ship-going")),
144                dialogue::where_are_we_going_next(session).boxed(),
145            ));
146        }
147
148        // General informational questions
149        responses.push((
150            Response::from(Content::localized("dialogue-question-site")),
151            dialogue::about_site(session).boxed(),
152        ));
153        responses.push((
154            Response::from(Content::localized("dialogue-question-self")),
155            dialogue::about_self(session).boxed(),
156        ));
157        responses.push((
158            Response::from(Content::localized("dialogue-question-sentiment")),
159            dialogue::sentiments(tgt, session).boxed(),
160        ));
161        responses.push((
162            Response::from(Content::localized("dialogue-question-directions")),
163            dialogue::directions(session).boxed(),
164        ));
165
166        // Local activities
167        responses.push((
168            Response::from(Content::localized("dialogue-play_game")),
169            dialogue::games(session).boxed(),
170        ));
171        // TODO: Include trading here!
172
173        responses.push((
174            Response::from(Content::localized("dialogue-finish")),
175            session
176                .say_statement(Content::localized("npc-goodbye"))
177                .boxed(),
178        ));
179
180        session.ask_question(Content::localized("npc-question-general"), responses)
181    })
182}
183
184fn about_site<S: State>(session: DialogueSession) -> impl Action<S> {
185    now(move |ctx, _| {
186        if let Some(site_name) = util::site_name(ctx, ctx.npc.current_site) {
187            let mut action = session
188                .say_statement(
189                    Content::localized("npc-info-current_site").with_arg("site", site_name),
190                )
191                .boxed();
192
193            if let Some(current_site) = ctx.npc.current_site
194                && let Some(current_site) = ctx.data.sites.get(current_site)
195            {
196                for mention_site in &current_site.nearby_sites_by_size {
197                    if ctx.rng.random_bool(0.5)
198                        && let Some(content) = tell_site_content(ctx, *mention_site)
199                    {
200                        action = action.then(session.say_statement(content)).boxed();
201                    }
202                }
203            }
204
205            action
206        } else {
207            session
208                .say_statement(Content::localized("npc-info-unknown"))
209                .boxed()
210        }
211    })
212}
213
214fn about_self<S: State>(session: DialogueSession) -> impl Action<S> {
215    now(move |ctx, _| {
216        let name = Content::localized("npc-info-self_name")
217            .with_arg("name", ctx.npc.get_name().as_deref().unwrap_or("unknown"));
218
219        let job = ctx
220            .npc
221            .profession()
222            .map(|p| match p {
223                Profession::Farmer => "noun-role-farmer",
224                Profession::Hunter => "noun-role-hunter",
225                Profession::Merchant => "noun-role-merchant",
226                Profession::Guard => "noun-role-guard",
227                Profession::Adventurer(_) => "noun-role-adventurer",
228                Profession::Blacksmith => "noun-role-blacksmith",
229                Profession::Chef => "noun-role-chef",
230                Profession::Alchemist => "noun-role-alchemist",
231                Profession::Pirate(_) => "noun-role-pirate",
232                Profession::Cultist => "noun-role-cultist",
233                Profession::Herbalist => "noun-role-herbalist",
234                Profession::Captain => "noun-role-captain",
235            })
236            .map(|p| Content::localized("npc-info-role").with_arg("role", Content::localized(p)))
237            .unwrap_or_else(|| Content::localized("noun-role-none"));
238
239        let home = if let Some(site_name) = util::site_name(ctx, ctx.npc.home) {
240            Content::localized("npc-info-self_home").with_arg("site", site_name)
241        } else {
242            Content::localized("npc-info-self_homeless")
243        };
244
245        session
246            .say_statement(name)
247            .then(session.say_statement(job))
248            .then(session.say_statement(home))
249    })
250}
251
252fn where_are_we_going_next<S: State>(session: DialogueSession) -> impl Action<S> {
253    now(move |ctx, _| match ctx.npc.profession() {
254        Some(Profession::Captain) => {
255            let msg = if let Some(assigned_route) =
256                ctx.data.airship_sim.assigned_routes.get(&ctx.npc_id)
257            {
258                let dests = ctx.data.airship_sim.next_destinations(
259                    &ctx.world.civs().airships,
260                    &ctx.world.sim().map_size_lg(),
261                    assigned_route.0,
262                    ctx.controller.current_airship_pilot_leg,
263                );
264
265                if let Some(dests) = dests {
266                    let first_site_name = ctx
267                        .index
268                        .sites
269                        .get(dests.0.site_id)
270                        .name()
271                        .unwrap_or("Unknown Site")
272                        .to_string();
273                    let next_site_name = ctx
274                        .index
275                        .sites
276                        .get(dests.1.site_id)
277                        .name()
278                        .unwrap_or("Unknown Site")
279                        .to_string();
280
281                    let first_site_vec = dests.0.approach_transition_pos - ctx.npc.wpos.xy();
282                    let first_site_dir = Direction::from_dir(first_site_vec).localize_npc();
283
284                    let next_site_vec = dests.1.approach_transition_pos - ctx.npc.wpos.xy();
285                    let next_site_dir = Direction::from_dir(next_site_vec).localize_npc();
286
287                    Content::localized("npc-speech-pilot-where_heading_now")
288                        .with_arg("dir", first_site_dir)
289                        .with_arg("dst", first_site_name)
290                        .with_arg("ndir", next_site_dir)
291                        .with_arg("ndst", next_site_name)
292                } else {
293                    Content::localized("npc-speech-pilot-unknown_destination")
294                }
295            } else {
296                Content::localized("npc-speech-pilot-unknown_destination")
297            };
298
299            session.say_statement(msg)
300        },
301        _ => session.say_statement(Content::localized(
302            "npc-speech-where_are_we_going_wrong_profession",
303        )),
304    })
305}
306
307fn sentiments<S: State>(tgt: Actor, session: DialogueSession) -> impl Action<S> {
308    session.ask_question(Content::Plain("...".to_string()), [(
309        Content::localized("dialogue-me"),
310        now(move |ctx, _| {
311            if ctx.sentiments.toward(tgt).is(Sentiment::ALLY) {
312                session.say_statement(Content::localized("npc-response-like_you"))
313            } else if ctx.sentiments.toward(tgt).is(Sentiment::RIVAL) {
314                session.say_statement(Content::localized("npc-response-dislike_you"))
315            } else {
316                session.say_statement(Content::localized("npc-response-ambivalent_you"))
317            }
318        }),
319    )])
320}
321
322fn hire<S: State>(tgt: Actor, session: DialogueSession) -> impl Action<S> {
323    now(move |ctx, _| {
324        if ctx.npc.job.is_none() && ctx.npc.rng(38792).random_bool(0.5) {
325            let hire_level = match ctx.npc.profession() {
326                Some(Profession::Adventurer(l)) => l,
327                _ => 0,
328            };
329            let price_mul = 1u32 << hire_level.min(31);
330            let mut responses = Vec::new();
331            responses.push((
332                Response::from(Content::localized("dialogue-cancel_interaction")),
333                session
334                    .say_statement(Content::localized("npc-response-no_problem"))
335                    .boxed(),
336            ));
337            let options = [
338                (
339                    1.0,
340                    60,
341                    Content::localized_attr("dialogue-buy_hire_days", "day"),
342                ),
343                (
344                    7.0,
345                    300,
346                    Content::localized_attr("dialogue-buy_hire_days", "week"),
347                ),
348            ];
349            for (days, base_price, msg) in options {
350                responses.push((
351                    Response {
352                        msg,
353                        given_item: Some((
354                            Arc::<ItemDef>::load_cloned("common.items.utility.coins").unwrap(),
355                            price_mul.saturating_mul(base_price),
356                        )),
357                    },
358                    session
359                        .say_statement(Content::localized("npc-response-accept_hire"))
360                        .then(just(move |ctx, _| {
361                            ctx.controller.set_newly_hired(
362                                tgt,
363                                ctx.time.add_days(days, &ctx.system_data.server_constants),
364                            );
365                        }))
366                        .boxed(),
367                ));
368            }
369            session
370                .ask_question(Content::localized("npc-response-hire_time"), responses)
371                .boxed()
372        } else {
373            session
374                .say_statement(Content::localized("npc-response-decline_hire"))
375                .boxed()
376        }
377    })
378}
379
380fn directions<S: State>(session: DialogueSession) -> impl Action<S> {
381    now(move |ctx, _| {
382        let mut responses = Vec::new();
383
384        for actor in ctx.data
385            .quests
386            .related_actors(session.target)
387            .filter(|actor| *actor != Actor::Npc(ctx.npc_id))
388            // Avoid mentioning too many actors
389            .take(32)
390        {
391            if let Some(pos) = util::locate_actor(ctx, actor)
392                && let Some(name) = util::actor_name(ctx, actor)
393            {
394                responses.push((
395                    Content::localized("dialogue-direction-actor").with_arg("name", name.clone()),
396                    session
397                        .give_marker(
398                            Marker::at(pos.xy())
399                                .with_label(
400                                    Content::localized("hud-map-character-label")
401                                        .with_arg("name", name.clone()),
402                                )
403                                .with_kind(MarkerKind::Character)
404                                .with_id(actor)
405                                .with_quest_flag(true),
406                        )
407                        .then(session.say_statement(Content::localized("npc-response-directions")))
408                        .boxed(),
409                ));
410            }
411        }
412
413        if let Some(current_site) = ctx.npc.current_site
414            && let Some(ws_id) = ctx.data.sites[current_site].world_site
415        {
416            let direction_to_nearest =
417                |f: fn(&&world::site::Plot) -> bool,
418                 plot_name: fn(&world::site::Plot) -> Content| {
419                    now(move |ctx, _| {
420                        let ws = ctx.index.sites.get(ws_id);
421                        if let Some(p) = ws.plots().filter(f).min_by_key(|p| {
422                            ws.tile_center_wpos(p.root_tile())
423                                .distance_squared(ctx.npc.wpos.xy().as_())
424                        }) {
425                            session
426                                .give_marker(
427                                    Marker::at(ws.tile_center_wpos(p.root_tile()).as_())
428                                        .with_label(plot_name(p)),
429                                )
430                                .then(
431                                    session.say_statement(Content::localized(
432                                        "npc-response-directions",
433                                    )),
434                                )
435                                .boxed()
436                        } else {
437                            session
438                                .say_statement(Content::localized("npc-response-doesnt_exist"))
439                                .boxed()
440                        }
441                    })
442                    .boxed()
443                };
444
445            responses.push((
446                Content::localized("dialogue-direction-tavern"),
447                direction_to_nearest(
448                    |p| matches!(p.kind(), PlotKind::Tavern(_)),
449                    |p| match p.kind() {
450                        PlotKind::Tavern(t) => Content::Plain(t.name.clone()),
451                        _ => unreachable!(),
452                    },
453                ),
454            ));
455            responses.push((
456                Content::localized("dialogue-direction-plaza"),
457                direction_to_nearest(
458                    |p| matches!(p.kind(), PlotKind::Plaza(_)),
459                    |_| Content::localized("hud-map-plaza"),
460                ),
461            ));
462            responses.push((
463                Content::localized("dialogue-direction-workshop"),
464                direction_to_nearest(
465                    |p| matches!(p.kind().meta(), Some(PlotKindMeta::Workshop { .. })),
466                    |_| Content::localized("hud-map-workshop"),
467                ),
468            ));
469            responses.push((
470                Content::localized("dialogue-direction-airship_dock"),
471                direction_to_nearest(
472                    |p| matches!(p.kind().meta(), Some(PlotKindMeta::AirshipDock { .. })),
473                    |_| Content::localized("hud-map-airship_dock"),
474                ),
475            ));
476        }
477
478        session.ask_question(Content::localized("npc-question-directions"), responses)
479    })
480}
481
482fn rock_paper_scissors<S: State>(session: DialogueSession) -> impl Action<S> {
483    now(move |ctx, _| {
484        #[derive(PartialEq, Eq, Clone, Copy)]
485        enum RockPaperScissor {
486            Rock,
487            Paper,
488            Scissors,
489        }
490        use RockPaperScissor::*;
491        impl RockPaperScissor {
492            fn i18n_key(&self) -> &'static str {
493                match self {
494                    Rock => "dialogue-game-rock",
495                    Paper => "dialogue-game-paper",
496                    Scissors => "dialogue-game-scissors",
497                }
498            }
499        }
500        fn end<S: State>(
501            session: DialogueSession,
502            our: RockPaperScissor,
503            their: RockPaperScissor,
504        ) -> impl Action<S> {
505            let draw = our == their;
506            let we_win = matches!(
507                (our, their),
508                (Rock, Scissors) | (Paper, Rock) | (Scissors, Paper)
509            );
510            let result = if draw {
511                "dialogue-game-draw"
512            } else if we_win {
513                "dialogue-game-win"
514            } else {
515                "dialogue-game-lose"
516            };
517
518            session
519                .say_statement(Content::localized(our.i18n_key()))
520                .then(session.say_statement(Content::localized(result)))
521        }
522        let choices = [Rock, Paper, Scissors];
523        let our_choice = choices
524            .choose(&mut ctx.rng)
525            .expect("We have a non-empty array");
526
527        let choices = choices.map(|choice| {
528            (
529                Response::from(Content::localized(choice.i18n_key())),
530                end(session, *our_choice, choice),
531            )
532        });
533
534        session.ask_question(
535            Content::localized("dialogue-game-rock_paper_scissors"),
536            choices,
537        )
538    })
539}
540
541fn games<S: State>(session: DialogueSession) -> impl Action<S> {
542    let games = [(
543        Response::from(Content::localized("dialogue-game-rock_paper_scissors")),
544        rock_paper_scissors(session),
545    )];
546
547    session.ask_question(Content::localized("dialogue-game-what_game"), games)
548}