veloren_rtsim/rule/npc_ai/
quest.rs1use super::*;
2use common::comp::{Item, item::ItemBase};
3
4pub 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 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 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
103pub 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 const ESCORT_REWARD_ITEM: ItemResource = ItemResource::Coin;
123 if ctx.npc.job.is_none()
125 && matches!(ctx.npc.profession(), Some(Profession::Merchant))
127 && let Some((dst_site_id, dst_site, dist)) = ctx
129 .state
130 .data()
131 .sites
132 .iter()
133 .map(|(site_id, site)| (site_id, site, site.wpos.as_().distance(ctx.npc.wpos.xy())))
135 .filter(|(site_id, _, dist)| Some(*site_id) != ctx.npc.current_site && (1000.0..5_000.0).contains(dist))
137 .choose(&mut ChaChaRng::from_seed([(ctx.time.0 / (60.0 * 15.0)) as u8; 32]))
140 && 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 const SLAY_REWARD_ITEM: ItemResource = ItemResource::Coin;
203 if let Some((monster_id, monster)) = ctx
204 .state
205 .data()
206 .npcs
207 .iter()
208 .filter(|(_, npc)| matches!(&npc.role, Role::Monster))
210 .filter(|(id, _)| ctx.state.data().quests.related_to(*id).count() == 0)
212 .filter(|(_, npc)| npc.wpos.xy().distance(ctx.npc.wpos.xy()) < 2500.0)
214 .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 && ctx.time > timeout
293 && let Ok(Some(_)) = resolve_take_deposit(ctx, quest_id, false)
295 {
296 if ctx.npc.job == Some(Job::Quest(quest_id)) {
298 ctx.controller.end_quest();
299 }
300
301 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 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 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 .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 ctx.state
354 .data()
355 .quests
356 .get(quest_id)
357 .is_none_or(|q| q.resolution().is_some())
358 })
359 .map(|_, _| ())
360}