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 .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 && ctx.time > timeout
287 && let Ok(Some(_)) = resolve_take_deposit(ctx, quest_id, false)
289 {
290 if ctx.npc.job == Some(Job::Quest(quest_id)) {
292 ctx.controller.end_quest();
293 }
294
295 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 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 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 .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 ctx.data
347 .quests
348 .get(quest_id)
349 .is_none_or(|q| q.resolution().is_some())
350 })
351 .map(|_, _| ())
352}