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