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