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 if let Some(Profession::Captain) = &ctx.npc.profession() {
142 responses.push((
143 Response::from(Content::localized("dialogue-question-where-ship-going")),
144 dialogue::where_are_we_going_next(session).boxed(),
145 ));
146 }
147
148 responses.push((
150 Response::from(Content::localized("dialogue-question-site")),
151 dialogue::about_site(session).boxed(),
152 ));
153 responses.push((
154 Response::from(Content::localized("dialogue-question-self")),
155 dialogue::about_self(session).boxed(),
156 ));
157 responses.push((
158 Response::from(Content::localized("dialogue-question-sentiment")),
159 dialogue::sentiments(tgt, session).boxed(),
160 ));
161 responses.push((
162 Response::from(Content::localized("dialogue-question-directions")),
163 dialogue::directions(session).boxed(),
164 ));
165
166 responses.push((
168 Response::from(Content::localized("dialogue-play_game")),
169 dialogue::games(session).boxed(),
170 ));
171 responses.push((
174 Response::from(Content::localized("dialogue-finish")),
175 session
176 .say_statement(Content::localized("npc-goodbye"))
177 .boxed(),
178 ));
179
180 session.ask_question(Content::localized("npc-question-general"), responses)
181 })
182}
183
184fn about_site<S: State>(session: DialogueSession) -> impl Action<S> {
185 now(move |ctx, _| {
186 if let Some(site_name) = util::site_name(ctx, ctx.npc.current_site) {
187 let mut action = session
188 .say_statement(
189 Content::localized("npc-info-current_site").with_arg("site", site_name),
190 )
191 .boxed();
192
193 if let Some(current_site) = ctx.npc.current_site
194 && let Some(current_site) = ctx.data.sites.get(current_site)
195 {
196 for mention_site in ¤t_site.nearby_sites_by_size {
197 if ctx.rng.random_bool(0.5)
198 && let Some(content) = tell_site_content(ctx, *mention_site)
199 {
200 action = action.then(session.say_statement(content)).boxed();
201 }
202 }
203 }
204
205 action
206 } else {
207 session
208 .say_statement(Content::localized("npc-info-unknown"))
209 .boxed()
210 }
211 })
212}
213
214fn about_self<S: State>(session: DialogueSession) -> impl Action<S> {
215 now(move |ctx, _| {
216 let name = Content::localized("npc-info-self_name")
217 .with_arg("name", ctx.npc.get_name().as_deref().unwrap_or("unknown"));
218
219 let job = ctx
220 .npc
221 .profession()
222 .map(|p| match p {
223 Profession::Farmer => "noun-role-farmer",
224 Profession::Hunter => "noun-role-hunter",
225 Profession::Merchant => "noun-role-merchant",
226 Profession::Guard => "noun-role-guard",
227 Profession::Adventurer(_) => "noun-role-adventurer",
228 Profession::Blacksmith => "noun-role-blacksmith",
229 Profession::Chef => "noun-role-chef",
230 Profession::Alchemist => "noun-role-alchemist",
231 Profession::Pirate(_) => "noun-role-pirate",
232 Profession::Cultist => "noun-role-cultist",
233 Profession::Herbalist => "noun-role-herbalist",
234 Profession::Captain => "noun-role-captain",
235 })
236 .map(|p| Content::localized("npc-info-role").with_arg("role", Content::localized(p)))
237 .unwrap_or_else(|| Content::localized("noun-role-none"));
238
239 let home = if let Some(site_name) = util::site_name(ctx, ctx.npc.home) {
240 Content::localized("npc-info-self_home").with_arg("site", site_name)
241 } else {
242 Content::localized("npc-info-self_homeless")
243 };
244
245 session
246 .say_statement(name)
247 .then(session.say_statement(job))
248 .then(session.say_statement(home))
249 })
250}
251
252fn where_are_we_going_next<S: State>(session: DialogueSession) -> impl Action<S> {
253 now(move |ctx, _| match ctx.npc.profession() {
254 Some(Profession::Captain) => {
255 let msg = if let Some(assigned_route) =
256 ctx.data.airship_sim.assigned_routes.get(&ctx.npc_id)
257 {
258 let dests = ctx.data.airship_sim.next_destinations(
259 &ctx.world.civs().airships,
260 &ctx.world.sim().map_size_lg(),
261 assigned_route.0,
262 ctx.controller.current_airship_pilot_leg,
263 );
264
265 if let Some(dests) = dests {
266 let first_site_name = ctx
267 .index
268 .sites
269 .get(dests.0.site_id)
270 .name()
271 .unwrap_or("Unknown Site")
272 .to_string();
273 let next_site_name = ctx
274 .index
275 .sites
276 .get(dests.1.site_id)
277 .name()
278 .unwrap_or("Unknown Site")
279 .to_string();
280
281 let first_site_vec = dests.0.approach_transition_pos - ctx.npc.wpos.xy();
282 let first_site_dir = Direction::from_dir(first_site_vec).localize_npc();
283
284 let next_site_vec = dests.1.approach_transition_pos - ctx.npc.wpos.xy();
285 let next_site_dir = Direction::from_dir(next_site_vec).localize_npc();
286
287 Content::localized("npc-speech-pilot-where_heading_now")
288 .with_arg("dir", first_site_dir)
289 .with_arg("dst", first_site_name)
290 .with_arg("ndir", next_site_dir)
291 .with_arg("ndst", next_site_name)
292 } else {
293 Content::localized("npc-speech-pilot-unknown_destination")
294 }
295 } else {
296 Content::localized("npc-speech-pilot-unknown_destination")
297 };
298
299 session.say_statement(msg)
300 },
301 _ => session.say_statement(Content::localized(
302 "npc-speech-where_are_we_going_wrong_profession",
303 )),
304 })
305}
306
307fn sentiments<S: State>(tgt: Actor, session: DialogueSession) -> impl Action<S> {
308 session.ask_question(Content::Plain("...".to_string()), [(
309 Content::localized("dialogue-me"),
310 now(move |ctx, _| {
311 if ctx.sentiments.toward(tgt).is(Sentiment::ALLY) {
312 session.say_statement(Content::localized("npc-response-like_you"))
313 } else if ctx.sentiments.toward(tgt).is(Sentiment::RIVAL) {
314 session.say_statement(Content::localized("npc-response-dislike_you"))
315 } else {
316 session.say_statement(Content::localized("npc-response-ambivalent_you"))
317 }
318 }),
319 )])
320}
321
322fn hire<S: State>(tgt: Actor, session: DialogueSession) -> impl Action<S> {
323 now(move |ctx, _| {
324 if ctx.npc.job.is_none() && ctx.npc.rng(38792).random_bool(0.5) {
325 let hire_level = match ctx.npc.profession() {
326 Some(Profession::Adventurer(l)) => l,
327 _ => 0,
328 };
329 let price_mul = 1u32 << hire_level.min(31);
330 let mut responses = Vec::new();
331 responses.push((
332 Response::from(Content::localized("dialogue-cancel_interaction")),
333 session
334 .say_statement(Content::localized("npc-response-no_problem"))
335 .boxed(),
336 ));
337 let options = [
338 (
339 1.0,
340 60,
341 Content::localized_attr("dialogue-buy_hire_days", "day"),
342 ),
343 (
344 7.0,
345 300,
346 Content::localized_attr("dialogue-buy_hire_days", "week"),
347 ),
348 ];
349 for (days, base_price, msg) in options {
350 responses.push((
351 Response {
352 msg,
353 given_item: Some((
354 Arc::<ItemDef>::load_cloned("common.items.utility.coins").unwrap(),
355 price_mul.saturating_mul(base_price),
356 )),
357 },
358 session
359 .say_statement(Content::localized("npc-response-accept_hire"))
360 .then(just(move |ctx, _| {
361 ctx.controller.set_newly_hired(
362 tgt,
363 ctx.time.add_days(days, &ctx.system_data.server_constants),
364 );
365 }))
366 .boxed(),
367 ));
368 }
369 session
370 .ask_question(Content::localized("npc-response-hire_time"), responses)
371 .boxed()
372 } else {
373 session
374 .say_statement(Content::localized("npc-response-decline_hire"))
375 .boxed()
376 }
377 })
378}
379
380fn directions<S: State>(session: DialogueSession) -> impl Action<S> {
381 now(move |ctx, _| {
382 let mut responses = Vec::new();
383
384 for actor in ctx.data
385 .quests
386 .related_actors(session.target)
387 .filter(|actor| *actor != Actor::Npc(ctx.npc_id))
388 .take(32)
390 {
391 if let Some(pos) = util::locate_actor(ctx, actor)
392 && let Some(name) = util::actor_name(ctx, actor)
393 {
394 responses.push((
395 Content::localized("dialogue-direction-actor").with_arg("name", name.clone()),
396 session
397 .give_marker(
398 Marker::at(pos.xy())
399 .with_label(
400 Content::localized("hud-map-character-label")
401 .with_arg("name", name.clone()),
402 )
403 .with_kind(MarkerKind::Character)
404 .with_id(actor)
405 .with_quest_flag(true),
406 )
407 .then(session.say_statement(Content::localized("npc-response-directions")))
408 .boxed(),
409 ));
410 }
411 }
412
413 if let Some(current_site) = ctx.npc.current_site
414 && let Some(ws_id) = ctx.data.sites[current_site].world_site
415 {
416 let direction_to_nearest =
417 |f: fn(&&world::site::Plot) -> bool,
418 plot_name: fn(&world::site::Plot) -> Content| {
419 now(move |ctx, _| {
420 let ws = ctx.index.sites.get(ws_id);
421 if let Some(p) = ws.plots().filter(f).min_by_key(|p| {
422 ws.tile_center_wpos(p.root_tile())
423 .distance_squared(ctx.npc.wpos.xy().as_())
424 }) {
425 session
426 .give_marker(
427 Marker::at(ws.tile_center_wpos(p.root_tile()).as_())
428 .with_label(plot_name(p)),
429 )
430 .then(
431 session.say_statement(Content::localized(
432 "npc-response-directions",
433 )),
434 )
435 .boxed()
436 } else {
437 session
438 .say_statement(Content::localized("npc-response-doesnt_exist"))
439 .boxed()
440 }
441 })
442 .boxed()
443 };
444
445 responses.push((
446 Content::localized("dialogue-direction-tavern"),
447 direction_to_nearest(
448 |p| matches!(p.kind(), PlotKind::Tavern(_)),
449 |p| match p.kind() {
450 PlotKind::Tavern(t) => Content::Plain(t.name.clone()),
451 _ => unreachable!(),
452 },
453 ),
454 ));
455 responses.push((
456 Content::localized("dialogue-direction-plaza"),
457 direction_to_nearest(
458 |p| matches!(p.kind(), PlotKind::Plaza(_)),
459 |_| Content::localized("hud-map-plaza"),
460 ),
461 ));
462 responses.push((
463 Content::localized("dialogue-direction-workshop"),
464 direction_to_nearest(
465 |p| matches!(p.kind().meta(), Some(PlotKindMeta::Workshop { .. })),
466 |_| Content::localized("hud-map-workshop"),
467 ),
468 ));
469 responses.push((
470 Content::localized("dialogue-direction-airship_dock"),
471 direction_to_nearest(
472 |p| matches!(p.kind().meta(), Some(PlotKindMeta::AirshipDock { .. })),
473 |_| Content::localized("hud-map-airship_dock"),
474 ),
475 ));
476 }
477
478 session.ask_question(Content::localized("npc-question-directions"), responses)
479 })
480}
481
482fn rock_paper_scissors<S: State>(session: DialogueSession) -> impl Action<S> {
483 now(move |ctx, _| {
484 #[derive(PartialEq, Eq, Clone, Copy)]
485 enum RockPaperScissor {
486 Rock,
487 Paper,
488 Scissors,
489 }
490 use RockPaperScissor::*;
491 impl RockPaperScissor {
492 fn i18n_key(&self) -> &'static str {
493 match self {
494 Rock => "dialogue-game-rock",
495 Paper => "dialogue-game-paper",
496 Scissors => "dialogue-game-scissors",
497 }
498 }
499 }
500 fn end<S: State>(
501 session: DialogueSession,
502 our: RockPaperScissor,
503 their: RockPaperScissor,
504 ) -> impl Action<S> {
505 let draw = our == their;
506 let we_win = matches!(
507 (our, their),
508 (Rock, Scissors) | (Paper, Rock) | (Scissors, Paper)
509 );
510 let result = if draw {
511 "dialogue-game-draw"
512 } else if we_win {
513 "dialogue-game-win"
514 } else {
515 "dialogue-game-lose"
516 };
517
518 session
519 .say_statement(Content::localized(our.i18n_key()))
520 .then(session.say_statement(Content::localized(result)))
521 }
522 let choices = [Rock, Paper, Scissors];
523 let our_choice = choices
524 .choose(&mut ctx.rng)
525 .expect("We have a non-empty array");
526
527 let choices = choices.map(|choice| {
528 (
529 Response::from(Content::localized(choice.i18n_key())),
530 end(session, *our_choice, choice),
531 )
532 });
533
534 session.ask_question(
535 Content::localized("dialogue-game-rock_paper_scissors"),
536 choices,
537 )
538 })
539}
540
541fn games<S: State>(session: DialogueSession) -> impl Action<S> {
542 let games = [(
543 Response::from(Content::localized("dialogue-game-rock_paper_scissors")),
544 rock_paper_scissors(session),
545 )];
546
547 session.ask_question(Content::localized("dialogue-game-what_game"), games)
548}