veloren_server_agent/
util.rs

1use crate::data::{AbilityData, ActionMode, AgentData, AttackData, Path, ReadData, TargetData};
2use common::{
3    comp::{
4        Agent, Alignment, Body, Controller, InputKind, Pos, Scale,
5        ability::AbilityInput,
6        agent::Psyche,
7        buff::BuffKind,
8        item::{ItemDesc, ItemTag, tool::AbilityContext},
9    },
10    consts::GRAVITY,
11    terrain::Block,
12    uid::Uid,
13    util::Dir,
14    vol::ReadVol,
15};
16use core::f32::consts::PI;
17use rand::Rng;
18use specs::Entity as EcsEntity;
19use vek::*;
20
21pub fn is_dead_or_invulnerable(entity: EcsEntity, read_data: &ReadData) -> bool {
22    is_dead(entity, read_data) || is_invulnerable(entity, read_data)
23}
24
25pub fn is_dead(entity: EcsEntity, read_data: &ReadData) -> bool {
26    let health = read_data.healths.get(entity);
27    health.is_some_and(|a| a.is_dead)
28}
29
30// FIXME: The logic that is used in this function and throughout the code
31// shouldn't be used to mean that a character is in a safezone.
32pub fn is_invulnerable(entity: EcsEntity, read_data: &ReadData) -> bool {
33    let buffs = read_data.buffs.get(entity);
34
35    buffs.is_some_and(|b| b.kinds[BuffKind::Invulnerability].is_some())
36}
37
38pub fn is_steering(entity: EcsEntity, read_data: &ReadData) -> bool {
39    read_data
40        .is_volume_riders
41        .get(entity)
42        .is_some_and(|r| r.is_steering_entity())
43}
44
45/// Gets alignment of owner if alignment given is `Owned`.
46/// Returns original alignment if not owned.
47pub fn try_owner_alignment<'a>(
48    alignment: Option<&'a Alignment>,
49    read_data: &'a ReadData,
50) -> Option<&'a Alignment> {
51    if let Some(&Alignment::Owned(owner_uid)) = alignment {
52        if let Some(owner) = get_entity_by_id(owner_uid, read_data) {
53            return read_data.alignments.get(owner);
54        }
55    }
56    alignment
57}
58
59/// Projectile motion: Returns the direction to aim for the projectile to reach
60/// target position. Does not take any forces but gravity into account.
61pub fn aim_projectile(speed: f32, pos: Vec3<f32>, tgt: Vec3<f32>) -> Option<Dir> {
62    let mut to_tgt = tgt - pos;
63    let dist_sqrd = to_tgt.xy().magnitude_squared();
64    let u_sqrd = speed.powi(2);
65    to_tgt.z = (u_sqrd
66        - (u_sqrd.powi(2) - GRAVITY * (GRAVITY * dist_sqrd + 2.0 * to_tgt.z * u_sqrd))
67            .sqrt()
68            .max(0.0))
69        / GRAVITY;
70
71    Dir::from_unnormalized(to_tgt)
72}
73
74pub fn get_entity_by_id(uid: Uid, read_data: &ReadData) -> Option<EcsEntity> {
75    read_data.id_maps.uid_entity(uid)
76}
77
78/// Calculates whether the agent should continue chase or let the target escape.
79///
80/// Will return true when score of letting target escape is higher then the
81/// score of continuing the pursue, false otherwise.
82pub fn stop_pursuing(
83    dist_to_target_sqrd: f32,
84    dist_to_home_sqrd: f32,
85    own_health_fraction: f32,
86    target_health_fraction: f32,
87    dur_since_last_attacked: f64,
88    psyche: &Psyche,
89) -> bool {
90    psyche.should_stop_pursuing
91        && should_let_target_escape(
92            dist_to_home_sqrd,
93            dur_since_last_attacked,
94            own_health_fraction,
95        ) > should_continue_to_pursue(dist_to_target_sqrd, psyche, target_health_fraction)
96}
97
98/// Scores the benefit of continuing the pursue in value from 0 to infinity.
99fn should_continue_to_pursue(
100    dist_to_target_sqrd: f32,
101    psyche: &Psyche,
102    target_health_fraction: f32,
103) -> f32 {
104    let aggression_score = (1.0 / psyche.flee_health.max(0.25))
105        * psyche.aggro_dist.unwrap_or(psyche.sight_dist)
106        * psyche.sight_dist;
107
108    (100.0 * aggression_score) / (dist_to_target_sqrd * target_health_fraction)
109}
110
111/// Scores the benefit of letting the target escape in a value from 0 to
112/// infinity.
113fn should_let_target_escape(
114    dist_to_home_sqrd: f32,
115    dur_since_last_attacked: f64,
116    own_health_fraction: f32,
117) -> f32 {
118    (dist_to_home_sqrd / own_health_fraction) * dur_since_last_attacked as f32 * 0.005
119}
120
121pub fn entity_looks_like_cultist(entity: EcsEntity, read_data: &ReadData) -> bool {
122    let number_of_cultist_items_equipped = read_data.inventories.get(entity).map_or(0, |inv| {
123        inv.equipped_items()
124            .filter(|item| item.tags().contains(&ItemTag::Cultist))
125            .count()
126    });
127
128    number_of_cultist_items_equipped > 2
129}
130
131// FIXME: `Alignment::Npc` doesn't necessarily mean villager.
132pub fn is_villager(alignment: Option<&Alignment>) -> bool {
133    alignment.is_some_and(|alignment| matches!(alignment, Alignment::Npc))
134}
135
136pub fn is_village_guard(_entity: EcsEntity, _read_data: &ReadData) -> bool {
137    // FIXME: We need to be able to change the name of a guard without
138    // breaking this logic.
139    // The `Mark` enum from common::agent could be used to match with
140    // `agent::Mark::Guard`
141    false
142    /*
143    read_data
144        .stats
145        .get(entity)
146        .is_some_and(|stats| stats.name == "Guard")
147    */
148}
149
150pub fn are_our_owners_hostile(
151    our_alignment: Option<&Alignment>,
152    their_alignment: Option<&Alignment>,
153    read_data: &ReadData,
154) -> bool {
155    try_owner_alignment(our_alignment, read_data).is_some_and(|our_owners_alignment| {
156        try_owner_alignment(their_alignment, read_data).is_some_and(|their_owners_alignment| {
157            our_owners_alignment.hostile_towards(*their_owners_alignment)
158        })
159    })
160}
161
162pub fn entities_have_line_of_sight(
163    pos: &Pos,
164    body: Option<&Body>,
165    scale: f32,
166    other_pos: &Pos,
167    other_body: Option<&Body>,
168    other_scale: Option<&Scale>,
169    read_data: &ReadData,
170) -> bool {
171    let get_eye_pos = |pos: &Pos, body: Option<&Body>, scale: f32| {
172        let eye_offset = body.map_or(0.0, |b| b.eye_height(scale));
173
174        Pos(pos.0.with_z(pos.0.z + eye_offset))
175    };
176    let eye_pos = get_eye_pos(pos, body, scale);
177    let other_eye_pos = get_eye_pos(other_pos, other_body, other_scale.map_or(1.0, |s| s.0));
178
179    positions_have_line_of_sight(&eye_pos, &other_eye_pos, read_data)
180}
181
182pub fn positions_have_line_of_sight(pos_a: &Pos, pos_b: &Pos, read_data: &ReadData) -> bool {
183    let dist_sqrd = pos_b.0.distance_squared(pos_a.0);
184
185    read_data
186        .terrain
187        .ray(pos_a.0, pos_b.0)
188        .until(Block::is_opaque)
189        .cast()
190        .0
191        .powi(2)
192        >= (dist_sqrd - 0.01)
193}
194
195pub fn is_dressed_as_cultist(entity: EcsEntity, read_data: &ReadData) -> bool {
196    read_data.inventories.get(entity).is_some_and(|inventory| {
197        inventory
198            .equipped_items()
199            .filter(|item| item.tags().contains(&ItemTag::Cultist))
200            .count()
201            > 2
202    })
203}
204
205pub fn is_dressed_as_witch(entity: EcsEntity, read_data: &ReadData) -> bool {
206    read_data.inventories.get(entity).is_some_and(|inventory| {
207        inventory
208            .equipped_items()
209            .filter(|item| item.tags().contains(&ItemTag::Witch))
210            .count()
211            > 3
212    })
213}
214
215pub fn is_dressed_as_pirate(entity: EcsEntity, read_data: &ReadData) -> bool {
216    read_data.inventories.get(entity).is_some_and(|inventory| {
217        inventory
218            .equipped_items()
219            .filter(|item| item.tags().contains(&ItemTag::Pirate))
220            .count()
221            > 4
222    })
223}
224
225pub fn get_attacker(entity: EcsEntity, read_data: &ReadData) -> Option<EcsEntity> {
226    read_data
227        .healths
228        .get(entity)
229        .filter(|health| health.last_change.amount < 0.0)
230        .and_then(|health| health.last_change.damage_by())
231        .and_then(|damage_contributor| get_entity_by_id(damage_contributor.uid(), read_data))
232}
233
234impl AgentData<'_> {
235    pub fn has_buff(&self, read_data: &ReadData, buff: BuffKind) -> bool {
236        read_data
237            .buffs
238            .get(*self.entity)
239            .is_some_and(|b| b.kinds[buff].is_some())
240    }
241
242    pub fn extract_ability(&self, input: AbilityInput) -> Option<AbilityData> {
243        let context = AbilityContext::from(self.stance, Some(self.inventory), self.combo);
244        AbilityData::from_ability(
245            &self
246                .active_abilities
247                .activate_ability(
248                    input,
249                    Some(self.inventory),
250                    self.skill_set,
251                    self.body,
252                    Some(self.char_state),
253                    &context,
254                    self.stats,
255                )
256                .map_or(Default::default(), |a| a.0),
257        )
258    }
259}
260
261// Probably works best for melee (or maybe only for melee considering its
262// reliance on blocking?)
263/// Handles whether an agent should attack and how the agent moves around.
264/// Returns whether the agent should attack (so that individual tactics can
265/// determine what specific attack to use)
266pub fn handle_attack_aggression(
267    agent_data: &AgentData,
268    agent: &mut Agent,
269    controller: &mut Controller,
270    attack_data: &AttackData,
271    tgt_data: &TargetData,
272    read_data: &ReadData,
273    rng: &mut impl Rng,
274    timer_pos_timeout_index: usize,
275    timer_guarded_cycle_index: usize,
276    fcounter_guarded_timer_index: usize,
277    icounter_action_mode_index: usize,
278    condition_guarded_defend_index: usize,
279    condition_rolling_breakthrough_index: usize,
280    position_guarded_cover_index: usize,
281    position_flee_index: usize,
282) -> bool {
283    if let Some(health) = agent_data.health {
284        agent.combat_state.int_counters[icounter_action_mode_index] = if health.fraction() < 0.1 {
285            agent.combat_state.positions[position_guarded_cover_index] = None;
286            ActionMode::Fleeing as u8
287        } else if health.fraction() < 0.9 {
288            agent.combat_state.positions[position_flee_index] = None;
289            ActionMode::Guarded as u8
290        } else {
291            agent.combat_state.positions[position_guarded_cover_index] = None;
292            agent.combat_state.positions[position_flee_index] = None;
293            ActionMode::Reckless as u8
294        };
295    }
296
297    // If agent has not moved, assume agent was unable to move and reset attempted
298    // path positions if occurs for too long
299    if agent_data.vel.0.magnitude_squared() < 1_f32.powi(2) {
300        agent.combat_state.timers[timer_pos_timeout_index] += read_data.dt.0;
301    } else {
302        agent.combat_state.timers[timer_pos_timeout_index] = 0.0;
303    }
304
305    if agent.combat_state.timers[timer_pos_timeout_index] > 2.0 {
306        agent.combat_state.positions[position_guarded_cover_index] = None;
307        agent.combat_state.positions[position_flee_index] = None;
308        agent.combat_state.timers[timer_pos_timeout_index] = 0.0;
309    }
310
311    match ActionMode::from_u8(agent.combat_state.int_counters[icounter_action_mode_index]) {
312        ActionMode::Reckless => true,
313        ActionMode::Guarded => {
314            agent.combat_state.timers[timer_guarded_cycle_index] += read_data.dt.0;
315            if agent.combat_state.timers[timer_guarded_cycle_index]
316                > agent.combat_state.counters[fcounter_guarded_timer_index]
317            {
318                agent.combat_state.timers[timer_guarded_cycle_index] = 0.0;
319                agent.combat_state.conditions[condition_guarded_defend_index] ^= true;
320                agent.combat_state.counters[fcounter_guarded_timer_index] =
321                    if agent.combat_state.conditions[condition_guarded_defend_index] {
322                        rng.gen_range(3.0..6.0)
323                    } else {
324                        rng.gen_range(6.0..10.0)
325                    };
326            }
327            if let Some(pos) = agent.combat_state.positions[position_guarded_cover_index] {
328                if pos.distance_squared(agent_data.pos.0) < 3_f32.powi(2) {
329                    agent.combat_state.positions[position_guarded_cover_index] = None;
330                }
331            }
332            if !agent.combat_state.conditions[condition_guarded_defend_index] {
333                agent.combat_state.positions[position_guarded_cover_index] = None;
334                true
335            } else {
336                if attack_data.dist_sqrd > 10_f32.powi(2) {
337                    // Choose random point to either side when looking at target and move
338                    // towards it
339                    if let Some(pos) = agent.combat_state.positions[position_guarded_cover_index] {
340                        if pos.distance_squared(agent_data.pos.0) < 5_f32.powi(2) {
341                            agent.combat_state.positions[position_guarded_cover_index] = None;
342                        }
343                        agent_data.path_toward_target(
344                            agent,
345                            controller,
346                            pos,
347                            read_data,
348                            Path::Separate,
349                            None,
350                        );
351                    } else {
352                        agent.combat_state.positions[position_guarded_cover_index] = {
353                            let rand_dir = {
354                                let dir = (tgt_data.pos.0 - agent_data.pos.0)
355                                    .try_normalized()
356                                    .unwrap_or(Vec3::unit_x())
357                                    .xy();
358                                if rng.gen_bool(0.5) {
359                                    dir.rotated_z(PI / 2.0 + rng.gen_range(-0.75..0.0))
360                                } else {
361                                    dir.rotated_z(-PI / 2.0 + rng.gen_range(-0.0..0.75))
362                                }
363                            };
364                            let attempted_dist = rng.gen_range(6.0..16.0);
365                            let actual_dist = read_data
366                                .terrain
367                                .ray(
368                                    agent_data.pos.0 + Vec3::unit_z() * 0.5,
369                                    agent_data.pos.0
370                                        + Vec3::unit_z() * 0.5
371                                        + rand_dir * attempted_dist,
372                                )
373                                .until(Block::is_solid)
374                                .cast()
375                                .0
376                                - 1.0;
377                            Some(agent_data.pos.0 + rand_dir * actual_dist)
378                        };
379                    }
380                } else if let Some(pos) = agent.combat_state.positions[position_guarded_cover_index]
381                {
382                    agent_data.path_toward_target(
383                        agent,
384                        controller,
385                        pos,
386                        read_data,
387                        Path::Separate,
388                        None,
389                    );
390                    if agent.combat_state.conditions[condition_rolling_breakthrough_index] {
391                        controller.push_basic_input(InputKind::Roll);
392                        agent.combat_state.conditions[condition_rolling_breakthrough_index] = false;
393                    }
394                    if tgt_data.char_state.is_some_and(|cs| cs.is_melee_attack()) {
395                        controller.push_basic_input(InputKind::Block);
396                    }
397                } else {
398                    agent.combat_state.positions[position_guarded_cover_index] = {
399                        let backwards = (agent_data.pos.0 - tgt_data.pos.0)
400                            .try_normalized()
401                            .unwrap_or(Vec3::unit_x())
402                            .xy();
403                        let pos = if read_data
404                            .terrain
405                            .ray(
406                                agent_data.pos.0 + Vec3::unit_z() * 0.5,
407                                agent_data.pos.0 + Vec3::unit_z() * 0.5 + backwards * 6.0,
408                            )
409                            .until(Block::is_solid)
410                            .cast()
411                            .0
412                            > 5.0
413                        {
414                            agent_data.pos.0 + backwards * 5.0
415                        } else {
416                            agent.combat_state.conditions[condition_rolling_breakthrough_index] =
417                                true;
418                            agent_data.pos.0
419                                - backwards
420                                    * read_data
421                                        .terrain
422                                        .ray(
423                                            agent_data.pos.0 + Vec3::unit_z() * 0.5,
424                                            agent_data.pos.0 + Vec3::unit_z() * 0.5
425                                                - backwards * 10.0,
426                                        )
427                                        .until(Block::is_solid)
428                                        .cast()
429                                        .0
430                                - 1.0
431                        };
432                        Some(pos)
433                    }
434                }
435                false
436            }
437        },
438        ActionMode::Fleeing => {
439            if agent.combat_state.conditions[condition_rolling_breakthrough_index] {
440                controller.push_basic_input(InputKind::Roll);
441                agent.combat_state.conditions[condition_rolling_breakthrough_index] = false;
442            }
443            if let Some(pos) = agent.combat_state.positions[position_flee_index] {
444                if let Some(dir) = Dir::from_unnormalized(pos - agent_data.pos.0) {
445                    controller.inputs.look_dir = dir;
446                }
447                if pos.distance_squared(agent_data.pos.0) < 5_f32.powi(2) {
448                    agent.combat_state.positions[position_flee_index] = None;
449                }
450                agent_data.path_toward_target(
451                    agent,
452                    controller,
453                    pos,
454                    read_data,
455                    Path::Separate,
456                    None,
457                );
458            } else {
459                agent.combat_state.positions[position_flee_index] = {
460                    let rand_dir = {
461                        let dir = (agent_data.pos.0 - tgt_data.pos.0)
462                            .try_normalized()
463                            .unwrap_or(Vec3::unit_x())
464                            .xy();
465                        dir.rotated_z(rng.gen_range(-0.75..0.75))
466                    };
467                    let attempted_dist = rng.gen_range(16.0..26.0);
468                    let actual_dist = read_data
469                        .terrain
470                        .ray(
471                            agent_data.pos.0 + Vec3::unit_z() * 0.5,
472                            agent_data.pos.0 + Vec3::unit_z() * 0.5 + rand_dir * attempted_dist,
473                        )
474                        .until(Block::is_solid)
475                        .cast()
476                        .0
477                        - 1.0;
478                    if actual_dist < 10.0 {
479                        let dist = read_data
480                            .terrain
481                            .ray(
482                                agent_data.pos.0 + Vec3::unit_z() * 0.5,
483                                agent_data.pos.0 + Vec3::unit_z() * 0.5 - rand_dir * attempted_dist,
484                            )
485                            .until(Block::is_solid)
486                            .cast()
487                            .0
488                            - 1.0;
489                        agent.combat_state.conditions[condition_rolling_breakthrough_index] = true;
490                        Some(agent_data.pos.0 - rand_dir * dist)
491                    } else {
492                        Some(agent_data.pos.0 + rand_dir * actual_dist)
493                    }
494                };
495            }
496            false
497        },
498    }
499}