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 match &ctx.npc.job {
9 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 let Actor::Npc(target_npc_id) = target else {
85 continue;
86 };
87 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 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 responses.push((
161 Response::from(Content::localized("dialogue-play_game")),
162 dialogue::games(session).boxed(),
163 ));
164 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 ¤t_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 .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}