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        // General informational questions
142        responses.push((
143            Response::from(Content::localized("dialogue-question-site")),
144            dialogue::about_site(session).boxed(),
145        ));
146        responses.push((
147            Response::from(Content::localized("dialogue-question-self")),
148            dialogue::about_self(session).boxed(),
149        ));
150        responses.push((
151            Response::from(Content::localized("dialogue-question-sentiment")),
152            dialogue::sentiments(tgt, session).boxed(),
153        ));
154        responses.push((
155            Response::from(Content::localized("dialogue-question-directions")),
156            dialogue::directions(session).boxed(),
157        ));
158
159        // Local activities
160        responses.push((
161            Response::from(Content::localized("dialogue-play_game")),
162            dialogue::games(session).boxed(),
163        ));
164        // TODO: Include trading here!
165
166        responses.push((
167            Response::from(Content::localized("dialogue-finish")),
168            session
169                .say_statement(Content::localized("npc-goodbye"))
170                .boxed(),
171        ));
172
173        session.ask_question(Content::localized("npc-question-general"), responses)
174    })
175}
176
177fn about_site<S: State>(session: DialogueSession) -> impl Action<S> {
178    now(move |ctx, _| {
179        if let Some(site_name) = util::site_name(ctx, ctx.npc.current_site) {
180            let mut action = session
181                .say_statement(
182                    Content::localized("npc-info-current_site").with_arg("site", site_name),
183                )
184                .boxed();
185
186            if let Some(current_site) = ctx.npc.current_site
187                && let Some(current_site) = ctx.data.sites.get(current_site)
188            {
189                for mention_site in &current_site.nearby_sites_by_size {
190                    if ctx.rng.random_bool(0.5)
191                        && let Some(content) = tell_site_content(ctx, *mention_site)
192                    {
193                        action = action.then(session.say_statement(content)).boxed();
194                    }
195                }
196            }
197
198            action
199        } else {
200            session
201                .say_statement(Content::localized("npc-info-unknown"))
202                .boxed()
203        }
204    })
205}
206
207fn about_self<S: State>(session: DialogueSession) -> impl Action<S> {
208    now(move |ctx, _| {
209        let name = Content::localized("npc-info-self_name")
210            .with_arg("name", ctx.npc.get_name().as_deref().unwrap_or("unknown"));
211
212        let job = ctx
213            .npc
214            .profession()
215            .map(|p| match p {
216                Profession::Farmer => "noun-role-farmer",
217                Profession::Hunter => "noun-role-hunter",
218                Profession::Merchant => "noun-role-merchant",
219                Profession::Guard => "noun-role-guard",
220                Profession::Adventurer(_) => "noun-role-adventurer",
221                Profession::Blacksmith => "noun-role-blacksmith",
222                Profession::Chef => "noun-role-chef",
223                Profession::Alchemist => "noun-role-alchemist",
224                Profession::Pirate(_) => "noun-role-pirate",
225                Profession::Cultist => "noun-role-cultist",
226                Profession::Herbalist => "noun-role-herbalist",
227                Profession::Captain => "noun-role-captain",
228            })
229            .map(|p| Content::localized("npc-info-role").with_arg("role", Content::localized(p)))
230            .unwrap_or_else(|| Content::localized("noun-role-none"));
231
232        let home = if let Some(site_name) = util::site_name(ctx, ctx.npc.home) {
233            Content::localized("npc-info-self_home").with_arg("site", site_name)
234        } else {
235            Content::localized("npc-info-self_homeless")
236        };
237
238        session
239            .say_statement(name)
240            .then(session.say_statement(job))
241            .then(session.say_statement(home))
242    })
243}
244
245fn sentiments<S: State>(tgt: Actor, session: DialogueSession) -> impl Action<S> {
246    session.ask_question(Content::Plain("...".to_string()), [(
247        Content::localized("dialogue-me"),
248        now(move |ctx, _| {
249            if ctx.sentiments.toward(tgt).is(Sentiment::ALLY) {
250                session.say_statement(Content::localized("npc-response-like_you"))
251            } else if ctx.sentiments.toward(tgt).is(Sentiment::RIVAL) {
252                session.say_statement(Content::localized("npc-response-dislike_you"))
253            } else {
254                session.say_statement(Content::localized("npc-response-ambivalent_you"))
255            }
256        }),
257    )])
258}
259
260fn hire<S: State>(tgt: Actor, session: DialogueSession) -> impl Action<S> {
261    now(move |ctx, _| {
262        if ctx.npc.job.is_none() && ctx.npc.rng(38792).random_bool(0.5) {
263            let hire_level = match ctx.npc.profession() {
264                Some(Profession::Adventurer(l)) => l,
265                _ => 0,
266            };
267            let price_mul = 1u32 << hire_level.min(31);
268            let mut responses = Vec::new();
269            responses.push((
270                Response::from(Content::localized("dialogue-cancel_interaction")),
271                session
272                    .say_statement(Content::localized("npc-response-no_problem"))
273                    .boxed(),
274            ));
275            let options = [
276                (
277                    1.0,
278                    60,
279                    Content::localized_attr("dialogue-buy_hire_days", "day"),
280                ),
281                (
282                    7.0,
283                    300,
284                    Content::localized_attr("dialogue-buy_hire_days", "week"),
285                ),
286            ];
287            for (days, base_price, msg) in options {
288                responses.push((
289                    Response {
290                        msg,
291                        given_item: Some((
292                            Arc::<ItemDef>::load_cloned("common.items.utility.coins").unwrap(),
293                            price_mul.saturating_mul(base_price),
294                        )),
295                    },
296                    session
297                        .say_statement(Content::localized("npc-response-accept_hire"))
298                        .then(just(move |ctx, _| {
299                            ctx.controller.set_newly_hired(
300                                tgt,
301                                ctx.time.add_days(days, &ctx.system_data.server_constants),
302                            );
303                        }))
304                        .boxed(),
305                ));
306            }
307            session
308                .ask_question(Content::localized("npc-response-hire_time"), responses)
309                .boxed()
310        } else {
311            session
312                .say_statement(Content::localized("npc-response-decline_hire"))
313                .boxed()
314        }
315    })
316}
317
318fn directions<S: State>(session: DialogueSession) -> impl Action<S> {
319    now(move |ctx, _| {
320        let mut responses = Vec::new();
321
322        for actor in ctx.data
323            .quests
324            .related_actors(session.target)
325            .filter(|actor| *actor != Actor::Npc(ctx.npc_id))
326            // Avoid mentioning too many actors
327            .take(32)
328        {
329            if let Some(pos) = util::locate_actor(ctx, actor)
330                && let Some(name) = util::actor_name(ctx, actor)
331            {
332                responses.push((
333                    Content::localized("dialogue-direction-actor").with_arg("name", name.clone()),
334                    session
335                        .give_marker(
336                            Marker::at(pos.xy())
337                                .with_label(
338                                    Content::localized("hud-map-character-label")
339                                        .with_arg("name", name.clone()),
340                                )
341                                .with_kind(MarkerKind::Character)
342                                .with_id(actor)
343                                .with_quest_flag(true),
344                        )
345                        .then(session.say_statement(Content::localized("npc-response-directions")))
346                        .boxed(),
347                ));
348            }
349        }
350
351        if let Some(current_site) = ctx.npc.current_site
352            && let Some(ws_id) = ctx.data.sites[current_site].world_site
353        {
354            let direction_to_nearest =
355                |f: fn(&&world::site::Plot) -> bool,
356                 plot_name: fn(&world::site::Plot) -> Content| {
357                    now(move |ctx, _| {
358                        let ws = ctx.index.sites.get(ws_id);
359                        if let Some(p) = ws.plots().filter(f).min_by_key(|p| {
360                            ws.tile_center_wpos(p.root_tile())
361                                .distance_squared(ctx.npc.wpos.xy().as_())
362                        }) {
363                            session
364                                .give_marker(
365                                    Marker::at(ws.tile_center_wpos(p.root_tile()).as_())
366                                        .with_label(plot_name(p)),
367                                )
368                                .then(
369                                    session.say_statement(Content::localized(
370                                        "npc-response-directions",
371                                    )),
372                                )
373                                .boxed()
374                        } else {
375                            session
376                                .say_statement(Content::localized("npc-response-doesnt_exist"))
377                                .boxed()
378                        }
379                    })
380                    .boxed()
381                };
382
383            responses.push((
384                Content::localized("dialogue-direction-tavern"),
385                direction_to_nearest(
386                    |p| matches!(p.kind(), PlotKind::Tavern(_)),
387                    |p| match p.kind() {
388                        PlotKind::Tavern(t) => Content::Plain(t.name.clone()),
389                        _ => unreachable!(),
390                    },
391                ),
392            ));
393            responses.push((
394                Content::localized("dialogue-direction-plaza"),
395                direction_to_nearest(
396                    |p| matches!(p.kind(), PlotKind::Plaza(_)),
397                    |_| Content::localized("hud-map-plaza"),
398                ),
399            ));
400            responses.push((
401                Content::localized("dialogue-direction-workshop"),
402                direction_to_nearest(
403                    |p| matches!(p.kind().meta(), Some(PlotKindMeta::Workshop { .. })),
404                    |_| Content::localized("hud-map-workshop"),
405                ),
406            ));
407            responses.push((
408                Content::localized("dialogue-direction-airship_dock"),
409                direction_to_nearest(
410                    |p| matches!(p.kind().meta(), Some(PlotKindMeta::AirshipDock { .. })),
411                    |_| Content::localized("hud-map-airship_dock"),
412                ),
413            ));
414        }
415
416        session.ask_question(Content::localized("npc-question-directions"), responses)
417    })
418}
419
420fn rock_paper_scissors<S: State>(session: DialogueSession) -> impl Action<S> {
421    now(move |ctx, _| {
422        #[derive(PartialEq, Eq, Clone, Copy)]
423        enum RockPaperScissor {
424            Rock,
425            Paper,
426            Scissors,
427        }
428        use RockPaperScissor::*;
429        impl RockPaperScissor {
430            fn i18n_key(&self) -> &'static str {
431                match self {
432                    Rock => "dialogue-game-rock",
433                    Paper => "dialogue-game-paper",
434                    Scissors => "dialogue-game-scissors",
435                }
436            }
437        }
438        fn end<S: State>(
439            session: DialogueSession,
440            our: RockPaperScissor,
441            their: RockPaperScissor,
442        ) -> impl Action<S> {
443            let draw = our == their;
444            let we_win = matches!(
445                (our, their),
446                (Rock, Scissors) | (Paper, Rock) | (Scissors, Paper)
447            );
448            let result = if draw {
449                "dialogue-game-draw"
450            } else if we_win {
451                "dialogue-game-win"
452            } else {
453                "dialogue-game-lose"
454            };
455
456            session
457                .say_statement(Content::localized(our.i18n_key()))
458                .then(session.say_statement(Content::localized(result)))
459        }
460        let choices = [Rock, Paper, Scissors];
461        let our_choice = choices
462            .choose(&mut ctx.rng)
463            .expect("We have a non-empty array");
464
465        let choices = choices.map(|choice| {
466            (
467                Response::from(Content::localized(choice.i18n_key())),
468                end(session, *our_choice, choice),
469            )
470        });
471
472        session.ask_question(
473            Content::localized("dialogue-game-rock_paper_scissors"),
474            choices,
475        )
476    })
477}
478
479fn games<S: State>(session: DialogueSession) -> impl Action<S> {
480    let games = [(
481        Response::from(Content::localized("dialogue-game-rock_paper_scissors")),
482        rock_paper_scissors(session),
483    )];
484
485    session.ask_question(Content::localized("dialogue-game-what_game"), games)
486}