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 .data
65 .quests
66 .get(quest_id)
67 .and_then(|q| q.resolve(ctx.npc_id, success))
68 {
69 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 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
102pub 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 const ESCORT_REWARD_ITEM: ItemResource = ItemResource::Coin;
122 if ctx.npc.job.is_none()
124 && matches!(ctx.npc.profession(), Some(Profession::Merchant))
126 && let Some((dst_site_id, dst_site, dist)) = ctx.data
128 .sites
129 .iter()
130 .map(|(site_id, site)| (site_id, site, site.wpos.as_().distance(ctx.npc.wpos.xy())))
132 .filter(|(site_id, _, dist)| Some(*site_id) != ctx.npc.current_site && (1000.0..5_000.0).contains(dist))
134 .choose(&mut ChaChaRng::from_seed([(ctx.time.0 / (60.0 * 15.0)) as u8; 32]))
137 && 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 const SLAY_REWARD_ITEM: ItemResource = ItemResource::Coin;
200 if let Some((monster_id, monster)) = ctx.data
201 .npcs
202 .iter()
203 .filter(|(_, npc)| matches!(&npc.role, Role::Monster))
205 .filter(|(id, _)| ctx.data.quests.related_to(*id).count() == 0)
207 .filter(|(_, npc)| npc.wpos.xy().distance(ctx.npc.wpos.xy()) < 2500.0)
209 .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 .then(session.say_statement(Content::localized(
256 "npc-response-quest-slay-start_2",
257 )))
258 .then(session.say_statement(Content::localized(
259 "npc-response-quest-slay-start_3",
260 )))
261 .then(session.say_statement(Content::localized(
262 "npc-response-quest-slay-start_4",
263 )))
264 .boxed()
265 } else {
266 session
267 .say_statement(Content::localized(
268 "npc-response-quest-rejected",
269 ))
270 .boxed()
271 }
272 })
273 })
274 .boxed(),
275 );
276 }
277
278 if quests.is_empty() {
279 session
280 .say_statement(Content::localized("npc-response-quest-nothing"))
281 .boxed()
282 } else {
283 quests.remove(ctx.rng.random_range(0..quests.len()))
284 }
285 })
286}
287
288pub fn check_for_timeouts<S: State>(ctx: &mut NpcCtx) -> Option<impl Action<S> + use<S>> {
289 for quest_id in ctx.data.quests.related_to(ctx.npc_id) {
290 let Some(quest) = ctx.data.quests.get(quest_id) else {
291 continue;
292 };
293 if let Some(timeout) = quest.timeout
294 && ctx.time > timeout
296 && let Ok(Some(_)) = resolve_take_deposit(ctx, quest_id, false)
298 {
299 if ctx.npc.job == Some(Job::Quest(quest_id)) {
301 ctx.controller.end_quest();
302 }
303
304 match quest.kind {
306 QuestKind::Escort { escorter, .. } => {
307 return Some(
308 goto_actor(escorter, 2.0)
309 .then(do_dialogue(escorter, move |session| {
310 session
311 .say_statement(Content::localized("npc-response-quest-timeout"))
312 }))
313 .boxed(),
314 );
315 },
316 QuestKind::Slay { .. } => {},
317 }
318 }
319 }
320 None
321}
322
323pub fn escorted<S: State>(quest_id: QuestId, escorter: Actor, dst_site: SiteId) -> impl Action<S> {
324 follow_actor(escorter, 5.0)
325 .stop_if(move |ctx: &mut NpcCtx| {
326 if let Some(escorter_pos) = util::locate_actor(ctx, escorter)
328 && ctx.npc.wpos.xy().distance_squared(escorter_pos.xy()) > 20.0f32.powi(2)
329 && ctx.rng.random_bool(ctx.dt as f64 / 30.0)
330 {
331 ctx.controller
332 .say(None, Content::localized("npc-speech-wait_for_me"));
333 }
334 ctx.data
336 .sites
337 .get(dst_site)
338 .is_none_or(|site| site.wpos.as_().distance_squared(ctx.npc.wpos.xy()) < 150.0f32.powi(2))
339 })
340 .then(goto_actor(escorter, 2.0))
341 .then(do_dialogue(escorter, move |session| {
342 session
343 .say_statement(Content::localized("npc-response-quest-escort-complete"))
344 .then(now(move |ctx, _| {
346 ctx.controller.end_quest();
347 match resolve_take_deposit(ctx, quest_id, true) {
348 Ok(deposit) => session.say_statement_with_gift(Content::localized("npc-response-quest-reward"), deposit).boxed(),
349 Err(()) => finish().boxed(),
350 }
351 }))
352 }))
353 .stop_if(move |ctx: &mut NpcCtx| {
354 ctx.data
356 .quests
357 .get(quest_id)
358 .is_none_or(|q| q.resolution().is_some())
359 })
360 .map(|_, _| ())
361}