veloren_rtsim/rule/npc_ai/
quest.rs

1use super::*;
2use common::comp::{Item, item::ItemBase};
3
4/// Perform a deposit check, ensuring that the NPC has the given item and amount
5/// in their inventory. If they do, the provided action is performed to
6/// determine whether we should proceed. If the action chooses to proceed, then
7/// we attempt to remove the items from the inventory. This may be fallible.
8pub fn create_deposit<S: State, T: Action<S, bool>>(
9    ctx: &mut NpcCtx,
10    item: ItemResource,
11    amount: f32,
12    then: T,
13) -> Option<impl Action<S, bool> + use<S, T>> {
14    if let Some(npc_entity) = ctx.system_data.id_maps.rtsim_entity(ctx.npc_id)
15        && ctx
16            .system_data
17            .inventories
18            .lock()
19            .unwrap()
20            .get(npc_entity)
21            .is_some_and(|inv| {
22                inv.item_count(&item.to_equivalent_item_def()) >= amount.ceil() as u64
23            })
24    {
25        Some(then.and_then(move |should_proceed: bool| {
26            just(move |ctx, _| {
27                if !should_proceed {
28                    false
29                } else if let Some(npc_entity) = ctx.system_data.id_maps.rtsim_entity(ctx.npc_id)
30                    && ctx
31                        .system_data
32                        .inventories
33                        .lock()
34                        .unwrap()
35                        .get_mut(npc_entity)
36                        .and_then(|mut inv| {
37                            inv.remove_item_amount(
38                                &item.to_equivalent_item_def(),
39                                amount.ceil() as u32,
40                                &ctx.system_data.ability_map,
41                                &ctx.system_data.msm,
42                            )
43                        })
44                        .is_some()
45                {
46                    true
47                } else {
48                    false
49                }
50            })
51        }))
52    } else {
53        None
54    }
55}
56
57#[allow(clippy::result_unit_err)]
58pub fn resolve_take_deposit(
59    ctx: &mut NpcCtx,
60    quest_id: QuestId,
61    success: bool,
62) -> Result<Option<(Arc<ItemDef>, u32)>, ()> {
63    if let Some(outcome) = ctx
64        .data
65        .quests
66        .get(quest_id)
67        .and_then(|q| q.resolve(ctx.npc_id, success))
68    {
69        // ...take the deposit back into our own inventory...
70        if let Some((item, amount)) = &outcome.deposit
71            && let Some(npc_entity) = ctx.system_data.id_maps.rtsim_entity(ctx.npc_id)
72            && let Some(mut inv) = ctx
73                .system_data
74                .inventories
75                .lock()
76                .unwrap()
77                .get_mut(npc_entity)
78        {
79            let item_def = item.to_equivalent_item_def();
80            // Rounding down, to avoid potential precision exploits
81            let amount = amount.floor() as u32;
82
83            let mut item = Item::new_from_item_base(
84                ItemBase::Simple(item_def.clone()),
85                Vec::new(),
86                &ctx.system_data.ability_map,
87                &ctx.system_data.msm,
88            );
89            item.set_amount(amount)
90                .expect("Item cannot be stacked that far!");
91            let _ = inv.push(item);
92
93            Ok(Some((item_def, amount)))
94        } else {
95            Ok(None)
96        }
97    } else {
98        Err(())
99    }
100}
101
102/// Register and create a new quest, producing its ID.
103///
104/// This is an action because quest creation can only happen at the end of an
105/// rtsim tick (for reasons related to parallelism).
106pub fn create_quest<S: State>(quest: Quest) -> impl Action<S, QuestId> {
107    just(move |ctx, _| {
108        let quest_id = ctx.data.quests.register();
109        ctx.controller
110            .quests_to_create
111            .push((quest_id, quest.clone()));
112        quest_id
113    })
114}
115
116pub fn quest_request<S: State>(session: DialogueSession) -> impl Action<S> {
117    now(move |ctx, _| {
118        let mut quests = Vec::new();
119
120        // Escort quest.
121        const ESCORT_REWARD_ITEM: ItemResource = ItemResource::Coin;
122        // Escortable NPCs must have no existing job
123        if ctx.npc.job.is_none()
124            // They must be a merchant
125            && matches!(ctx.npc.profession(), Some(Profession::Merchant))
126            // Choose an appropriate target site
127            && let Some((dst_site_id, dst_site, dist)) = ctx.data
128                .sites
129                .iter()
130                // Find the distance to the site
131                .map(|(site_id, site)| (site_id, site, site.wpos.as_().distance(ctx.npc.wpos.xy())))
132                // Don't try to be escorted to the site we're currently in, and ensure it's a reasonable distance away
133                .filter(|(site_id, _, dist)| Some(*site_id) != ctx.npc.current_site && (1000.0..5_000.0).contains(dist))
134                // Temporarily, try to choose the same target site for 15 minutes to avoid players asking many times
135                // TODO: Don't do this
136                .choose(&mut ChaChaRng::from_seed([(ctx.time.0 / (60.0 * 15.0)) as u8; 32]))
137            // Escort reward amount is proportional to distance
138            && let escort_reward_amount = dist / 25.0
139            && let Some(dst_site_name) = util::site_name(ctx, dst_site_id)
140            && let time_limit = 1.0 + dist as f64 / 80.0
141            && let Some(accept_quest) = create_deposit(ctx, ESCORT_REWARD_ITEM, escort_reward_amount, session
142                    .ask_yes_no_question(Content::localized("npc-response-quest-escort-ask")
143                        .with_arg("dst", dst_site_name.clone())
144                        .with_arg("coins", escort_reward_amount as u64)
145                        .with_arg("mins", time_limit as u64)))
146        {
147            let dst_wpos = dst_site.wpos.as_();
148            quests.push(
149                accept_quest
150                    .and_then(move |yes| {
151                        now(move |ctx, _| {
152                            if yes {
153                                let quest =
154                                    Quest::escort(ctx.npc_id.into(), session.target, dst_site_id)
155                                        .with_deposit(ESCORT_REWARD_ITEM, escort_reward_amount)
156                                        .with_timeout(ctx.time.add_minutes(time_limit));
157                                create_quest(quest.clone())
158                                    .and_then(move |quest_id| {
159                                        now(move |ctx, _| {
160                                            ctx.controller.job = Some(Job::Quest(quest_id));
161                                            session.give_marker(
162                                                Marker::at(dst_wpos)
163                                                    .with_id(quest_id)
164                                                    .with_label(
165                                                        Content::localized("hud-map-escort-label")
166                                                            .with_arg(
167                                                                "name",
168                                                                ctx.npc.get_name().unwrap_or_else(
169                                                                    || "<unknown>".to_string(),
170                                                                ),
171                                                            )
172                                                            .with_arg(
173                                                                "place",
174                                                                dst_site_name.clone(),
175                                                            ),
176                                                    )
177                                                    .with_quest_flag(true),
178                                            )
179                                        })
180                                    })
181                                    .then(session.say_statement(Content::localized(
182                                        "npc-response-quest-escort-start",
183                                    )))
184                                    .boxed()
185                            } else {
186                                session
187                                    .say_statement(Content::localized(
188                                        "npc-response-quest-rejected",
189                                    ))
190                                    .boxed()
191                            }
192                        })
193                    })
194                    .boxed(),
195            );
196        }
197
198        // Kill monster quest
199        const SLAY_REWARD_ITEM: ItemResource = ItemResource::Coin;
200        if let Some((monster_id, monster)) = ctx.data
201            .npcs
202            .iter()
203            // Ensure the NPC is a monster
204            .filter(|(_, npc)| matches!(&npc.role, Role::Monster))
205            // Try to filter out monsters that are tied up in another quest (imperfect: race conditions)
206            .filter(|(id, _)| ctx.data.quests.related_to(*id).count() == 0)
207            // Filter out monsters that are too far away
208            .filter(|(_, npc)| npc.wpos.xy().distance(ctx.npc.wpos.xy()) < 2500.0)
209            // Find the closest
210            .min_by_key(|(_, npc)| npc.wpos.xy().distance_squared(ctx.npc.wpos.xy()) as i64)
211            && let monster_pos = monster.wpos
212            && let monster_body = monster.body
213            && let escort_reward_amount = 200.0
214            && let Some(accept_quest) = create_deposit(
215                ctx,
216                SLAY_REWARD_ITEM,
217                escort_reward_amount,
218                session.ask_yes_no_question(
219                    Content::localized("npc-response-quest-slay-ask")
220                        .with_arg("body", monster_body.localize_npc())
221                        .with_arg("coins", escort_reward_amount as u64),
222                ),
223            )
224        {
225            quests.push(
226                accept_quest
227                    .and_then(move |yes| {
228                        now(move |ctx, _| {
229                            if yes {
230                                let quest = Quest::slay(
231                                    ctx.npc_id.into(),
232                                    monster_id.into(),
233                                    session.target,
234                                )
235                                .with_deposit(ESCORT_REWARD_ITEM, escort_reward_amount)
236                                .with_timeout(ctx.time.add_minutes(60.0));
237                                create_quest(quest.clone())
238                                    .then(
239                                        session.give_marker(
240                                            Marker::at(monster_pos.xy())
241                                                .with_id(Actor::from(monster_id))
242                                                .with_label(
243                                                    Content::localized("hud-map-creature-label")
244                                                        .with_arg(
245                                                            "body",
246                                                            monster_body.localize_npc(),
247                                                        ),
248                                                )
249                                                .with_quest_flag(true),
250                                        ),
251                                    )
252                                    .then(session.say_statement(Content::localized(
253                                        "npc-response-quest-slay-start",
254                                    )))
255                                    .boxed()
256                            } else {
257                                session
258                                    .say_statement(Content::localized(
259                                        "npc-response-quest-rejected",
260                                    ))
261                                    .boxed()
262                            }
263                        })
264                    })
265                    .boxed(),
266            );
267        }
268
269        if quests.is_empty() {
270            session
271                .say_statement(Content::localized("npc-response-quest-nothing"))
272                .boxed()
273        } else {
274            quests.remove(ctx.rng.random_range(0..quests.len()))
275        }
276    })
277}
278
279pub fn check_for_timeouts<S: State>(ctx: &mut NpcCtx) -> Option<impl Action<S> + use<S>> {
280    for quest_id in ctx.data.quests.related_to(ctx.npc_id) {
281        let Some(quest) = ctx.data.quests.get(quest_id) else {
282            continue;
283        };
284        if let Some(timeout) = quest.timeout
285            // The quest has timed out...
286            && ctx.time > timeout
287            // ...so resolve it
288            && let Ok(Some(_)) = resolve_take_deposit(ctx, quest_id, false)
289        {
290            // Stop any job related to the quest
291            if ctx.npc.job == Some(Job::Quest(quest_id)) {
292                ctx.controller.end_quest();
293            }
294
295            // If needs be, inform the quester that they failed
296            match quest.kind {
297                QuestKind::Escort { escorter, .. } => {
298                    return Some(
299                        goto_actor(escorter, 2.0)
300                            .then(do_dialogue(escorter, move |session| {
301                                session
302                                    .say_statement(Content::localized("npc-response-quest-timeout"))
303                            }))
304                            .boxed(),
305                    );
306                },
307                QuestKind::Slay { .. } => {},
308            }
309        }
310    }
311    None
312}
313
314pub fn escorted<S: State>(quest_id: QuestId, escorter: Actor, dst_site: SiteId) -> impl Action<S> {
315    follow_actor(escorter, 5.0)
316        .stop_if(move |ctx: &mut NpcCtx| {
317            // Occasionally, tell the escoter to wait if we're lagging far behind
318            if let Some(escorter_pos) = util::locate_actor(ctx, escorter)
319                && ctx.npc.wpos.xy().distance_squared(escorter_pos.xy()) > 20.0f32.powi(2)
320                && ctx.rng.random_bool(ctx.dt as f64 / 30.0)
321            {
322                ctx.controller
323                    .say(None, Content::localized("npc-speech-wait_for_me"));
324            }
325            // Stop if we've reached the destination site
326            ctx.data
327                .sites
328                .get(dst_site)
329                .is_none_or(|site| site.wpos.as_().distance_squared(ctx.npc.wpos.xy()) < 150.0f32.powi(2))
330        })
331        .then(goto_actor(escorter, 2.0))
332        .then(do_dialogue(escorter, move |session| {
333            session
334                .say_statement(Content::localized("npc-response-quest-escort-complete"))
335                // Now that the quest has ended, resolve it and give the player the deposit
336                .then(now(move |ctx, _| {
337                    ctx.controller.end_quest();
338                    match resolve_take_deposit(ctx, quest_id, true) {
339                        Ok(deposit) => session.say_statement_with_gift(Content::localized("npc-response-quest-reward"), deposit).boxed(),
340                        Err(()) => finish().boxed(),
341                    }
342                }))
343        }))
344        .stop_if(move |ctx: &mut NpcCtx| {
345            // Cancel performing the quest if it's been resolved
346            ctx.data
347                .quests
348                .get(quest_id)
349                .is_none_or(|q| q.resolution().is_some())
350        })
351        .map(|_, _| ())
352}