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