Skip to main content

veloren_rtsim/rule/npc_ai/
quest.rs

1use super::*;
2use crate::data::quest::{
3    COURIER_QUEST_VARIANTS, CourierQuest, CourierQuestInstance, Payload, Recipient,
4};
5use common::{
6    comp::{Item, item::ItemBase},
7    rtsim::ActorId,
8    spot::Spot,
9};
10use std::num::NonZeroU32;
11
12/// Perform a deposit check, ensuring that the NPC has the given item and amount
13/// in their inventory. If they do, the provided action is performed to
14/// determine whether we should proceed. If the action chooses to proceed, then
15/// we attempt to remove the items from the inventory. This may be fallible.
16pub fn create_deposit<S: State, T: Action<S, bool>>(
17    ctx: &mut NpcCtx,
18    item: ItemResource,
19    amount: f32,
20    then: T,
21) -> Option<impl Action<S, bool> + use<S, T>> {
22    if let Some(npc_entity) = ctx.system_data.id_maps.rtsim_entity(ctx.actor_id)
23        && ctx
24            .system_data
25            .inventories
26            .lock()
27            .unwrap()
28            .get(npc_entity)
29            .is_some_and(|inv| {
30                inv.item_count(&item.to_equivalent_item_def()) >= amount.ceil() as u64
31            })
32    {
33        Some(then.and_then(move |should_proceed: bool| {
34            just(move |ctx, _| {
35                if !should_proceed {
36                    false
37                } else if let Some(npc_entity) = ctx.system_data.id_maps.rtsim_entity(ctx.actor_id)
38                    && ctx
39                        .system_data
40                        .inventories
41                        .lock()
42                        .unwrap()
43                        .get_mut(npc_entity)
44                        .and_then(|mut inv| {
45                            inv.remove_item_amount(
46                                &item.to_equivalent_item_def(),
47                                amount.ceil() as u32,
48                                &ctx.system_data.ability_map,
49                                &ctx.system_data.msm,
50                            )
51                        })
52                        .is_some()
53                {
54                    true
55                } else {
56                    false
57                }
58            })
59        }))
60    } else {
61        None
62    }
63}
64
65#[allow(clippy::result_unit_err)]
66pub fn resolve_take_deposit(
67    ctx: &mut NpcCtx,
68    quest_id: QuestId,
69    success: bool,
70) -> Result<Option<(Arc<ItemDef>, u32)>, ()> {
71    if let Some(outcome) = ctx
72        .data
73        .quests
74        .get(quest_id)
75        .and_then(|q| q.resolve(ctx.actor_id, success))
76    {
77        // ...take the deposit back into our own inventory...
78        if let Some((item, amount)) = &outcome.deposit
79            && let Some(npc_entity) = ctx.system_data.id_maps.rtsim_entity(ctx.actor_id)
80            && let Some(mut inv) = ctx
81                .system_data
82                .inventories
83                .lock()
84                .unwrap()
85                .get_mut(npc_entity)
86        {
87            let item_def = item.to_equivalent_item_def();
88            // Rounding down, to avoid potential precision exploits
89            let amount = amount.floor() as u32;
90
91            let mut item = Item::new_from_item_base(
92                ItemBase::Simple(item_def.clone()),
93                Vec::new(),
94                &ctx.system_data.ability_map,
95                &ctx.system_data.msm,
96            );
97            item.set_amount(amount)
98                .expect("Item cannot be stacked that far!");
99            let _ = inv.push(item);
100
101            Ok(Some((item_def, amount)))
102        } else {
103            Ok(None)
104        }
105    } else {
106        Err(())
107    }
108}
109
110/// Checks if a courier quest can be completed based on inventory and entity
111/// presence.
112///
113/// The inventory check/consume operation is atomic. All inventory items'
114/// presence are verified first, then the items are subsequently removed in the
115/// same transaction/lock.
116///
117/// That being said, if `read_only` is true, the inventory will not be modified,
118/// only checked.
119///
120/// This should support checking for completion regardless of if it's being
121/// completed by a player or an rtsim NPC.
122pub fn finalize_courier_task(ctx: &mut NpcCtx, quest_id: QuestId, read_only: bool) -> bool {
123    fn required_count(raw: f32) -> u32 {
124        debug_assert!(
125            raw.is_finite() && raw >= 0.0,
126            "courier quest required amount must be finite and non-negative, got {raw}",
127        );
128        raw.round() as u32
129    }
130
131    if let Some(quest) = ctx.data.quests.get(quest_id)
132        && let QuestKind::Courier { instance } = &quest.kind
133        && let Some(entity) = ctx.system_data.id_maps.rtsim_entity(instance.messenger)
134        && let Ok(mut inventories) = ctx.system_data.inventories.lock()
135        && let Some(mut inv) = inventories.get_mut(entity)
136        && let Ok(required_items) = instance.get_required_items()
137        && required_items.iter().all(|(item_def, amount)| {
138            inv.item_count(item_def) >= u64::from(required_count(*amount))
139        })
140        && (read_only
141            || required_items.iter().all(|(item_def, amount)| {
142                inv.remove_item_amount(
143                    item_def,
144                    required_count(*amount),
145                    &ctx.system_data.ability_map,
146                    &ctx.system_data.msm,
147                )
148                .is_some()
149            }))
150    {
151        true
152    } else {
153        false
154    }
155}
156
157/// Register and create a new quest, producing its ID.
158///
159/// This is an action because quest creation can only happen at the end of an
160/// rtsim tick (for reasons related to parallelism).
161pub fn create_quest<S: State>(quest: Quest) -> impl Action<S, QuestId> {
162    just(move |ctx, _| {
163        let quest_id = ctx.data.quests.register();
164        ctx.controller
165            .quests_to_create
166            .push((quest_id, quest.clone()));
167        quest_id
168    })
169}
170
171pub fn quest_request<S: State>(session: DialogueSession) -> impl Action<S> {
172    now(move |ctx, _| {
173        let mut quests = Vec::new();
174
175        // Escort quest.
176        const ESCORT_REWARD_ITEM: ItemResource = ItemResource::Coin;
177        // Escortable NPCs must have no existing job
178        if ctx.npc.job.is_none()
179            // They must be a merchant
180            && matches!(ctx.actor.profession(), Some(Profession::Merchant))
181            // Choose an appropriate target site
182            && let Some((dst_site_id, dst_site, dist)) = ctx.data
183                .sites
184                .iter()
185                // Find the distance to the site
186                .map(|(site_id, site)| (site_id, site, site.wpos.as_().distance(ctx.actor.wpos.xy())))
187                // Don't try to be escorted to the site we're currently in, and ensure it's a reasonable distance away
188                .filter(|(site_id, _, dist)| Some(*site_id) != ctx.actor.current_site && (1000.0..5_000.0).contains(dist))
189                // Temporarily, try to choose the same target site for 15 minutes to avoid players asking many times
190                // TODO: Don't do this
191                .choose(&mut ChaChaRng::from_seed([(ctx.time.0 / (60.0 * 15.0)) as u8; 32]))
192            // Escort reward amount is proportional to distance
193            && let escort_reward_amount = dist / 5.0
194            && let Some(dst_site_name) = util::site_name(ctx, dst_site_id)
195            && let time_limit = 1.0 + dist as f64 / 80.0
196            && let Some(accept_quest) = create_deposit(ctx, ESCORT_REWARD_ITEM, escort_reward_amount, session
197                    .ask_yes_no_question(Content::localized("npc-response-quest-escort-ask")
198                        .with_arg("dst", dst_site_name.clone())
199                        .with_arg("coins", escort_reward_amount as u64)
200                        .with_arg("mins", time_limit as u64)))
201        {
202            let dst_wpos = dst_site.wpos.as_();
203            quests.push(
204                accept_quest
205                    .and_then(move |yes| {
206                        now(move |ctx, _| {
207                            if yes {
208                                let quest =
209                                    Quest::escort(ctx.actor_id, session.target, dst_site_id)
210                                        .with_deposit(ESCORT_REWARD_ITEM, escort_reward_amount)
211                                        .with_timeout(ctx.time.add_minutes(time_limit));
212                                create_quest(quest.clone())
213                                    .and_then(move |quest_id| {
214                                        now(move |ctx, _| {
215                                            ctx.controller.job = Some(Job::Quest(quest_id));
216                                            session.give_marker(
217                                                Marker::at(dst_wpos)
218                                                    .with_id(quest_id)
219                                                    .with_label(
220                                                        Content::localized("hud-map-escort-label")
221                                                            .with_arg(
222                                                                "name",
223                                                                ctx.actor
224                                                                    .get_name()
225                                                                    .unwrap_or_else(|| {
226                                                                        "<unknown>".to_string()
227                                                                    }),
228                                                            )
229                                                            .with_arg(
230                                                                "place",
231                                                                dst_site_name.clone(),
232                                                            ),
233                                                    )
234                                                    .with_quest_flag(true),
235                                            )
236                                        })
237                                    })
238                                    .then(session.say_statement(Content::localized(
239                                        "npc-response-quest-escort-start",
240                                    )))
241                                    .boxed()
242                            } else {
243                                session
244                                    .say_statement(Content::localized(
245                                        "npc-response-quest-rejected",
246                                    ))
247                                    .boxed()
248                            }
249                        })
250                    })
251                    .boxed(),
252            );
253        }
254
255        // Kill monster quest
256        const SLAY_REWARD_ITEM: ItemResource = ItemResource::Coin;
257        if let Some((monster_id, monster)) = ctx.data.actors
258            .iter()
259            // Ensure the NPC is a monster
260            .filter(|(_, npc)| matches!(&npc.role, Role::Monster))
261            // Try to filter out monsters that are tied up in another quest (imperfect: race conditions)
262            .filter(|(id, _)| ctx.data.quests.related_to(*id).count() == 0)
263            // Filter out monsters that are too far away
264            .filter(|(_, npc)| npc.wpos.xy().distance(ctx.actor.wpos.xy()) < 2500.0)
265            // Find the closest
266            .min_by_key(|(_, npc)| npc.wpos.xy().distance_squared(ctx.actor.wpos.xy()) as i64)
267            && let monster_pos = monster.wpos
268            && let monster_body = monster.body
269            && let slay_reward_amount = 1000.0
270            && let Some(accept_quest) = create_deposit(
271                ctx,
272                SLAY_REWARD_ITEM,
273                slay_reward_amount,
274                session.ask_yes_no_question(
275                    Content::localized("npc-response-quest-slay-ask")
276                        .with_arg("body", monster_body.localize_npc())
277                        .with_arg("coins", slay_reward_amount as u64),
278                ),
279            )
280        {
281            quests.push(
282                accept_quest
283                    .and_then(move |yes| {
284                        now(move |ctx, _| {
285                            if yes {
286                                let quest = Quest::slay(ctx.actor_id, monster_id, session.target)
287                                    .with_deposit(ESCORT_REWARD_ITEM, slay_reward_amount)
288                                    .with_timeout(ctx.time.add_minutes(60.0));
289                                create_quest(quest.clone())
290                                    .then(
291                                        session.give_marker(
292                                            Marker::at(monster_pos.xy())
293                                                .with_id(monster_id)
294                                                .with_label(
295                                                    Content::localized("hud-map-creature-label")
296                                                        .with_arg(
297                                                            "body",
298                                                            monster_body.localize_npc(),
299                                                        ),
300                                                )
301                                                .with_quest_flag(true),
302                                        ),
303                                    )
304                                    .then(session.say_statement(Content::localized(
305                                        "npc-response-quest-slay-start",
306                                    )))
307                                    .then(session.say_statement(Content::localized(
308                                        "npc-response-quest-slay-start_2",
309                                    )))
310                                    .then(session.say_statement(Content::localized(
311                                        "npc-response-quest-slay-start_3",
312                                    )))
313                                    .then(session.say_statement(Content::localized(
314                                        "npc-response-quest-slay-start_4",
315                                    )))
316                                    .boxed()
317                            } else {
318                                session
319                                    .say_statement(Content::localized(
320                                        "npc-response-quest-rejected",
321                                    ))
322                                    .boxed()
323                            }
324                        })
325                    })
326                    .boxed(),
327            );
328        }
329
330        const COURIER_REWARD_ITEM: ItemResource = ItemResource::Coin;
331        if let Some(courier_quest) = roll_courier_quest(ctx, session.target)
332            && let Some(quest_tgt) = ctx.data.actors.get(courier_quest.target_actor)
333            && let Some(tgt_site_name) = courier_quest
334                .target_site
335                .and_then(|tgt_site_id| ctx.data.sites.get(tgt_site_id))
336                .and_then(|queried_site| queried_site.world_site)
337                .and_then(|queried_site_world_id| ctx.index.sites.get(queried_site_world_id).name())
338        {
339            let quest_tgt_actor = courier_quest.target_actor;
340            let (start_stmt, start_question) = courier_quest.get_start_dialogue(
341                quest_tgt
342                    .get_name()
343                    .unwrap_or_else(|| "<unknown>".to_string())
344                    .as_str(),
345                tgt_site_name,
346            );
347            let proposed_quest = create_deposit(
348                ctx,
349                COURIER_REWARD_ITEM,
350                courier_quest.get_reward(),
351                session
352                    .say_statement(start_stmt)
353                    .then(session.ask_yes_no_question(start_question)),
354            );
355
356            if let Some(accept_quest) = proposed_quest {
357                // define a few values before entering closures
358                let tgt_name = quest_tgt
359                    .get_name()
360                    .unwrap_or_else(|| "<unknown>".to_string());
361                let tgt_name_marker = tgt_name.clone();
362                let tgt_actor_wpos = quest_tgt.wpos.xy();
363                let tgt_actor = quest_tgt_actor;
364                let quest_exp = ctx.time.add_minutes(180.0);
365
366                let quest_offer = accept_quest
367                    .and_then(move |yes| {
368                        now(move |_ctx, _| {
369                            if yes {
370                                let quest = Quest::courier(tgt_actor, courier_quest)
371                                    .with_deposit(COURIER_REWARD_ITEM, courier_quest.get_reward())
372                                    .with_timeout(quest_exp);
373                                create_quest(quest)
374                                    .and_then(move |quest_id| {
375                                        now(move |_ctx, _| {
376                                            if let Some(chunk_pos) = courier_quest.spot {
377                                                let chunk_wpos = chunk_pos.cpos_to_wpos();
378                                                // provide a map marker that points to the
379                                                // nearest spot
380                                                session.give_marker(
381                                                    Marker::at(Vec2::new(
382                                                        chunk_wpos.x as f32,
383                                                        chunk_wpos.y as f32,
384                                                    ))
385                                                    .with_id(quest_id)
386                                                    .with_label(
387                                                        courier_quest
388                                                            .get_spot_map_label(tgt_name.as_str()),
389                                                    )
390                                                    .with_quest_flag(true),
391                                                )
392                                            } else {
393                                                // provide a map marker that points to the
394                                                // courier target
395                                                session.give_marker(
396                                                    Marker::at(tgt_actor_wpos)
397                                                        .with_id(tgt_actor)
398                                                        .with_label(
399                                                            Content::localized(
400                                                                "hud-map-character-label",
401                                                            )
402                                                            .with_arg("name", tgt_name.as_str()),
403                                                        )
404                                                        .with_kind(MarkerKind::Character),
405                                                )
406                                            }
407                                        })
408                                    })
409                                    .then(
410                                        // provide a map marker that points to the courier
411                                        // target (note: this does it twice if there is a spot,
412                                        // only because we have to do something in the previous
413                                        // .and_then() statement to satisfy type symmetry)
414                                        session.give_marker(
415                                            Marker::at(tgt_actor_wpos)
416                                                .with_id(tgt_actor)
417                                                .with_label(
418                                                    Content::localized("hud-map-character-label")
419                                                        .with_arg("name", tgt_name_marker),
420                                                )
421                                                .with_kind(MarkerKind::Character),
422                                        ),
423                                    )
424                                    .then(session.say_statement(Content::localized(
425                                        "npc-response-quest-courier-start",
426                                    )))
427                                    .then(session.say_statement(Content::localized(
428                                        "npc-response-quest-courier-start_2",
429                                    )))
430                                    .then(session.say_statement(Content::localized(
431                                        "npc-response-quest-courier-start_3",
432                                    )))
433                                    .boxed()
434                            } else {
435                                session
436                                    .say_statement(Content::localized(
437                                        "npc-response-quest-rejected",
438                                    ))
439                                    .boxed()
440                            }
441                        })
442                    })
443                    .boxed();
444
445                quests.push(quest_offer);
446            }
447        }
448
449        if quests.is_empty() {
450            session
451                .say_statement(Content::localized("npc-response-quest-nothing"))
452                .boxed()
453        } else {
454            quests.remove(ctx.rng.random_range(0..quests.len()))
455        }
456    })
457}
458
459pub fn check_for_timeouts<S: State>(ctx: &mut NpcCtx) -> Option<impl Action<S> + use<S>> {
460    for quest_id in ctx.data.quests.related_to(ctx.actor_id) {
461        let Some(quest) = ctx.data.quests.get(quest_id) else {
462            continue;
463        };
464        if let Some(timeout) = quest.timeout
465            // The quest has timed out...
466            && ctx.time > timeout
467            // ...so resolve it
468            && let Ok(Some(_)) = resolve_take_deposit(ctx, quest_id, false)
469        {
470            // Stop any job related to the quest
471            if ctx.npc.job == Some(Job::Quest(quest_id)) {
472                ctx.controller.end_quest();
473            }
474
475            // If needs be, inform the quester that they failed
476            match quest.kind {
477                QuestKind::Escort { escorter, .. } => {
478                    return Some(
479                        goto_actor(escorter, 2.0)
480                            .then(do_dialogue(escorter, move |session| {
481                                session
482                                    .say_statement(Content::localized("npc-response-quest-timeout"))
483                            }))
484                            .boxed(),
485                    );
486                },
487                QuestKind::Slay { .. } => {},
488                QuestKind::Courier { .. } => {},
489            }
490        }
491    }
492    None
493}
494
495pub fn escorted<S: State>(
496    quest_id: QuestId,
497    escorter: ActorId,
498    dst_site: SiteId,
499) -> impl Action<S> {
500    follow_actor(escorter, 5.0)
501        .stop_if(move |ctx: &mut NpcCtx| {
502            // Occasionally, tell the escoter to wait if we're lagging far behind
503            if let Some(escorter_pos) = util::locate_actor(ctx, escorter)
504                && ctx.actor.wpos.xy().distance_squared(escorter_pos.xy()) > 20.0f32.powi(2)
505                && ctx.rng.random_bool(ctx.dt as f64 / 30.0)
506            {
507                ctx.controller
508                    .say(None, Content::localized("npc-speech-wait_for_me"));
509            }
510            // Stop if we've reached the destination site
511            ctx.data
512                .sites
513                .get(dst_site)
514                .is_none_or(|site| site.wpos.as_().distance_squared(ctx.actor.wpos.xy()) < 150.0f32.powi(2))
515        })
516        .then(goto_actor(escorter, 2.0))
517        .then(do_dialogue(escorter, move |session| {
518            session
519                .say_statement(Content::localized("npc-response-quest-escort-complete"))
520                // Now that the quest has ended, resolve it and give the player the deposit
521                .then(now(move |ctx, _| {
522                    ctx.controller.end_quest();
523                    match resolve_take_deposit(ctx, quest_id, true) {
524                        Ok(deposit) => session.say_statement_with_gift(Content::localized("npc-response-quest-reward"), deposit).boxed(),
525                        Err(()) => finish().boxed(),
526                    }
527                }))
528        }))
529        .stop_if(move |ctx: &mut NpcCtx| {
530            // Cancel performing the quest if it's been resolved
531            ctx.data
532                .quests
533                .get(quest_id)
534                .is_none_or(|q| q.resolution().is_some())
535        })
536        .map(|_, _| ())
537}
538
539/// Finds the nearest chunk position that contains the appropriate kind of spot
540/// for this courier quest variant. For example, if you have a Gnarling Carving
541/// quest, this will search nearby for the nearest Gnarling Totem spot and
542/// return the chunk position (not the world position, you'll need to convert it
543/// to `wpos`).
544///
545/// The `target_chunk` needs to be predetermined in order to satisfy compiler
546/// checks.
547pub fn get_nearest_spot(
548    ctx: &mut NpcCtx,
549    quest: CourierQuest,
550    target_chunk: Vec2<i32>,
551) -> Option<Vec2<i32>> {
552    match quest.payload() {
553        // These do not have spots
554        None | Some(Payload::LegoomLeaf) => None,
555        // Add more here later!
556        Some(Payload::GnarlingCarving) => ctx
557            .world
558            .sim()
559            .get_nearest_spot(target_chunk, |spot| matches!(spot, Spot::GnarlingTotem)),
560    }
561}
562
563const MAX_COURIER_QUEST_DISTANCE: f32 = 5_000.0;
564
565/// This file only contains an implementation for quest interactions. Make sure
566/// to look for other implementations.
567impl CourierQuestInstance {
568    /// Returns a list of all items that are required for completing this
569    /// courier quest.
570    pub fn get_required_items(self) -> Result<Vec<(Arc<ItemDef>, f32)>, common::assets::Error> {
571        match self.kind.payload() {
572            None => Ok(vec![]),
573            Some(Payload::GnarlingCarving) => Ok(vec![(
574                Arc::<ItemDef>::load_cloned("common.items.quest.gnarling_carving")?,
575                1.0_f32,
576            )]),
577            Some(Payload::LegoomLeaf) => Ok(vec![(
578                Arc::<ItemDef>::load_cloned("common.items.quest.legoom_leaf")?,
579                1.0_f32,
580            )]),
581        }
582    }
583
584    /// Returns the number of coins that the quest arbiter must pay upon courier
585    /// quest completion. Note that in some cases the arbiter is not the
586    /// person that paid the quest deposit.
587    pub fn get_reward(self) -> f32 {
588        match self.kind {
589            CourierQuest::Message => f32::max(
590                150.0,
591                1000.0 * (self.distance.get() as f32 / MAX_COURIER_QUEST_DISTANCE),
592            ),
593            CourierQuest::Deliver {
594                payload: Payload::GnarlingCarving,
595                recipient: Recipient::Other,
596            } => f32::max(
597                500.0,
598                1400.0 * (self.distance.get() as f32 / MAX_COURIER_QUEST_DISTANCE),
599            ),
600            CourierQuest::Deliver {
601                payload: Payload::GnarlingCarving,
602                recipient: Recipient::Giver,
603            } => 350.0,
604            CourierQuest::Deliver {
605                payload: Payload::LegoomLeaf,
606                recipient: Recipient::Other,
607            } => f32::max(
608                400.0,
609                1200.0 * (self.distance.get() as f32 / MAX_COURIER_QUEST_DISTANCE),
610            ),
611            CourierQuest::Deliver {
612                payload: Payload::LegoomLeaf,
613                recipient: Recipient::Giver,
614            } => 200.0,
615        }
616    }
617
618    /// Retrieves the i18n content that will be shown on the map when hovering
619    /// over the courier quest's map marker.
620    pub fn get_spot_map_label(self, npc_name: &str) -> Content {
621        Content::localized(match self.kind.payload() {
622            Some(Payload::GnarlingCarving) => "hud-map-spot-gnarling-carving-label",
623            // These shouldn't be encountered since they don't have spot requirements:
624            None | Some(Payload::LegoomLeaf) => "hud-map-spot-unspecified",
625        })
626        .with_arg("name", npc_name)
627    }
628
629    /// "You don't have enough items on you to complete this quest."
630    pub fn lacks_items(self) -> Content {
631        Content::localized(match self.kind.payload() {
632            Some(Payload::GnarlingCarving) => {
633                "npc-response-quest-courier-gnarling-carving-insufficient-items"
634            },
635            Some(Payload::LegoomLeaf) => {
636                "npc-response-quest-courier-legoom-leaf-insufficient-items"
637            },
638            // For quests that do not require items, use this arm.
639            None => "npc-response-quest-courier-generic-insufficient-items",
640        })
641    }
642
643    /// Assembles the dialogue question and response when asking what items
644    /// are needed in order to complete an active courier quest.
645    ///
646    /// "What am I supposed to be getting for you/target again?"
647    /// "You need X, Y, and Z to complete this courier quest."
648    pub fn what_items_needed(self, is_target_npc: bool, npc_name: &str) -> (Content, Content) {
649        (
650            Content::localized(match self.kind {
651                CourierQuest::Deliver {
652                    recipient: Recipient::Other,
653                    ..
654                } => {
655                    if is_target_npc {
656                        "dialogue-question-quest-courier-what-target"
657                    } else {
658                        "dialogue-question-quest-courier-what"
659                    }
660                },
661                CourierQuest::Deliver {
662                    recipient: Recipient::Giver,
663                    ..
664                } => "dialogue-question-quest-fetch-what",
665                CourierQuest::Message => {
666                    if is_target_npc {
667                        "dialogue-question-quest-messenger-what-target"
668                    } else {
669                        "dialogue-question-quest-messenger-what"
670                    }
671                },
672            })
673            .with_arg("name", npc_name),
674            Content::localized(match self.kind.payload() {
675                Some(Payload::GnarlingCarving) => {
676                    "npc-response-quest-courier-gnarling-carving-what-is-needed"
677                },
678                Some(Payload::LegoomLeaf) => {
679                    "npc-response-quest-courier-legoom-leaf-what-is-needed"
680                },
681                None => {
682                    if is_target_npc {
683                        "npc-response-quest-messenger-what-is-needed-target"
684                    } else {
685                        "npc-response-quest-messenger-what-is-needed"
686                    }
687                },
688            })
689            .with_arg("name", npc_name),
690        )
691    }
692
693    /// Retrieves the i18n content for the name of the spot, or a generic
694    /// response if the courier quest variant does not need a spot.
695    pub fn get_spot_name(self) -> Content {
696        Content::localized(match self.kind.payload() {
697            Some(Payload::GnarlingCarving) => "spot-name-gnarling-totem",
698            None | Some(Payload::LegoomLeaf) => "spot-name-unspecified",
699        })
700    }
701
702    /// Returns the i18n content for the initial courier quest
703    /// statement/preamble that an NPC will say, as well as the subsequent
704    /// yes/no question that they ask that allows starting the quest.
705    ///
706    /// Note that not every quest uses the target npc name or the target site
707    /// name.
708    pub fn get_start_dialogue(
709        self,
710        tgt_npc_name_str: &str,
711        tgt_site_name: &str,
712    ) -> (Content, Content) {
713        const COURIER_GNARLING_CARVING_START_STMT: &str =
714            "npc-response-quest-courier-gnarling-carving";
715        const COURIER_LEGOOM_LEAF_START_STMT: &str = "npc-response-quest-courier-legoom-leaf";
716        const MESSENGER_SEND_WORD_START_STMT: &str = "npc-response-quest-messenger-send-word";
717
718        match self.kind {
719            CourierQuest::Deliver {
720                payload: Payload::GnarlingCarving,
721                recipient: Recipient::Other,
722            } => (
723                Content::localized(COURIER_GNARLING_CARVING_START_STMT),
724                Content::localized("npc-response-quest-spot-courier-ask")
725                    .with_arg("spot", self.get_spot_name())
726                    .with_arg("coins", self.get_reward() as u64)
727                    .with_arg("name", tgt_npc_name_str)
728                    .with_arg("site", tgt_site_name),
729            ),
730            CourierQuest::Deliver {
731                payload: Payload::GnarlingCarving,
732                recipient: Recipient::Giver,
733            } => (
734                Content::localized(COURIER_GNARLING_CARVING_START_STMT),
735                Content::localized("npc-response-quest-spot-fetch-ask")
736                    .with_arg("spot", self.get_spot_name())
737                    .with_arg("coins", self.get_reward() as u64),
738            ),
739            CourierQuest::Deliver {
740                payload: Payload::LegoomLeaf,
741                recipient: Recipient::Other,
742            } => (
743                Content::localized(COURIER_LEGOOM_LEAF_START_STMT),
744                Content::localized("npc-response-quest-courier-ask")
745                    .with_arg("coins", self.get_reward() as u64)
746                    .with_arg("name", tgt_npc_name_str)
747                    .with_arg("site", tgt_site_name),
748            ),
749            CourierQuest::Deliver {
750                payload: Payload::LegoomLeaf,
751                recipient: Recipient::Giver,
752            } => (
753                Content::localized(COURIER_LEGOOM_LEAF_START_STMT),
754                Content::localized("npc-response-quest-fetch-ask")
755                    .with_arg("coins", self.get_reward() as u64),
756            ),
757            CourierQuest::Message => (
758                Content::localized(MESSENGER_SEND_WORD_START_STMT),
759                Content::localized("npc-response-quest-messenger-ask")
760                    .with_arg("coins", self.get_reward() as u64)
761                    .with_arg("name", tgt_npc_name_str)
762                    .with_arg("site", tgt_site_name),
763            ),
764        }
765    }
766
767    /// "Where is my target again?"
768    /// Map gets marked with a marker, and the NPC responds with their location.
769    pub fn get_dialogue_where_target(
770        self,
771        npc_name: &str,
772        at: Vec2<f32>,
773        target: ActorId,
774    ) -> (Content, Marker, Content) {
775        (
776            Content::localized("dialogue-question-quest-courier-where").with_arg("name", npc_name),
777            Marker::at(at)
778                .with_label(
779                    Content::localized("hud-map-character-label").with_arg("name", npc_name),
780                )
781                .with_kind(MarkerKind::Character)
782                .with_id(target)
783                .with_quest_flag(true),
784            Content::localized("npc-response-quest-courier-where").with_arg("name", npc_name),
785        )
786    }
787
788    /// Returns the dialogue question and response that an entity will use when
789    /// the courier quest's messenger is speaking to the quest target and is
790    /// attempting to finish the quest (claim the reward).
791    pub fn get_courier_claim_dialogue(self) -> (Content, Content) {
792        (
793            Content::localized("dialogue-question-quest-courier-claim"),
794            Content::localized("npc-response-quest-courier-thanks"),
795        )
796    }
797
798    /// Generates a map marker that represents the position of the courier
799    /// quest's targeted spot's position.
800    pub fn get_quest_spot_start_marker(
801        self,
802        at: Vec2<f32>,
803        tgt_npc_name: &str,
804        quest_id: QuestId,
805    ) -> Marker {
806        Marker::at(at)
807            .with_id(quest_id)
808            .with_kind(MarkerKind::Unknown)
809            .with_quest_flag(true)
810            .with_label(self.get_spot_map_label(tgt_npc_name))
811    }
812
813    /// Generates a map marker that represents the position of the courier
814    /// quest's target entity.
815    pub fn get_quest_actor_target_marker(
816        self,
817        at: Vec2<f32>,
818        tgt_actor_name: &str,
819        target_actor_id: ActorId,
820    ) -> Marker {
821        Marker::at(at)
822            .with_id(target_actor_id)
823            .with_kind(MarkerKind::Character)
824            .with_quest_flag(true)
825            .with_label(
826                Content::localized("hud-map-character-label").with_arg("name", tgt_actor_name),
827            )
828    }
829}
830
831/// Attempts to build a valid courier quest.
832fn roll_courier_quest(ctx: &mut NpcCtx, messenger: ActorId) -> Option<CourierQuestInstance> {
833    let kind = COURIER_QUEST_VARIANTS
834        .choose(&mut ctx.rng)
835        .copied()
836        .unwrap_or(CourierQuest::Message);
837
838    let (target_site, target_actor, distance) = match kind {
839        // target and source are the same npc for this kind of courier quest
840        CourierQuest::Deliver {
841            recipient: Recipient::Giver,
842            ..
843        } => (ctx.actor.current_site, ctx.actor_id, 0.0),
844        // target npc differs from source npc for these kinds of courier quests,
845        // so find a target npc and the npc's site
846        CourierQuest::Deliver {
847            recipient: Recipient::Other,
848            ..
849        }
850        | CourierQuest::Message => ctx
851            .data
852            .actors
853            .iter()
854            .filter(|(_, actor)| actor.npc().is_some())
855            .filter_map(|(npc_id, npc)| match &npc.role {
856                Role::Civilised(Some(Profession::Hunter))
857                | Role::Civilised(Some(Profession::Farmer))
858                | Role::Civilised(Some(Profession::Blacksmith))
859                | Role::Civilised(Some(Profession::Alchemist))
860                | Role::Civilised(Some(Profession::Chef))
861                | Role::Civilised(Some(Profession::Herbalist))
862                | Role::Civilised(Some(Profession::Guard)) => {
863                    let distance = ctx.actor.wpos.xy().distance(npc.wpos.xy());
864                    (distance <= MAX_COURIER_QUEST_DISTANCE).then_some((npc_id, npc, distance))
865                },
866                _ => None,
867            })
868            .choose(&mut ctx.rng)
869            .and_then(|(tgt_npc_id, tgt_npc, distance)| {
870                ctx.data
871                    .sites
872                    .iter()
873                    .filter_map(|(site_id, site)| {
874                        (tgt_npc.wpos.xy().distance(site.wpos.as_()) <= 512.0).then_some(site_id)
875                    })
876                    .choose(&mut ctx.rng)
877                    .map(|site_id| (Some(site_id), tgt_npc_id, distance))
878            })?,
879    };
880
881    let spot = get_nearest_spot(ctx, kind, ctx.actor.wpos.xy().wpos_to_cpos().as_());
882
883    // check if the payload necessitates visiting a spot. Make sure to add more
884    // here later (the compiler will guide you), and avoid using `_` match arms
885    // please... otherwise the compiler won't guide you
886    if match kind.payload() {
887        Some(Payload::GnarlingCarving) => spot.is_none(),
888        Some(Payload::LegoomLeaf) | None => false,
889    } {
890        return None;
891    }
892
893    const ONE: NonZeroU32 = NonZeroU32::new(1).unwrap();
894    Some(CourierQuestInstance {
895        kind,
896        spot,
897        source_site: ctx.actor.current_site,
898        source_actor: ctx.actor_id,
899        target_actor,
900        target_site,
901        messenger,
902        distance: NonZeroU32::new(distance as u32).unwrap_or(ONE),
903    })
904}