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.state.data().quests.related_to(ctx.npc_id) {
37 let data = ctx.state.data();
38 let Some(quest) = data.quests.get(quest_id) else {
39 continue;
40 };
41 match &quest.kind {
42 QuestKind::Escort {
43 escortee,
44 escorter,
45 to,
46 } if *escortee == Actor::Npc(ctx.npc_id) && *escorter == tgt => {
47 let to_name =
48 util::site_name(ctx, *to).unwrap_or_else(|| "<unknown>".to_string());
49 let dst_wpos = ctx
50 .state
51 .data()
52 .sites
53 .get(*to)
54 .map_or(Vec2::zero(), |s| s.wpos.as_());
55 responses.push((
56 Response::from(Content::localized("dialogue-question-quest-escort-where")),
57 session
58 .give_marker(
59 Marker::at(dst_wpos)
60 .with_id(quest_id)
61 .with_label(
62 Content::localized("hud-map-escort-label")
63 .with_arg(
64 "name",
65 ctx.npc
66 .get_name()
67 .unwrap_or_else(|| "<unknown>".to_string()),
68 )
69 .with_arg("place", to_name.clone()),
70 )
71 .with_quest_flag(true),
72 )
73 .then(session.say_statement(Content::localized_with_args(
74 "npc-response-quest-escort-where",
75 [("dst", to_name)],
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) = 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(Content::localized_with_args("npc-info-current_site", [(
182 "site",
183 Content::Plain(site_name),
184 )]))
185 .boxed();
186
187 if let Some(current_site) = ctx.npc.current_site
188 && let Some(current_site) = ctx.state.data().sites.get(current_site)
189 {
190 for mention_site in ¤t_site.nearby_sites_by_size {
191 if ctx.rng.random_bool(0.5)
192 && let Some(content) = tell_site_content(ctx, *mention_site)
193 {
194 action = action.then(session.say_statement(content)).boxed();
195 }
196 }
197 }
198
199 action
200 } else {
201 session
202 .say_statement(Content::localized("npc-info-unknown"))
203 .boxed()
204 }
205 })
206}
207
208fn about_self<S: State>(session: DialogueSession) -> impl Action<S> {
209 now(move |ctx, _| {
210 let name = Content::localized("npc-info-self_name")
211 .with_arg("name", ctx.npc.get_name().as_deref().unwrap_or("unknown"));
212
213 let job = ctx
214 .npc
215 .profession()
216 .map(|p| match p {
217 Profession::Farmer => "noun-role-farmer",
218 Profession::Hunter => "noun-role-hunter",
219 Profession::Merchant => "noun-role-merchant",
220 Profession::Guard => "noun-role-guard",
221 Profession::Adventurer(_) => "noun-role-adventurer",
222 Profession::Blacksmith => "noun-role-blacksmith",
223 Profession::Chef => "noun-role-chef",
224 Profession::Alchemist => "noun-role-alchemist",
225 Profession::Pirate(_) => "noun-role-pirate",
226 Profession::Cultist => "noun-role-cultist",
227 Profession::Herbalist => "noun-role-herbalist",
228 Profession::Captain => "noun-role-captain",
229 })
230 .map(|p| {
231 Content::localized_with_args("npc-info-role", [("role", Content::localized(p))])
232 })
233 .unwrap_or_else(|| Content::localized("noun-role-none"));
234
235 let home = if let Some(site_name) = util::site_name(ctx, ctx.npc.home) {
236 Content::localized_with_args("npc-info-self_home", [(
237 "site",
238 Content::Plain(site_name),
239 )])
240 } else {
241 Content::localized("npc-info-self_homeless")
242 };
243
244 session
245 .say_statement(name)
246 .then(session.say_statement(job))
247 .then(session.say_statement(home))
248 })
249}
250
251fn sentiments<S: State>(tgt: Actor, session: DialogueSession) -> impl Action<S> {
252 session.ask_question(Content::Plain("...".to_string()), [(
253 Content::localized("dialogue-me"),
254 now(move |ctx, _| {
255 if ctx.sentiments.toward(tgt).is(Sentiment::ALLY) {
256 session.say_statement(Content::localized("npc-response-like_you"))
257 } else if ctx.sentiments.toward(tgt).is(Sentiment::RIVAL) {
258 session.say_statement(Content::localized("npc-response-dislike_you"))
259 } else {
260 session.say_statement(Content::localized("npc-response-ambivalent_you"))
261 }
262 }),
263 )])
264}
265
266fn hire<S: State>(tgt: Actor, session: DialogueSession) -> impl Action<S> {
267 now(move |ctx, _| {
268 if ctx.npc.job.is_none() && ctx.npc.rng(38792).random_bool(0.5) {
269 let hire_level = match ctx.npc.profession() {
270 Some(Profession::Adventurer(l)) => l,
271 _ => 0,
272 };
273 let price_mul = 1u32 << hire_level.min(31);
274 let mut responses = Vec::new();
275 responses.push((
276 Response::from(Content::localized("dialogue-cancel_interaction")),
277 session
278 .say_statement(Content::localized("npc-response-no_problem"))
279 .boxed(),
280 ));
281 let options = [
282 (
283 1.0,
284 60,
285 Content::localized_attr("dialogue-buy_hire_days", "day"),
286 ),
287 (
288 7.0,
289 300,
290 Content::localized_attr("dialogue-buy_hire_days", "week"),
291 ),
292 ];
293 for (days, base_price, msg) in options {
294 responses.push((
295 Response {
296 msg,
297 given_item: Some((
298 Arc::<ItemDef>::load_cloned("common.items.utility.coins").unwrap(),
299 price_mul.saturating_mul(base_price),
300 )),
301 },
302 session
303 .say_statement(Content::localized("npc-response-accept_hire"))
304 .then(just(move |ctx, _| {
305 ctx.controller.set_newly_hired(
306 tgt,
307 ctx.time.add_days(days, &ctx.system_data.server_constants),
308 );
309 }))
310 .boxed(),
311 ));
312 }
313 session
314 .ask_question(Content::localized("npc-response-hire_time"), responses)
315 .boxed()
316 } else {
317 session
318 .say_statement(Content::localized("npc-response-decline_hire"))
319 .boxed()
320 }
321 })
322}
323
324fn directions<S: State>(session: DialogueSession) -> impl Action<S> {
325 now(move |ctx, _| {
326 let mut responses = Vec::new();
327
328 for actor in ctx
329 .state
330 .data()
331 .quests
332 .related_actors(session.target)
333 .filter(|actor| *actor != Actor::Npc(ctx.npc_id))
334 .take(32)
336 {
337 if let Some(pos) = util::locate_actor(ctx, actor)
338 && let Some(name) = util::actor_name(ctx, actor)
339 {
340 responses.push((
341 Content::localized("dialogue-direction-actor").with_arg("name", name.clone()),
342 session
343 .give_marker(
344 Marker::at(pos.xy())
345 .with_label(
346 Content::localized("hud-map-character-label")
347 .with_arg("name", name.clone()),
348 )
349 .with_kind(MarkerKind::Character)
350 .with_id(actor)
351 .with_quest_flag(true),
352 )
353 .then(session.say_statement(Content::localized("npc-response-directions")))
354 .boxed(),
355 ));
356 }
357 }
358
359 if let Some(current_site) = ctx.npc.current_site
360 && let Some(ws_id) = ctx.state.data().sites[current_site].world_site
361 {
362 let direction_to_nearest =
363 |f: fn(&&world::site::Plot) -> bool,
364 plot_name: fn(&world::site::Plot) -> Content| {
365 now(move |ctx, _| {
366 let ws = ctx.index.sites.get(ws_id);
367 if let Some(p) = ws.plots().filter(f).min_by_key(|p| {
368 ws.tile_center_wpos(p.root_tile())
369 .distance_squared(ctx.npc.wpos.xy().as_())
370 }) {
371 session
372 .give_marker(
373 Marker::at(ws.tile_center_wpos(p.root_tile()).as_())
374 .with_label(plot_name(p)),
375 )
376 .then(
377 session.say_statement(Content::localized(
378 "npc-response-directions",
379 )),
380 )
381 .boxed()
382 } else {
383 session
384 .say_statement(Content::localized("npc-response-doesnt_exist"))
385 .boxed()
386 }
387 })
388 .boxed()
389 };
390
391 responses.push((
392 Content::localized("dialogue-direction-tavern"),
393 direction_to_nearest(
394 |p| matches!(p.kind(), PlotKind::Tavern(_)),
395 |p| match p.kind() {
396 PlotKind::Tavern(t) => Content::Plain(t.name.clone()),
397 _ => unreachable!(),
398 },
399 ),
400 ));
401 responses.push((
402 Content::localized("dialogue-direction-plaza"),
403 direction_to_nearest(
404 |p| matches!(p.kind(), PlotKind::Plaza(_)),
405 |_| Content::localized("hud-map-plaza"),
406 ),
407 ));
408 responses.push((
409 Content::localized("dialogue-direction-workshop"),
410 direction_to_nearest(
411 |p| matches!(p.kind().meta(), Some(PlotKindMeta::Workshop { .. })),
412 |_| Content::localized("hud-map-workshop"),
413 ),
414 ));
415 responses.push((
416 Content::localized("dialogue-direction-airship_dock"),
417 direction_to_nearest(
418 |p| matches!(p.kind().meta(), Some(PlotKindMeta::AirshipDock { .. })),
419 |_| Content::localized("hud-map-airship_dock"),
420 ),
421 ));
422 }
423
424 session.ask_question(Content::localized("npc-question-directions"), responses)
425 })
426}
427
428fn rock_paper_scissors<S: State>(session: DialogueSession) -> impl Action<S> {
429 now(move |ctx, _| {
430 #[derive(PartialEq, Eq, Clone, Copy)]
431 enum RockPaperScissor {
432 Rock,
433 Paper,
434 Scissors,
435 }
436 use RockPaperScissor::*;
437 impl RockPaperScissor {
438 fn i18n_key(&self) -> &'static str {
439 match self {
440 Rock => "dialogue-game-rock",
441 Paper => "dialogue-game-paper",
442 Scissors => "dialogue-game-scissors",
443 }
444 }
445 }
446 fn end<S: State>(
447 session: DialogueSession,
448 our: RockPaperScissor,
449 their: RockPaperScissor,
450 ) -> impl Action<S> {
451 let draw = our == their;
452 let we_win = matches!(
453 (our, their),
454 (Rock, Scissors) | (Paper, Rock) | (Scissors, Paper)
455 );
456 let result = if draw {
457 "dialogue-game-draw"
458 } else if we_win {
459 "dialogue-game-win"
460 } else {
461 "dialogue-game-lose"
462 };
463
464 session
465 .say_statement(Content::localized(our.i18n_key()))
466 .then(session.say_statement(Content::localized(result)))
467 }
468 let choices = [Rock, Paper, Scissors];
469 let our_choice = choices
470 .choose(&mut ctx.rng)
471 .expect("We have a non-empty array");
472
473 let choices = choices.map(|choice| {
474 (
475 Response::from(Content::localized(choice.i18n_key())),
476 end(session, *our_choice, choice),
477 )
478 });
479
480 session.ask_question(
481 Content::localized("dialogue-game-rock_paper_scissors"),
482 choices,
483 )
484 })
485}
486
487fn games<S: State>(session: DialogueSession) -> impl Action<S> {
488 let games = [(
489 Response::from(Content::localized("dialogue-game-rock_paper_scissors")),
490 rock_paper_scissors(session),
491 )];
492
493 session.ask_question(Content::localized("dialogue-game-what_game"), games)
494}