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