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
41        session.ask_question(Content::localized("npc-question-general"), responses)
42    })
43}
44
45fn about_site<S: State>(session: DialogueSession) -> impl Action<S> {
46    now(move |ctx, _| {
47        if let Some(site_name) = util::site_name(ctx, ctx.npc.current_site) {
48            let mut action = session
49                .say_statement(Content::localized_with_args("npc-info-current_site", [(
50                    "site",
51                    Content::Plain(site_name),
52                )]))
53                .boxed();
54
55            if let Some(current_site) = ctx.npc.current_site
56                && let Some(current_site) = ctx.state.data().sites.get(current_site)
57            {
58                for mention_site in &current_site.nearby_sites_by_size {
59                    if ctx.rng.gen_bool(0.5)
60                        && let Some(content) = tell_site_content(ctx, *mention_site)
61                    {
62                        action = action.then(session.say_statement(content)).boxed();
63                    }
64                }
65            }
66
67            action
68        } else {
69            session
70                .say_statement(Content::localized("npc-info-unknown"))
71                .boxed()
72        }
73    })
74}
75
76fn about_self<S: State>(session: DialogueSession) -> impl Action<S> {
77    now(move |ctx, _| {
78        let name = Content::localized_with_args("npc-info-self_name", [(
79            "name",
80            Content::Plain(ctx.npc.get_name()),
81        )]);
82
83        let job = ctx
84            .npc
85            .profession()
86            .map(|p| match p {
87                Profession::Farmer => "npc-info-role_farmer",
88                Profession::Hunter => "npc-info-role_hunter",
89                Profession::Merchant => "npc-info-role_merchant",
90                Profession::Guard => "npc-info-role_guard",
91                Profession::Adventurer(_) => "npc-info-role_adventurer",
92                Profession::Blacksmith => "npc-info-role_blacksmith",
93                Profession::Chef => "npc-info-role_chef",
94                Profession::Alchemist => "npc-info-role_alchemist",
95                Profession::Pirate => "npc-info-role_pirate",
96                Profession::Cultist => "npc-info-role_cultist",
97                Profession::Herbalist => "npc-info-role_herbalist",
98                Profession::Captain => "npc-info-role_captain",
99            })
100            .map(|p| {
101                Content::localized_with_args("npc-info-role", [("role", Content::localized(p))])
102            })
103            .unwrap_or_else(|| Content::localized("npc-info-role_none"));
104
105        let home = if let Some(site_name) = util::site_name(ctx, ctx.npc.home) {
106            Content::localized_with_args("npc-info-self_home", [(
107                "site",
108                Content::Plain(site_name),
109            )])
110        } else {
111            Content::localized("npc-info-self_homeless")
112        };
113
114        session
115            .say_statement(name)
116            .then(session.say_statement(job))
117            .then(session.say_statement(home))
118    })
119}
120
121fn sentiments<S: State>(tgt: Actor, session: DialogueSession) -> impl Action<S> {
122    session.ask_question(Content::Plain("...".to_string()), [(
123        Content::localized("dialogue-me"),
124        now(move |ctx, _| {
125            if ctx.sentiments.toward(tgt).is(Sentiment::ALLY) {
126                session.say_statement(Content::localized("npc-response-like_you"))
127            } else if ctx.sentiments.toward(tgt).is(Sentiment::RIVAL) {
128                session.say_statement(Content::localized("npc-response-dislike_you"))
129            } else {
130                session.say_statement(Content::localized("npc-response-ambivalent_you"))
131            }
132        }),
133    )])
134}
135
136fn hire<S: State>(tgt: Actor, session: DialogueSession) -> impl Action<S> {
137    now(move |ctx, _| {
138        if ctx.npc.hiring.is_none() && ctx.npc.rng(38792).gen_bool(0.5) {
139            let hire_level = match ctx.npc.profession() {
140                Some(Profession::Adventurer(l)) => l,
141                _ => 0,
142            };
143            let price_mul = 1u32 << hire_level.min(31);
144            let mut responses = Vec::new();
145            responses.push((
146                Response::from(Content::localized("dialogue-cancel_interaction")),
147                session
148                    .say_statement(Content::localized("npc-response-no_problem"))
149                    .boxed(),
150            ));
151            let options = [
152                (
153                    1.0,
154                    60,
155                    Content::localized_attr("dialogue-buy_hire_days", "day"),
156                ),
157                (
158                    7.0,
159                    300,
160                    Content::localized_attr("dialogue-buy_hire_days", "week"),
161                ),
162            ];
163            for (days, base_price, msg) in options {
164                responses.push((
165                    Response {
166                        msg,
167                        given_item: Some((
168                            Arc::<ItemDef>::load_cloned("common.items.utility.coins").unwrap(),
169                            price_mul.saturating_mul(base_price),
170                        )),
171                    },
172                    session
173                        .say_statement(Content::localized("npc-response-accept_hire"))
174                        .then(just(move |ctx, _| {
175                            ctx.controller.set_newly_hired(
176                                tgt,
177                                ctx.time.add_days(days, &ctx.system_data.server_constants),
178                            );
179                        }))
180                        .boxed(),
181                ));
182            }
183            session
184                .ask_question(Content::localized("npc-response-hire_time"), responses)
185                .boxed()
186        } else {
187            session
188                .say_statement(Content::localized("npc-response-decline_hire"))
189                .boxed()
190        }
191    })
192}
193
194fn directions<S: State>(session: DialogueSession) -> impl Action<S> {
195    now(move |ctx, _| {
196        let mut responses = Vec::new();
197
198        if let Some(current_site) = ctx.npc.current_site
199            && let Some(ws_id) = ctx.state.data().sites[current_site].world_site
200        {
201            let direction_to_nearest =
202                |f: fn(&&world::site2::Plot) -> bool,
203                 plot_name: fn(&world::site2::Plot) -> Content| {
204                    now(move |ctx, _| {
205                        if let Some(ws) = ctx.index.sites.get(ws_id).site2() {
206                            if let Some(p) = ws.plots().filter(f).min_by_key(|p| {
207                                ws.tile_center_wpos(p.root_tile())
208                                    .distance_squared(ctx.npc.wpos.xy().as_())
209                            }) {
210                                ctx.controller.dialogue_marker(
211                                    session,
212                                    ws.tile_center_wpos(p.root_tile()),
213                                    plot_name(p),
214                                );
215                                session.say_statement(Content::localized("npc-response-directions"))
216                            } else {
217                                session
218                                    .say_statement(Content::localized("npc-response-doesnt_exist"))
219                            }
220                        } else {
221                            session.say_statement(Content::localized("npc-info-unknown"))
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}