veloren_rtsim/rule/npc_ai/
dialogue.rs1use 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 ¤t_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}