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 can_be_hired = matches!(ctx.npc.profession(), Some(Profession::Adventurer(_)));
6        let is_hired_by_tgt = ctx.npc.hiring.is_some_and(|(a, _)| a == tgt);
7
8        let mut responses = Vec::new();
9
10        responses.push((
11            Response::from(Content::localized("dialogue-question-site")),
12            dialogue::about_site(session).boxed(),
13        ));
14        responses.push((
15            Response::from(Content::localized("dialogue-question-self")),
16            dialogue::about_self(session).boxed(),
17        ));
18        responses.push((
19            Response::from(Content::localized("dialogue-question-sentiment")),
20            dialogue::sentiments(tgt, session).boxed(),
21        ));
22        if is_hired_by_tgt {
23            responses.push((
24                Response::from(Content::localized("dialogue-cancel_hire")),
25                session
26                    .say_statement(Content::localized("npc-dialogue-hire_cancelled"))
27                    .then(just(move |ctx, _| ctx.controller.end_hiring()))
28                    .boxed(),
29            ));
30        } else if can_be_hired {
31            responses.push((
32                Response::from(Content::localized("dialogue-question-hire")),
33                dialogue::hire(tgt, session).boxed(),
34            ));
35        }
36        responses.push((
37            Response::from(Content::localized("dialogue-question-directions")),
38            dialogue::directions(session).boxed(),
39        ));
40        responses.push((
41            Response::from(Content::localized("dialogue-play_game")),
42            dialogue::games(session).boxed(),
43        ));
44
45        session.ask_question(Content::localized("npc-question-general"), responses)
46    })
47}
48
49fn about_site<S: State>(session: DialogueSession) -> impl Action<S> {
50    now(move |ctx, _| {
51        if let Some(site_name) = util::site_name(ctx, ctx.npc.current_site) {
52            let mut action = session
53                .say_statement(Content::localized_with_args("npc-info-current_site", [(
54                    "site",
55                    Content::Plain(site_name),
56                )]))
57                .boxed();
58
59            if let Some(current_site) = ctx.npc.current_site
60                && let Some(current_site) = ctx.state.data().sites.get(current_site)
61            {
62                for mention_site in &current_site.nearby_sites_by_size {
63                    if ctx.rng.gen_bool(0.5)
64                        && let Some(content) = tell_site_content(ctx, *mention_site)
65                    {
66                        action = action.then(session.say_statement(content)).boxed();
67                    }
68                }
69            }
70
71            action
72        } else {
73            session
74                .say_statement(Content::localized("npc-info-unknown"))
75                .boxed()
76        }
77    })
78}
79
80fn about_self<S: State>(session: DialogueSession) -> impl Action<S> {
81    now(move |ctx, _| {
82        let name = Content::localized_with_args("npc-info-self_name", [(
83            "name",
84            Content::Plain(ctx.npc.get_name()),
85        )]);
86
87        let job = ctx
88            .npc
89            .profession()
90            .map(|p| match p {
91                Profession::Farmer => "npc-info-role_farmer",
92                Profession::Hunter => "npc-info-role_hunter",
93                Profession::Merchant => "npc-info-role_merchant",
94                Profession::Guard => "npc-info-role_guard",
95                Profession::Adventurer(_) => "npc-info-role_adventurer",
96                Profession::Blacksmith => "npc-info-role_blacksmith",
97                Profession::Chef => "npc-info-role_chef",
98                Profession::Alchemist => "npc-info-role_alchemist",
99                Profession::Pirate => "npc-info-role_pirate",
100                Profession::Cultist => "npc-info-role_cultist",
101                Profession::Herbalist => "npc-info-role_herbalist",
102                Profession::Captain => "npc-info-role_captain",
103            })
104            .map(|p| {
105                Content::localized_with_args("npc-info-role", [("role", Content::localized(p))])
106            })
107            .unwrap_or_else(|| Content::localized("npc-info-role_none"));
108
109        let home = if let Some(site_name) = util::site_name(ctx, ctx.npc.home) {
110            Content::localized_with_args("npc-info-self_home", [(
111                "site",
112                Content::Plain(site_name),
113            )])
114        } else {
115            Content::localized("npc-info-self_homeless")
116        };
117
118        session
119            .say_statement(name)
120            .then(session.say_statement(job))
121            .then(session.say_statement(home))
122    })
123}
124
125fn sentiments<S: State>(tgt: Actor, session: DialogueSession) -> impl Action<S> {
126    session.ask_question(Content::Plain("...".to_string()), [(
127        Content::localized("dialogue-me"),
128        now(move |ctx, _| {
129            if ctx.sentiments.toward(tgt).is(Sentiment::ALLY) {
130                session.say_statement(Content::localized("npc-response-like_you"))
131            } else if ctx.sentiments.toward(tgt).is(Sentiment::RIVAL) {
132                session.say_statement(Content::localized("npc-response-dislike_you"))
133            } else {
134                session.say_statement(Content::localized("npc-response-ambivalent_you"))
135            }
136        }),
137    )])
138}
139
140fn hire<S: State>(tgt: Actor, session: DialogueSession) -> impl Action<S> {
141    now(move |ctx, _| {
142        if ctx.npc.hiring.is_none() && ctx.npc.rng(38792).gen_bool(0.5) {
143            let hire_level = match ctx.npc.profession() {
144                Some(Profession::Adventurer(l)) => l,
145                _ => 0,
146            };
147            let price_mul = 1u32 << hire_level.min(31);
148            let mut responses = Vec::new();
149            responses.push((
150                Response::from(Content::localized("dialogue-cancel_interaction")),
151                session
152                    .say_statement(Content::localized("npc-response-no_problem"))
153                    .boxed(),
154            ));
155            let options = [
156                (
157                    1.0,
158                    60,
159                    Content::localized_attr("dialogue-buy_hire_days", "day"),
160                ),
161                (
162                    7.0,
163                    300,
164                    Content::localized_attr("dialogue-buy_hire_days", "week"),
165                ),
166            ];
167            for (days, base_price, msg) in options {
168                responses.push((
169                    Response {
170                        msg,
171                        given_item: Some((
172                            Arc::<ItemDef>::load_cloned("common.items.utility.coins").unwrap(),
173                            price_mul.saturating_mul(base_price),
174                        )),
175                    },
176                    session
177                        .say_statement(Content::localized("npc-response-accept_hire"))
178                        .then(just(move |ctx, _| {
179                            ctx.controller.set_newly_hired(
180                                tgt,
181                                ctx.time.add_days(days, &ctx.system_data.server_constants),
182                            );
183                        }))
184                        .boxed(),
185                ));
186            }
187            session
188                .ask_question(Content::localized("npc-response-hire_time"), responses)
189                .boxed()
190        } else {
191            session
192                .say_statement(Content::localized("npc-response-decline_hire"))
193                .boxed()
194        }
195    })
196}
197
198fn directions<S: State>(session: DialogueSession) -> impl Action<S> {
199    now(move |ctx, _| {
200        let mut responses = Vec::new();
201
202        if let Some(current_site) = ctx.npc.current_site
203            && let Some(ws_id) = ctx.state.data().sites[current_site].world_site
204        {
205            let direction_to_nearest =
206                |f: fn(&&world::site::Plot) -> bool,
207                 plot_name: fn(&world::site::Plot) -> Content| {
208                    now(move |ctx, _| {
209                        let ws = ctx.index.sites.get(ws_id);
210                        if let Some(p) = ws.plots().filter(f).min_by_key(|p| {
211                            ws.tile_center_wpos(p.root_tile())
212                                .distance_squared(ctx.npc.wpos.xy().as_())
213                        }) {
214                            ctx.controller.dialogue_marker(
215                                session,
216                                ws.tile_center_wpos(p.root_tile()),
217                                plot_name(p),
218                            );
219                            session.say_statement(Content::localized("npc-response-directions"))
220                        } else {
221                            session.say_statement(Content::localized("npc-response-doesnt_exist"))
222                        }
223                    })
224                    .boxed()
225                };
226
227            responses.push((
228                Content::localized("dialogue-direction-tavern"),
229                direction_to_nearest(
230                    |p| matches!(p.kind(), PlotKind::Tavern(_)),
231                    |p| match p.kind() {
232                        PlotKind::Tavern(t) => Content::Plain(t.name.clone()),
233                        _ => unreachable!(),
234                    },
235                ),
236            ));
237            responses.push((
238                Content::localized("dialogue-direction-plaza"),
239                direction_to_nearest(
240                    |p| matches!(p.kind(), PlotKind::Plaza(_)),
241                    |_| Content::localized("hud-map-plaza"),
242                ),
243            ));
244            responses.push((
245                Content::localized("dialogue-direction-workshop"),
246                direction_to_nearest(
247                    |p| matches!(p.kind().meta(), Some(PlotKindMeta::Workshop { .. })),
248                    |_| Content::localized("hud-map-workshop"),
249                ),
250            ));
251            responses.push((
252                Content::localized("dialogue-direction-airship_dock"),
253                direction_to_nearest(
254                    |p| matches!(p.kind().meta(), Some(PlotKindMeta::AirshipDock { .. })),
255                    |_| Content::localized("hud-map-airship_dock"),
256                ),
257            ));
258        }
259
260        session.ask_question(Content::localized("npc-question-directions"), responses)
261    })
262}
263
264fn rock_paper_scissors<S: State>(session: DialogueSession) -> impl Action<S> {
265    now(move |ctx, _| {
266        #[derive(PartialEq, Eq, Clone, Copy)]
267        enum RockPaperScissor {
268            Rock,
269            Paper,
270            Scissors,
271        }
272        use RockPaperScissor::*;
273        impl RockPaperScissor {
274            fn i18n_key(&self) -> &'static str {
275                match self {
276                    Rock => "dialogue-game-rock",
277                    Paper => "dialogue-game-paper",
278                    Scissors => "dialogue-game-scissors",
279                }
280            }
281        }
282        fn end<S: State>(
283            session: DialogueSession,
284            our: RockPaperScissor,
285            their: RockPaperScissor,
286        ) -> impl Action<S> {
287            let draw = our == their;
288            let we_win = matches!(
289                (our, their),
290                (Rock, Scissors) | (Paper, Rock) | (Scissors, Paper)
291            );
292            let result = if draw {
293                "dialogue-game-draw"
294            } else if we_win {
295                "dialogue-game-win"
296            } else {
297                "dialogue-game-lose"
298            };
299
300            session
301                .say_statement(Content::localized(our.i18n_key()))
302                .then(session.say_statement(Content::localized(result)))
303        }
304        let choices = [Rock, Paper, Scissors];
305        let our_choice = choices
306            .choose(&mut ctx.rng)
307            .expect("We have a non-empty array");
308
309        let choices = choices.map(|choice| {
310            (
311                Response::from(Content::localized(choice.i18n_key())),
312                end(session, *our_choice, choice),
313            )
314        });
315
316        session.ask_question(
317            Content::localized("dialogue-game-rock_paper_scissors"),
318            choices,
319        )
320    })
321}
322
323fn games<S: State>(session: DialogueSession) -> impl Action<S> {
324    let games = [(
325        Response::from(Content::localized("dialogue-game-rock_paper_scissors")),
326        rock_paper_scissors(session),
327    )];
328
329    session.ask_question(Content::localized("dialogue-game-what_game"), games)
330}