veloren_rtsim/rule/npc_ai/
airship_ai.rs

1#[cfg(feature = "airship_log")]
2use crate::rule::npc_ai::airship_logger::airship_logger;
3use crate::{
4    ai::{Action, NpcCtx, State, finish, just, now, predicate::timeout},
5    data::npc::SimulationMode,
6};
7
8use common::{
9    comp::{
10        Content,
11        agent::{BrakingMode, FlightMode},
12        compass::Direction,
13    },
14    consts::AIR_DENSITY,
15    rtsim::NpcId,
16    util::Dir,
17};
18use num_traits::cast::FromPrimitive;
19use rand::prelude::*;
20use std::{cmp::Ordering, collections::VecDeque, fmt, time::Duration};
21use vek::*;
22use world::{
23    civ::airship_travel::{AirshipDockingApproach, Airships},
24    util::CARDINALS,
25};
26
27#[cfg(debug_assertions)] use tracing::debug;
28
29use tracing::warn;
30
31const CLOSE_TO_DOCKING_SITE_DISTANCE_SQR: f32 = 225.0f32 * 225.0f32;
32const VERY_CLOSE_AIRSHIP_DISTANCE_SQR: f32 = 400.0f32 * 400.0f32;
33const CRUISE_CHECKPOINT_DISTANCE: f32 = 800.0;
34const NEXT_PILOT_CRUISE_SPEED_TOLERANCE: f32 = 2.0;
35const MOVING_AVERAGE_SCALE_FACTOR: f64 = 10000.0;
36const NEXT_PILOT_MOVING_VELOCITY_AVERAGE_CAPACITY: usize = 5;
37const NEXT_PILOT_MOVING_VELOCITY_AVERAGE_MIN_SIZE: usize = 3;
38const NEXT_PILOT_MOVING_DIST_AVERAGE_CAPACITY: usize = 3;
39const NEXT_PILOT_MOVING_DIST_AVERAGE_MIN_SIZE: usize = 2;
40const NEXT_PILOT_MOVING_DIST_TRACKER_THRESHOLD: usize = 5;
41const NEXT_PILOT_VELOCITY_RATIO_MIN: f32 = 1.05;
42const NEXT_PILOT_SPACING_THRESHOLD_SQR: f32 = 0.6 * 0.6; // squared because distances are compared while squared;
43const CLOSE_AIRSHIP_SPEED_FACTOR: f32 = 0.9;
44
45/// Airships can slow down or hold position to avoid collisions with other
46/// airships. Stuck mode means the airship was stuck in one position and
47/// is not backing out and climbing to clear the obstacle.
48#[derive(Debug, Copy, Clone, Default, PartialEq)]
49enum AirshipAvoidanceMode {
50    #[default]
51    None,
52    Slow(f32),
53    Hold(Vec3<f32>, Dir),
54    Stuck(Vec3<f32>),
55}
56
57/// The context data for the pilot_airship action.
58#[derive(Debug, Clone)]
59struct AirshipRouteContext {
60    /// The route index (index into the outer vec of airships.routes)
61    route_index: usize,
62    /// The expected airship speed when simulated.
63    simulated_airship_speed: f32,
64    /// The next route leg index.
65    next_leg: usize,
66    /// The NpcId of the captain ahead of this one.
67    next_pilot_id: NpcId,
68    /// The current approach.
69    current_leg_approach: Option<AirshipDockingApproach>,
70    /// The next approach.
71    next_leg_approach: Option<AirshipDockingApproach>,
72    /// A point short of the approach transition point where the frequency of
73    /// avoidance checks increases.
74    next_leg_cruise_checkpoint_pos: Vec3<f32>,
75    /// Value used to convert a target velocity to a speed factor.
76    speed_factor_conversion_factor: f32,
77
78    // Avoidance Data
79    /// For tracking the airship's position history to determine if the airship
80    /// is stuck.
81    my_stuck_tracker: Option<StuckAirshipTracker>,
82    /// For tracking airship velocity towards the approach transition point.
83    my_rate_tracker: Option<RateTracker>,
84    /// For tracking the next pilot's velocity towards the approach transition
85    /// point.
86    next_pilot_rate_tracker_: Option<RateTracker>,
87    /// Timer for checking the airship trackers.
88    avoidance_timer: Duration,
89    /// Timer used when holding, either on approach or at the dock.
90    hold_timer: f32,
91    /// Whether the initial hold message has been sent to the client.
92    hold_announced: bool,
93    /// The moving average of the next pilot's velocity during cruise phase.
94    next_pilot_average_velocity: MovingAverage<
95        i64,
96        NEXT_PILOT_MOVING_VELOCITY_AVERAGE_CAPACITY,
97        NEXT_PILOT_MOVING_VELOCITY_AVERAGE_MIN_SIZE,
98    >,
99    /// The moving average of the next pilot's distance from my current docking
100    /// position target pos.
101    next_pilot_dist_to_my_docking_pos_tracker:
102        DistanceTrendTracker<NEXT_PILOT_MOVING_DIST_TRACKER_THRESHOLD>,
103    /// The current avoidance mode for the airship.
104    avoid_mode: AirshipAvoidanceMode,
105    /// Whether the airship had to hold during the last flight.
106    did_hold: bool,
107    /// The extra docking time due to holding.
108    extra_hold_dock_time: f32,
109}
110
111impl Default for AirshipRouteContext {
112    fn default() -> Self {
113        Self {
114            route_index: usize::MAX,
115            simulated_airship_speed: 0.0,
116            next_leg: 0,
117            next_pilot_id: NpcId::default(),
118            current_leg_approach: None,
119            next_leg_approach: None,
120            next_leg_cruise_checkpoint_pos: Vec3::default(),
121            speed_factor_conversion_factor: 0.0,
122            my_stuck_tracker: None,
123            my_rate_tracker: None,
124            next_pilot_rate_tracker_: None,
125            avoidance_timer: Duration::default(),
126            hold_timer: 0.0,
127            hold_announced: false,
128            next_pilot_average_velocity: MovingAverage::default(),
129            next_pilot_dist_to_my_docking_pos_tracker: DistanceTrendTracker::default(),
130            avoid_mode: AirshipAvoidanceMode::default(),
131            did_hold: false,
132            extra_hold_dock_time: 0.0,
133        }
134    }
135}
136
137/// Tracks the airship position history.
138/// Used for determining if an airship is stuck.
139#[derive(Debug, Default, Clone)]
140struct StuckAirshipTracker {
141    /// The airship's position history. Used for determining if the airship is
142    /// stuck in one place.
143    pos_history: Vec<Vec3<f32>>,
144    /// The route to follow for backing out of a stuck position.
145    backout_route: Vec<Vec3<f32>>,
146}
147
148impl StuckAirshipTracker {
149    /// The distance to back out from the stuck position.
150    const BACKOUT_DIST: f32 = 100.0;
151    /// The tolerance for determining if the airship has reached a backout
152    /// position.
153    const BACKOUT_TARGET_DIST: f32 = 50.0;
154    /// The number of positions to track in the position history.
155    const MAX_POS_HISTORY_SIZE: usize = 5;
156    /// The height for testing if the airship is near the ground.
157    const NEAR_GROUND_HEIGHT: f32 = 10.0;
158
159    /// Add a new position to the position history, maintaining a fixed size.
160    fn add_position(&mut self, new_pos: Vec3<f32>) {
161        if self.pos_history.len() >= StuckAirshipTracker::MAX_POS_HISTORY_SIZE {
162            self.pos_history.remove(0);
163        }
164        self.pos_history.push(new_pos);
165    }
166
167    /// Get the current backout position.
168    /// If the backout route is not empty, return the first position in the
169    /// route. As a side effect, if the airship position is within the
170    /// target distance of the first backout position, remove the backout
171    /// position. If there are no more backout postions, the position
172    /// history is cleared (because it will be stale data), and return None.
173    fn current_backout_pos(&mut self, ctx: &mut NpcCtx) -> Option<Vec3<f32>> {
174        if !self.backout_route.is_empty()
175            && let Some(pos) = self.backout_route.first().cloned()
176        {
177            if ctx.npc.wpos.distance_squared(pos) < StuckAirshipTracker::BACKOUT_TARGET_DIST.powi(2)
178            {
179                self.backout_route.remove(0);
180            }
181            Some(pos)
182        } else {
183            self.pos_history.clear();
184            None
185        }
186    }
187
188    /// Check if the airship is stuck in one place. This check is done only in
189    /// cruise flight when the PID controller is affecting the Z axis
190    /// movement only. When the airship gets stuck, it will stop moving. The
191    /// only recourse is reverse direction, back up, and then ascend to
192    /// hopefully fly over the top of the obstacle. This may be repeated if the
193    /// airship gets stuck again. When the determination is made that the
194    /// airship is stuck, two positions are generated for the backout
195    /// procedure: the first is in the reverse of the direction the airship
196    /// was recently moving, and the second is straight up from the first
197    /// position. If the airship was near the ground when it got stuck, the
198    /// initial backout is done while climbing slightly to avoid any other
199    /// near-ground objects. If the airship was not near the ground, the
200    /// initial backout position is at the same height as the current position,
201    fn is_stuck(
202        &mut self,
203        ctx: &mut NpcCtx,
204        current_pos: &Vec3<f32>,
205        target_pos: &Vec2<f32>,
206    ) -> bool {
207        self.add_position(*current_pos);
208        // The position history must be full to determine if the airship is stuck.
209        if self.pos_history.len() == StuckAirshipTracker::MAX_POS_HISTORY_SIZE
210            && self.backout_route.is_empty()
211        {
212            if let Some(last_pos) = self.pos_history.last() {
213                // If all the positions in the history are within 10 of the last position,
214                if self
215                    .pos_history
216                    .iter()
217                    .all(|pos| pos.distance_squared(*last_pos) < 10.0)
218                {
219                    // Airship is stuck on some obstacle.
220
221                    // The direction to backout is opposite to the direction from the airship
222                    // to where it was going before it got stuck.
223                    if let Some(backout_dir) = (ctx.npc.wpos.xy() - target_pos)
224                        .with_z(0.0)
225                        .try_normalized()
226                    {
227                        let ground = ctx
228                            .world
229                            .sim()
230                            .get_surface_alt_approx(last_pos.xy().map(|e| e as i32));
231                        // The position to backout to is the current position + a distance in the
232                        // backout direction.
233                        let mut backout_pos =
234                            ctx.npc.wpos + backout_dir * StuckAirshipTracker::BACKOUT_DIST;
235                        // Add a z offset to the backout pos if the airship is near the ground.
236                        if (ctx.npc.wpos.z - ground).abs() < StuckAirshipTracker::NEAR_GROUND_HEIGHT
237                        {
238                            backout_pos.z += 50.0;
239                        }
240                        self.backout_route =
241                            vec![backout_pos, backout_pos + Vec3::unit_z() * 200.0];
242                        // The airship is stuck.
243                        #[cfg(debug_assertions)]
244                        debug!(
245                            "Airship {} Stuck! at {} {} {}, backout_dir:{:?}, backout_pos:{:?}",
246                            format!("{:?}", ctx.npc_id),
247                            ctx.npc.wpos.x,
248                            ctx.npc.wpos.y,
249                            ctx.npc.wpos.z,
250                            backout_dir,
251                            backout_pos
252                        );
253                    }
254                }
255            }
256        }
257        !self.backout_route.is_empty()
258    }
259}
260
261#[derive(Debug, Clone, Default)]
262/// Tracks previous position and time to get a rough estimate of an NPC's
263/// velocity.
264struct RateTracker {
265    /// The previous position of the NPC in the XY plane.
266    last_pos: Option<Vec2<f32>>,
267    /// The game time when the last position was recorded.
268    last_time: f32,
269}
270
271impl RateTracker {
272    /// Calculates the velocity estimate, updates the previous values and
273    /// returns the velocity.
274    fn update(&mut self, current_pos: Vec2<f32>, time: f32) -> f32 {
275        let rate = if let Some(last_pos) = self.last_pos {
276            let dt = time - self.last_time;
277            if dt > 0.0 {
278                current_pos.distance(last_pos) / dt // blocks per second
279            } else {
280                0.0
281            }
282        } else {
283            0.0
284        };
285        self.last_pos = Some(current_pos);
286        self.last_time = time;
287        rate
288    }
289}
290
291#[derive(Debug, PartialEq)]
292/// The trend of the distance changes. The distance measured could be
293/// between a fixed position and an NPC or between two NPCs.
294enum DistanceTrend {
295    /// The distance is decreasing.
296    Towards,
297    /// The distance is increasing.
298    Away,
299    /// The distance is not changing significantly.
300    Neutral,
301}
302
303/// Tracks airship distance trend from a fixed position. Used for airship
304/// traffic control. The parameter C is the threshold for determining if the
305/// trend is stable (i.e., not increasing or decreasing too much).
306#[derive(Debug, Default, Clone)]
307struct DistanceTrendTracker<const C: usize> {
308    /// The fixed position to track the distance from.
309    fixed_pos: Vec2<f32>,
310    /// A moving average of the distance change rate.
311    avg_rate: MovingAverage<
312        f64,
313        NEXT_PILOT_MOVING_DIST_AVERAGE_CAPACITY,
314        NEXT_PILOT_MOVING_DIST_AVERAGE_MIN_SIZE,
315    >,
316    /// The most recent (previous) distance measurement.
317    prev_dist: Option<f32>,
318    /// The time when the previous distance was measured.
319    prev_time: f64,
320}
321
322impl<const C: usize> DistanceTrendTracker<C> {
323    /// Updates the distance trend based on the current position and time versus
324    /// the previous position and time.
325    fn update(&mut self, pos: Vec2<f32>, time: f64) -> Option<DistanceTrend> {
326        let current_dist = pos.distance(self.fixed_pos);
327        if let Some(prev) = self.prev_dist {
328            // rate is blocks per second
329            // Greater than 0 means the distance is increasing (going away from the target
330            // pos). Near zero means the airship is stationary or moving
331            // perpendicular to the target pos.
332            if time - self.prev_time > f64::EPSILON {
333                let rate = (current_dist - prev) as f64 / (time - self.prev_time);
334                self.prev_dist = Some(current_dist);
335                self.prev_time = time;
336
337                self.avg_rate.add(rate);
338                if let Some(avg) = self.avg_rate.average() {
339                    if avg > C as f64 {
340                        Some(DistanceTrend::Away)
341                    } else if avg < -(C as f64) {
342                        Some(DistanceTrend::Towards)
343                    } else {
344                        Some(DistanceTrend::Neutral)
345                    }
346                } else {
347                    None
348                }
349            } else {
350                // If not enough time has passed, keep the older previous values.
351                None
352            }
353        } else {
354            self.prev_dist = Some(current_dist);
355            self.prev_time = time;
356            None
357        }
358    }
359
360    fn reset(&mut self, pos: Vec2<f32>) {
361        self.fixed_pos = pos;
362        self.avg_rate.reset();
363        self.prev_dist = None;
364    }
365}
366
367/// A moving average of at least N values and at most S values.
368#[derive(Clone, Debug)]
369struct MovingAverage<T, const S: usize, const N: usize>
370where
371    T: Default
372        + FromPrimitive
373        + std::ops::AddAssign
374        + std::ops::Sub<Output = T>
375        + std::ops::Div<Output = T>
376        + Copy,
377{
378    values: VecDeque<T>,
379    sum: T,
380}
381
382impl<T, const S: usize, const N: usize> MovingAverage<T, S, N>
383where
384    T: Default
385        + FromPrimitive
386        + std::ops::AddAssign
387        + std::ops::Sub<Output = T>
388        + std::ops::Div<Output = T>
389        + Copy,
390{
391    /// Add a value to the average. Maintains the sum without needing to iterate
392    /// the values.
393    fn add(&mut self, value: T) {
394        if self.values.len() == S {
395            if let Some(old_value) = self.values.pop_front() {
396                self.sum = self.sum - old_value;
397            }
398        }
399        self.values.push_back(value);
400        self.sum += value;
401    }
402
403    /// Returns the current average, if enough values have been added.
404    fn average(&self) -> Option<T> {
405        if self.values.len() < N {
406            None
407        } else {
408            Some(self.sum / T::from_u32(self.values.len() as u32).unwrap())
409        }
410    }
411
412    fn reset(&mut self) {
413        self.values.clear();
414        self.sum = T::from_u32(0).unwrap();
415    }
416}
417
418impl<T, const S: usize, const N: usize> Default for MovingAverage<T, S, N>
419where
420    T: Default
421        + FromPrimitive
422        + std::ops::AddAssign
423        + std::ops::Sub<Output = T>
424        + std::ops::Div<Output = T>
425        + Copy,
426{
427    fn default() -> Self {
428        Self {
429            values: VecDeque::with_capacity(S),
430            sum: T::from_u32(0).unwrap(),
431        }
432    }
433}
434
435/// The flight phases of an airship.
436#[derive(Debug, Copy, Clone, PartialEq, Default)]
437pub enum AirshipFlightPhase {
438    Ascent,
439    DepartureCruise,
440    ApproachCruise,
441    Transition,
442    #[default]
443    Docked,
444}
445
446impl fmt::Display for AirshipFlightPhase {
447    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
448        match self {
449            AirshipFlightPhase::Ascent => write!(f, "Ascent"),
450            AirshipFlightPhase::DepartureCruise => write!(f, "DepartureCruise"),
451            AirshipFlightPhase::ApproachCruise => write!(f, "ApproachCruise"),
452            AirshipFlightPhase::Transition => write!(f, "Transition"),
453            AirshipFlightPhase::Docked => write!(f, "Docked"),
454        }
455    }
456}
457
458/// Wrapper for the fly_airship action, so the route context fields can be
459/// reset.
460fn fly_airship(
461    phase: AirshipFlightPhase,
462    wpos: Vec3<f32>,
463    goal_dist: f32,
464    initial_speed_factor: f32,
465    height_offset: f32,
466    with_terrain_following: bool,
467    direction_override: Option<Dir>,
468    flight_mode: FlightMode,
469    with_collision_avoidance: bool,
470    radar_interval: Duration,
471) -> impl Action<AirshipRouteContext> {
472    now(move |_, airship_context: &mut AirshipRouteContext| {
473        airship_context.avoidance_timer = radar_interval;
474        airship_context.avoid_mode = AirshipAvoidanceMode::None;
475        if matches!(phase, AirshipFlightPhase::DepartureCruise) {
476            // Reset the stuck tracker.
477            airship_context.my_stuck_tracker = Some(StuckAirshipTracker::default());
478        }
479        fly_airship_inner(
480            phase,
481            wpos,
482            goal_dist,
483            initial_speed_factor,
484            height_offset,
485            with_terrain_following,
486            direction_override,
487            flight_mode,
488            with_collision_avoidance,
489            radar_interval,
490        )
491    })
492}
493
494/// My airship should hold position if the next pilot is moving towards my
495/// docking target and is close to my docking target and my pilot is close to
496/// the next pilot.
497fn should_hold(
498    my_pilot_distance_to_next_pilot: f32,
499    next_pilot_dist_to_docking_target: f32,
500    next_pilot_to_docking_target_trend: &DistanceTrend,
501) -> bool {
502    *next_pilot_to_docking_target_trend == DistanceTrend::Towards
503        && next_pilot_dist_to_docking_target < CLOSE_TO_DOCKING_SITE_DISTANCE_SQR
504        && my_pilot_distance_to_next_pilot < VERY_CLOSE_AIRSHIP_DISTANCE_SQR
505}
506
507/// My pilot should slow down if the pilot ahead is moving towards my target
508/// docking position and the ratio of next pilot velocity over my velocity is
509/// less than a threshold (i.e. the next pilot is moving slower than my pilot),
510/// and the distance between the next pilot and my pilot is less than some
511/// fraction of the standard airship spacing, and the distance between my pilot
512/// and my docking target position is greater than the distance between the next
513/// pilot and my docking target position (i.e. the next pilot is inside my
514/// radius from my target docking position).
515fn should_slow_down(
516    my_pilot_pos: &Vec2<f32>,
517    my_pilot_velocity: f32,
518    my_pilot_dist_to_docking_target: f32,
519    next_pilot_pos: &Vec2<f32>,
520    next_pilot_velocity: f32,
521    next_pilot_dist_to_docking_target: f32,
522    next_pilot_to_docking_target_trend: &DistanceTrend,
523) -> bool {
524    *next_pilot_to_docking_target_trend == DistanceTrend::Towards
525        && next_pilot_velocity / my_pilot_velocity < NEXT_PILOT_VELOCITY_RATIO_MIN
526        && my_pilot_pos.distance_squared(*next_pilot_pos)
527            < Airships::AIRSHIP_SPACING.powi(2) * NEXT_PILOT_SPACING_THRESHOLD_SQR
528        && my_pilot_dist_to_docking_target > next_pilot_dist_to_docking_target
529}
530
531/// The normal controller movement action of the airship. Called from
532/// fly_airship_inner() for cases that do not mean the airship is avoiding the
533/// airship ahead of it on the route.
534fn fly_inner_default_goto(
535    ctx: &mut NpcCtx,
536    wpos: Vec3<f32>,
537    speed_factor: f32,
538    height_offset: f32,
539    with_terrain_following: bool,
540    direction_override: Option<Dir>,
541    flight_mode: FlightMode,
542) {
543    let height_offset_opt = if with_terrain_following {
544        Some(height_offset)
545    } else {
546        None
547    };
548    ctx.controller.do_goto_with_height_and_dir(
549        wpos,
550        speed_factor,
551        height_offset_opt,
552        direction_override,
553        flight_mode,
554    );
555}
556
557/// The action that moves the airship.
558fn fly_airship_inner(
559    phase: AirshipFlightPhase,
560    wpos: Vec3<f32>,
561    goal_dist: f32,
562    initial_speed_factor: f32,
563    height_offset: f32,
564    with_terrain_following: bool,
565    direction_override: Option<Dir>,
566    flight_mode: FlightMode,
567    with_collision_avoidance: bool,
568    radar_interval: Duration,
569) -> impl Action<AirshipRouteContext> {
570    just(
571        move |ctx, airship_context: &mut AirshipRouteContext| {
572
573            // It's safe to unwrap here, this function is not called if either airship_context.current_leg_approach
574            // or airship_context.next_leg_approach are None. The pos_tracker_target_loc is for determining the reverse
575            // direction for 'unsticking' the airship if it gets stuck in one place; In DepartureCruise phase, it is the
576            // next_leg_cruise_checkpoint_pos and in ApproachCruise phase, it is the transition pos of the current destination.
577            let (current_approach, pos_tracker_target_loc) =
578                match phase {
579                    AirshipFlightPhase::DepartureCruise => (&airship_context.next_leg_approach.unwrap(), airship_context.next_leg_cruise_checkpoint_pos.xy()),
580                    _ => (&airship_context.current_leg_approach.unwrap(), airship_context.current_leg_approach.unwrap().approach_transition_pos.xy()),
581                };
582
583            // Decrement the avoidance timer. (the timer is always counting down)..
584            // The collision avoidance checks are not done every tick, only
585            // every 1-5 seconds depending on flight phase.
586            let remaining = airship_context
587                .avoidance_timer
588                .checked_sub(Duration::from_secs_f32(ctx.dt));
589            // If it's time for avoidance checks
590            if remaining.is_none() {
591                // reset the timer
592                airship_context.avoidance_timer = radar_interval;
593
594                #[cfg(feature = "airship_log")]
595                // log my position
596                log_airship_position(ctx, &phase);
597
598                // If actually doing avoidance checks..
599                if with_collision_avoidance
600                    && matches!(phase, AirshipFlightPhase::DepartureCruise | AirshipFlightPhase::ApproachCruise)
601                {
602                    // This runs every time the avoidance timer counts down (1-5 seconds)
603
604                    // get my velocity (my_rate_tracker)
605                    // get my distance to my docking target position
606                    // get the next pilot's position
607                    // update next_pilot_trend (movement relative to my docking target position)
608                    // update_next_pilot_average_velocity
609                    // get next pilot's distance to my docking target position
610
611                    let mypos = ctx.npc.wpos;
612                    let my_velocity = if let Some(my_rate_tracker) = &mut airship_context.my_rate_tracker {
613                        my_rate_tracker.update(mypos.xy(), ctx.time.0 as f32)
614                    } else {
615                        0.0
616                    };
617                    let my_distance_to_docking_target = mypos.xy().distance_squared(current_approach.airship_pos.xy());
618
619                    let (next_pilot_wpos, next_pilot_dist_trend) = if let Some(next_pilot_rate_tracker) = &mut airship_context.next_pilot_rate_tracker_
620                        && let Some(next_pilot) = ctx.state.data().npcs.get(airship_context.next_pilot_id)
621                    {
622                        let next_rate = next_pilot_rate_tracker.update(next_pilot.wpos.xy(), ctx.time.0 as f32);
623
624                        // Estimate the distance trend of the next pilot relative to my target docking position.
625                        let next_dist_trend = airship_context
626                            .next_pilot_dist_to_my_docking_pos_tracker.update(next_pilot.wpos.xy(), ctx.time.0)
627                            .unwrap_or(DistanceTrend::Away);
628
629                        // Track the moving average of the velocity of the next pilot ahead of my pilot but only
630                        // if the velocity is greater than the expected simulated cruise speed minus a small tolerance.
631                        // (i.e., when it can be expected that the next pilot is in the cruise phase).
632                        if next_rate > airship_context.simulated_airship_speed - NEXT_PILOT_CRUISE_SPEED_TOLERANCE {
633                            // Scale up the velocity so that the moving average can be done as an integer.
634                            airship_context.next_pilot_average_velocity.add((next_rate as f64 * MOVING_AVERAGE_SCALE_FACTOR) as i64);
635                        }
636
637                        (next_pilot.wpos, next_dist_trend)
638                    } else {
639                        (Vec3::zero(), DistanceTrend::Away)
640                    };
641
642                    let my_pilot_distance_to_next_pilot = mypos.xy().distance_squared(next_pilot_wpos.xy());
643                    let next_pilot_distance_to_docking_target = next_pilot_wpos.xy().distance_squared(current_approach.airship_pos.xy());
644
645                    // What to check is based on the current avoidance mode.
646                    let avoidance = match airship_context.avoid_mode {
647                        AirshipAvoidanceMode::Stuck(..) => {
648                            // currently stuck and backing out.
649                            // Don't change anything until the airship position matches the backout position.
650                            // The current_backout_pos() will return None when the backout position is reached.
651                            if let Some(stuck_tracker) = &mut airship_context.my_stuck_tracker
652                                && stuck_tracker.is_stuck(ctx, &mypos, &pos_tracker_target_loc)
653                                && let Some(backout_pos) = stuck_tracker.current_backout_pos(ctx)
654                            {
655                                AirshipAvoidanceMode::Stuck(backout_pos)
656                            } else {
657                                #[cfg(debug_assertions)]
658                                debug!("{:?} unstuck at pos: {} {}",
659                                    ctx.npc_id,
660                                    mypos.x as i32, mypos.y as i32,
661                                );
662
663                                AirshipAvoidanceMode::None
664                            }
665                        }
666                        AirshipAvoidanceMode::Hold(..) => {
667                            // currently holding position.
668                            // It is assumed that the airship can't get stuck while holding.
669                            // Continue holding until the next pilot is moving away from the docking position.
670                            if next_pilot_dist_trend != DistanceTrend::Away {
671                                airship_context.avoid_mode
672                            } else {
673                                AirshipAvoidanceMode::None
674                            }
675                        }
676                        AirshipAvoidanceMode::Slow(..) => {
677                            // currently slowed down
678                            // My pilot could get stuck or reach the hold criteria while slowed down.
679                            if let Some(stuck_tracker) = &mut airship_context.my_stuck_tracker
680                                && stuck_tracker.is_stuck(ctx, &mypos, &pos_tracker_target_loc)
681                                && let Some(backout_pos) = stuck_tracker.current_backout_pos(ctx)
682                            {
683                                AirshipAvoidanceMode::Stuck(backout_pos)
684                            } else if should_hold(
685                                my_pilot_distance_to_next_pilot,
686                                next_pilot_distance_to_docking_target,
687                                &next_pilot_dist_trend,
688                            ) {
689                                airship_context.did_hold = true;
690                                AirshipAvoidanceMode::Hold(
691                                    mypos,
692                                    // hold with the airship pointing at the dock
693                                    Dir::from_unnormalized((current_approach.dock_center - mypos.xy()).with_z(0.0))
694                                        .unwrap_or_default(),
695                                )
696                            } else {
697                                // Since my pilot is already slowed to slightly slower than the next pilot,
698                                // it's possible that the distance between my pilot and the next pilot could increase
699                                // to above the slow-down threshold, but it's more likely that the next pilot has to dock
700                                // and then start moving away from the docking position before my pilot can
701                                // resume normal speed. If my pilot did not slow down enough, it's possible that the
702                                // next pilot will still be at the dock when my pilot arrives, and that is taken
703                                // care of by the Hold avoidance mode.
704                                // Check if the distance to the next pilot has increased to above Airships::AIRSHIP_SPACING,
705                                // otherwise continue the slow-down until the next pilot is moving away from the docking position.
706                                if next_pilot_dist_trend == DistanceTrend::Away || my_pilot_distance_to_next_pilot > Airships::AIRSHIP_SPACING.powi(2) {
707                                    AirshipAvoidanceMode::None
708                                } else {
709                                    // continue slow down
710                                    airship_context.avoid_mode
711                                }
712                            }
713                        }
714                        AirshipAvoidanceMode::None => {
715                            // not currently avoiding or stuck. Check for all conditions.
716                            let next_pilot_avg_velocity = (airship_context.next_pilot_average_velocity.average().unwrap_or(0) as f64 / MOVING_AVERAGE_SCALE_FACTOR) as f32;
717                            // Check if stuck
718                            if let Some(stuck_tracker) = &mut airship_context.my_stuck_tracker
719                                && stuck_tracker.is_stuck(ctx, &mypos, &pos_tracker_target_loc)
720                                && let Some(backout_pos) = stuck_tracker.current_backout_pos(ctx)
721                            {
722                                #[cfg(debug_assertions)]
723                                debug!("{:?} stuck at pos: {} {}",
724                                    ctx.npc_id,
725                                    mypos.x as i32, mypos.y as i32,
726                                );
727                                AirshipAvoidanceMode::Stuck(backout_pos)
728                                // Check if hold criteria are met.
729                            } else if should_hold(
730                                my_pilot_distance_to_next_pilot,
731                                next_pilot_distance_to_docking_target,
732                                &next_pilot_dist_trend,
733                            ) {
734                                airship_context.did_hold = true;
735                                AirshipAvoidanceMode::Hold(
736                                    mypos,
737                                    Dir::from_unnormalized((current_approach.dock_center - mypos.xy()).with_z(0.0))
738                                        .unwrap_or_default(),
739                                )
740                                // Check if slow down criteria are met.
741                            } else if should_slow_down(
742                                &mypos.xy(),
743                                my_velocity,
744                                my_distance_to_docking_target,
745                                &next_pilot_wpos.xy(),
746                                next_pilot_avg_velocity,
747                                next_pilot_distance_to_docking_target,
748                                &next_pilot_dist_trend
749                            ) {
750                                // My pilot is getting close to the next pilot ahead and should slow down.
751                                // If simulated, slow to CLOSE_AIRSHIP_SPEED_FACTOR.
752                                // If loaded, slow to a percentage of the next pilot's average velocity.
753                                let mut new_speed_factor = if matches!(ctx.npc.mode, SimulationMode::Simulated) {
754                                    // In simulated mode, the next pilot's average velocity is not updated,
755                                    // so we use the simulated airship speed.
756                                    CLOSE_AIRSHIP_SPEED_FACTOR
757                                } else {
758                                    // loaded mode, adjust my speed factor based on a percentage of
759                                    // the next pilot's average velocity.
760                                    let target_velocity: f32 = next_pilot_avg_velocity * CLOSE_AIRSHIP_SPEED_FACTOR;
761
762                                    // TODO: Document the speed factor calculation in the airship evolutions RFC.
763                                    // Set my speed factor according to the target velocity.
764                                    // speed_factor = (0.5 * air_density * velocity ^ 2 * reference_area)/(thrust * 0.9)
765                                    //              = 0.45 * air_density * velocity ^ 2 * reference_area / thrust
766                                    // where:
767                                    //      air_density is a constant
768                                    //      reference_area is a constant per airship model
769                                    //      thrust is a constant per airship model
770                                    // The airship_context.speed_factor_conversion_factor is a constant value equal to
771                                    // 0.45 * air_density * reference_area / thrust
772                                    // and the estimated speed factor is calculated as:
773                                    // speed_factor = airship_context.speed_factor_conversion_factor * target_velocity ^ 2
774                                    let new_sf = airship_context.speed_factor_conversion_factor * target_velocity.powi(2);
775                                    if new_sf > 0.0 {
776                                        #[cfg(debug_assertions)]
777                                        debug!(
778                                            "Pilot {:?}: Adjusting speed factor to {}, next pilot avg velocity: {}, * speed_factor = {}",
779                                            ctx.npc_id,
780                                            new_sf,
781                                            next_pilot_avg_velocity,
782                                            target_velocity,
783                                        );
784                                    }
785                                    new_sf
786                                };
787
788                                if new_speed_factor < 0.05 {
789                                    warn!("Pilot {:?} calculated slow down speed factor is too low, clamping to 0.05", ctx.npc_id);
790                                    new_speed_factor = 0.05;
791                                }
792                                AirshipAvoidanceMode::Slow(
793                                    new_speed_factor
794                                )
795                            } else {
796                                AirshipAvoidanceMode::None
797                            }
798                        }
799                    };
800
801                    // If the avoidance mode has changed, update the airship context.
802                    if avoidance != airship_context.avoid_mode {
803                        airship_context.avoid_mode = avoidance;
804                    }
805                }
806            } else {
807                // counting down
808                airship_context.avoidance_timer = remaining.unwrap_or(radar_interval);
809            }
810
811            // Every time through the loop, move the airship according to the avoidance mode.
812            match airship_context.avoid_mode {
813                AirshipAvoidanceMode::Stuck(backout_pos) => {
814                    // Unstick the airship
815                    ctx.controller.do_goto_with_height_and_dir(
816                        backout_pos,
817                        1.5,
818                        None,
819                        None,
820                        FlightMode::Braking(BrakingMode::Normal),
821                    );
822                }
823                AirshipAvoidanceMode::Hold(hold_pos, hold_dir) => {
824                    // Hold position
825                    airship_context.hold_timer -= ctx.dt;
826                    if airship_context.hold_timer <= 0.0 {
827                        if !airship_context.hold_announced {
828                            airship_context.hold_announced = true;
829                            ctx.controller
830                                .say(None, Content::localized("npc-speech-pilot-announce_hold"));
831                        } else {
832                            ctx.controller
833                                .say(None, Content::localized("npc-speech-pilot-continue_hold"));
834                        }
835                        airship_context.hold_timer = ctx.rng.gen_range(10.0..20.0);
836                    }
837                    // Hold position (same idea as holding station at the dock except allow
838                    // oscillations)
839                    let hold_pos = if matches!(ctx.npc.mode, SimulationMode::Simulated) {
840                        hold_pos
841                    } else {
842                        // Airship is loaded, add some randomness to the hold position
843                        // so that the airship doesn't look like it's stuck in one place.
844                        // This also keeps the propellers spinning slowly and somewhat randomly.
845                        hold_pos
846                            + (Vec3::new(0.7, 0.8, 0.9).map(|e| (e * ctx.time.0).sin())
847                                * Vec3::new(5.0, 5.0, 10.0))
848                            .map(|e| e as f32)
849                    };
850
851                    // Holding position
852                    ctx.controller.do_goto_with_height_and_dir(
853                        hold_pos,
854                        0.25,
855                        None,
856                        Some(hold_dir),
857                        FlightMode::Braking(BrakingMode::Normal),
858                    );
859                }
860                AirshipAvoidanceMode::Slow(speed_factor) => {
861                    // Slow down mode. Only change from normal movement is the speed factor.
862                    fly_inner_default_goto(ctx, wpos, speed_factor, height_offset,
863                        with_terrain_following, direction_override, flight_mode,
864                    );
865                }
866                AirshipAvoidanceMode::None => {
867                    // Normal movement, no avoidance.
868                    fly_inner_default_goto(ctx, wpos, initial_speed_factor, height_offset,
869                        with_terrain_following, direction_override, flight_mode,
870                    );
871                }
872            }
873        },
874    )
875    .repeat()
876    .boxed()
877    .stop_if(move |ctx: &mut NpcCtx| {
878        if flight_mode == FlightMode::FlyThrough {
879            // we only care about the xy distance (just get close to the target position)
880            ctx.npc.wpos.xy().distance_squared(wpos.xy()) < goal_dist.powi(2)
881        } else {
882            // Braking mode means the PID controller will be controlling all three axes
883            ctx.npc.wpos.distance_squared(wpos) < goal_dist.powi(2)
884        }
885    })
886    .debug(move || {
887        format!(
888            "fly airship, phase:{:?}, tgt pos:({}, {}, {}), goal dist:{}, speed:{}, height:{}, \
889             terrain following:{}, FlightMode:{:?}, collision avoidance:{}, radar interval:{}",
890            phase,
891            wpos.x,
892            wpos.y,
893            wpos.z,
894            goal_dist,
895            initial_speed_factor,
896            height_offset,
897            with_terrain_following,
898            flight_mode,
899            with_collision_avoidance,
900            radar_interval.as_secs_f32()
901        )
902    })
903    .map(|_, _| ())
904}
905
906/// Get the target position for airship movement given the target position, the
907/// default height above terrain, and the height above terrain for the airship
908/// route cruise phase. This samples terrain points aound the target pos to get
909/// the maximum terrain altitude in a 200 block radius of the target position
910/// (only checking 4 cardinal directions). and returns the input approach_pos
911/// with z equal to the maximum terrain alt + height or the default_alt
912/// whichever is greater.
913fn approach_target_pos(
914    ctx: &mut NpcCtx,
915    approach_pos: Vec2<f32>,
916    default_alt: f32,
917    height: f32,
918) -> Vec3<f32> {
919    // sample 4 terrain altitudes around the final approach point and take the max.
920    let max_alt = CARDINALS
921        .iter()
922        .map(|rpos| {
923            ctx.world
924                .sim()
925                .get_surface_alt_approx(approach_pos.as_() + rpos * 200)
926        })
927        .max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
928        .unwrap_or(default_alt);
929    approach_pos.with_z(max_alt + height)
930}
931
932/// The NPC is the airship captain. This action defines the flight loop for the
933/// airship. The captain NPC is autonomous and will fly the airship along the
934/// assigned route. The routes are established and assigned to the captain NPCs
935/// when the world is generated.
936pub fn pilot_airship<S: State>() -> impl Action<S> {
937    now(move |ctx, route_context: &mut AirshipRouteContext| {
938        // get the assigned route and start leg indexes
939        if let Some((route_index, start_leg_index)) = ctx.state.data().airship_sim.assigned_routes.get(&ctx.npc_id)
940        {
941            // ----- Server startup processing -----
942            // If route_context.route_index is the default value (usize::MAX) it means the server has just started.
943
944            if route_context.route_index == usize::MAX {
945                // This block should only run once, when the server starts up.
946                // Set up the route context fixed values.
947                route_context.route_index = *route_index;
948                route_context.next_leg = *start_leg_index;
949                if let Some (next_pilot) = ctx.state.data().airship_sim.next_pilot(*route_index, ctx.npc_id) {
950                    route_context.next_pilot_id = next_pilot;
951                } else {
952                    route_context.next_pilot_id = NpcId::default();
953                }
954                let (max_speed, reference_area, thrust) =
955                    ctx.state.data().npcs.mounts.get_mount_link(ctx.npc_id)
956                        .map(|mount_link|
957                            ctx.state.data().npcs.get(mount_link.mount)
958                                .map(|airship| {
959                                    (airship.body.max_speed_approx(),
960                                     airship.body.parasite_drag(1.0),
961                                     airship.body.fly_thrust().unwrap_or_default())
962                                })
963                                .unwrap_or_default()
964                        ).or({
965                            // If the mount link is not found, use a default speed.
966                            Some((0.0, 1.0, 1.0))
967                        })
968                        .unwrap_or((0.0, 1.0, 1.0));
969                route_context.simulated_airship_speed = max_speed;
970                route_context.speed_factor_conversion_factor = 0.45 * AIR_DENSITY * reference_area / thrust;
971                route_context.my_rate_tracker = Some(RateTracker::default());
972                route_context.next_pilot_rate_tracker_ = Some(RateTracker::default());
973
974                #[cfg(debug_assertions)]
975                {
976                    let current_approach = ctx.world.civs().airships.approach_for_route_and_leg(
977                        route_context.route_index,
978                        route_context.next_leg,
979                    );
980                    debug!(
981                        "Server startup, airship pilot {:?} starting on route {} and leg {}, target dock: {} {}, following pilot {:?}",
982                        ctx.npc_id,
983                        route_context.route_index,
984                        route_context.next_leg,
985                        current_approach.airship_pos.x as i32, current_approach.airship_pos.y as i32,
986                        route_context.next_pilot_id
987                    );
988                }
989                if route_context.next_pilot_id == NpcId::default() {
990                    tracing::error!("Pilot {:?} has no next pilot to follow.", ctx.npc_id);
991                }
992            }
993
994            // ----- Each time entering pilot_airship -----
995
996            // set the approach data for the current leg
997            // Needed: docking position and direction.
998            route_context.current_leg_approach = Some(ctx.world.civs().airships.approach_for_route_and_leg(
999                route_context.route_index,
1000                route_context.next_leg,
1001            ));
1002            // Increment the leg index with wrap around
1003            route_context.next_leg = ctx.world.civs().airships.increment_route_leg(
1004                route_context.route_index,
1005                route_context.next_leg,
1006            );
1007            // set the approach data for the next leg
1008            // Needed: transition position for next leg.
1009            route_context.next_leg_approach = Some(ctx.world.civs().airships.approach_for_route_and_leg(
1010                route_context.route_index,
1011                route_context.next_leg,
1012            ));
1013
1014            if route_context.current_leg_approach.is_none() ||
1015                route_context.next_leg_approach.is_none()
1016            {
1017                tracing::error!(
1018                    "Airship pilot {:?} approaches not found for route {} leg {}, stopping pilot_airship loop.",
1019                    ctx.npc_id,
1020                    route_context.route_index,
1021                    route_context.next_leg
1022                );
1023                return finish().map(|_, _| ()).boxed();
1024            }
1025
1026            // unwrap is safe
1027            let current_approach = route_context.current_leg_approach.unwrap();
1028            let next_approach = route_context.next_leg_approach.unwrap();
1029
1030            let next_leg_cruise_dir = (next_approach.approach_transition_pos.xy() - current_approach.airship_pos.xy()).normalized();
1031            route_context.next_leg_cruise_checkpoint_pos = (next_approach.approach_transition_pos - next_leg_cruise_dir * CRUISE_CHECKPOINT_DISTANCE).with_z(next_approach.approach_transition_pos.z);
1032            // The terrain height at the cruise checkpoint may be different from the terrain height at the docking position.
1033            // The approach_target_pos function will sample the terrain around the cruise checkpoint and return the position with z adjusted
1034            // to the maximum terrain height around the cruise checkpoint plus the approach cruise height.
1035            route_context.next_leg_cruise_checkpoint_pos = approach_target_pos(ctx, route_context.next_leg_cruise_checkpoint_pos.xy(), route_context.next_leg_cruise_checkpoint_pos.z, next_approach.height);
1036
1037            // Track the next pilot distance trend relative to my current approach docking position.
1038            route_context.next_pilot_dist_to_my_docking_pos_tracker.reset(current_approach.airship_pos.xy());
1039
1040            // Use a function to determine the speed factor based on the simulation mode. The simulation mode
1041            // could change at any time as world chunks are loaded or unloaded.
1042            let speed_factor_fn = |sim_mode: SimulationMode, speed_factor: f32| {
1043                // The speed factor for simulated airships is always 1.0
1044                if matches!(sim_mode, SimulationMode::Simulated) {
1045                    speed_factor.powf(0.3)
1046                } else {
1047                    speed_factor
1048                }
1049            };
1050
1051            // ----- Phases of flight - One Leg of the Route -----
1052
1053            // At this point, the airship is somewhere in the cruise phase of the current route leg.
1054            // Normally, the airship will be at the cruise checkpoint, fairly close to the docking site.
1055            // When the server first starts, the airship will be at its spawn point, somewhere along the route
1056            // and heading for the cruise checkpoint.
1057
1058            // Fly 2D to Destination Transition Point with frequent radar checks
1059            fly_airship(
1060                AirshipFlightPhase::ApproachCruise,
1061                current_approach.approach_transition_pos,
1062                50.0,
1063                speed_factor_fn(ctx.npc.mode, 1.0),
1064                current_approach.height,
1065                true,
1066                None,
1067                FlightMode::FlyThrough,
1068                true,
1069                Duration::from_secs_f32(1.0),
1070            )
1071            .then(
1072                // Fly 3D to directly above the docking position, full PID control
1073                fly_airship(
1074                    AirshipFlightPhase::Transition,
1075                    current_approach.airship_pos + Vec3::unit_z() * current_approach.height,
1076                    20.0,
1077                    speed_factor_fn(ctx.npc.mode, 0.4),
1078                    current_approach.height,
1079                    true,
1080                    Some(current_approach.airship_direction),
1081                    FlightMode::Braking(BrakingMode::Normal),
1082                    false,
1083                    Duration::from_secs_f32(2.0),
1084            ))
1085            // Descend and Dock
1086            //    Docking
1087            //      Drop to 125 blocks above the dock with slightly looser controller settings
1088            //      then to the docking position with full precision control.
1089            //      This helps to ensure that the airship docks vertically and avoids collisions
1090            //      with other airships and the dock. The speed_factor is high to
1091            //      give a strong response to the PID controller. The speed_factor is reduced
1092            //      once docked to stop the airship propellers from rotating.
1093            //      Vary the timeout to get variation in the docking sequence.
1094            .then(
1095                just(|ctx: &mut NpcCtx, _| {
1096                    log_airship_position(ctx, &AirshipFlightPhase::Transition);
1097                }))
1098
1099            .then(
1100                // descend to 125 blocks above the dock
1101                just(move |ctx, _| {
1102                    ctx.controller
1103                        .do_goto_with_height_and_dir(
1104                            current_approach.airship_pos + Vec3::unit_z() * 125.0,
1105                            speed_factor_fn(ctx.npc.mode, 0.9),
1106                            None,
1107                            Some(current_approach.airship_direction),
1108                            FlightMode::Braking(BrakingMode::Normal),
1109                        );
1110                })
1111                .repeat()
1112                .stop_if(timeout(ctx.rng.gen_range(12.5..16.0) * (current_approach.height as f64 / Airships::CRUISE_HEIGHTS[0] as f64) * 1.3)))
1113            .then(
1114                just(|ctx: &mut NpcCtx, _| {
1115                    log_airship_position(ctx, &AirshipFlightPhase::Transition);
1116                }))
1117            .then(
1118                // descend to just above the docking position
1119                just(move |ctx: &mut NpcCtx, _| {
1120                    ctx.controller
1121                        .do_goto_with_height_and_dir(
1122                            current_approach.airship_pos + Vec3::unit_z() * 20.0,
1123                            speed_factor_fn(ctx.npc.mode, 0.9),
1124                            None,
1125                            Some(current_approach.airship_direction),
1126                            FlightMode::Braking(BrakingMode::Precise),
1127                        );
1128                })
1129                .repeat()
1130                .stop_if(timeout(ctx.rng.gen_range(6.5..8.0))))
1131            // Announce arrival
1132            .then(just(|ctx: &mut NpcCtx, _| {
1133                log_airship_position(ctx, &AirshipFlightPhase::Docked);
1134                ctx.controller
1135                    .say(None, Content::localized("npc-speech-pilot-landed"));
1136            }))
1137
1138            // Docked - Wait at Dock
1139            .then(
1140                now(move |ctx, route_context:&mut AirshipRouteContext| {
1141                    // The extra time to hold at the dock is a route_context variable.
1142                    // Adjust the extra time based on the airship's behavior during the previous
1143                    // flight. If the airship had to hold position, add 30 seconds to the dock time.
1144                    // If the airship did not hold position, subtract 45 seconds from the dock time
1145                    // because we want to reverse the extra penalty faster than building it up in case
1146                    // the airship transitions to simulation mode.
1147                    // If the airship had to slow down add 15 and if not subtract 20.
1148                    if route_context.did_hold {
1149                        route_context.extra_hold_dock_time += 30.0;
1150                    } else if route_context.extra_hold_dock_time > 45.0 {
1151                        route_context.extra_hold_dock_time -= 45.0;
1152                    } else {
1153                        route_context.extra_hold_dock_time = 0.0;
1154                    }
1155
1156                    let docking_time = route_context.extra_hold_dock_time + Airships::docking_duration();
1157                    #[cfg(debug_assertions)]
1158                    {
1159                        if route_context.did_hold || docking_time > Airships::docking_duration() {
1160                            let docked_site_name = ctx.index.sites.get(current_approach.site_id).name().to_string();
1161                            debug!("{}, Docked at {}, did_hold:{}, extra_hold_dock_time:{}, docking_time:{}", format!("{:?}", ctx.npc_id), docked_site_name, route_context.did_hold, route_context.extra_hold_dock_time, docking_time);
1162                        }
1163                    }
1164                    route_context.did_hold = false;
1165
1166                    just(move |ctx, _| {
1167                        ctx.controller
1168                        .do_goto_with_height_and_dir(
1169                            current_approach.airship_pos,
1170                            speed_factor_fn(ctx.npc.mode, 0.75),
1171                            None,
1172                            Some(current_approach.airship_direction),
1173                            FlightMode::Braking(BrakingMode::Precise),
1174                        );
1175                    })
1176                    .repeat()
1177                    .stop_if(timeout(ctx.rng.gen_range(10.0..16.0)))
1178                    // While waiting, every now and then announce where the airship is going next.
1179                    .then(
1180                        just(move |ctx, _| {
1181                            // get the name of the site the airship is going to next.
1182                            // The route_context.current_approach_index has not been switched yet,
1183                            // so the index is the opposite of the current approach index.
1184                            let next_site_name = ctx.index.sites.get(next_approach.site_id).name().to_string();
1185                            ctx.controller.say(
1186                                None,
1187                                Content::localized_with_args("npc-speech-pilot-announce_next", [
1188                                (
1189                                    "dir",
1190                                    Direction::from_dir((next_approach.approach_transition_pos - ctx.npc.wpos).xy()).localize_npc(),
1191                                ),
1192                                ("dst", Content::Plain(next_site_name.to_string())),
1193                                ]),
1194                            );
1195                        })
1196                    )
1197                    .repeat()
1198                    .stop_if(timeout(docking_time as f64))
1199                })
1200            ).then(
1201                // Announce takeoff
1202                just(move |ctx, route_context:&mut AirshipRouteContext| {
1203                    ctx.controller.say(
1204                    None,
1205                        Content::localized_with_args("npc-speech-pilot-takeoff", [
1206                            ("src", Content::Plain(ctx.index.sites.get(current_approach.site_id).name().to_string())),
1207                            ("dst", Content::Plain(ctx.index.sites.get(next_approach.site_id).name().to_string())),
1208                        ]),
1209                    );
1210                    // This is when the airship target docking position changes to the next approach.
1211                    // Reset the next pilot distance trend tracker.
1212                    route_context.next_pilot_dist_to_my_docking_pos_tracker.reset(next_approach.airship_pos.xy());
1213                    log_airship_position(ctx, &AirshipFlightPhase::Ascent);
1214                })
1215            ).then(
1216                // Take off, full PID control
1217                fly_airship(
1218                    AirshipFlightPhase::Ascent,
1219                    current_approach.airship_pos + Vec3::unit_z() * 100.0,
1220                    20.0,
1221                    speed_factor_fn(ctx.npc.mode, 0.2),
1222                    0.0,
1223                    false,
1224                    Some(current_approach.airship_direction),
1225                    FlightMode::Braking(BrakingMode::Normal),
1226                    false,
1227                    Duration::from_secs_f32(120.0),
1228                )
1229            ).then(
1230                // Ascend to Cruise Alt, full PID control
1231                fly_airship(
1232                    AirshipFlightPhase::Ascent,
1233                    current_approach.airship_pos + Vec3::unit_z() * current_approach.height,
1234                    20.0,
1235                    speed_factor_fn(ctx.npc.mode, 0.4),
1236                    0.0,
1237                    false,
1238                    Some(Dir::from_unnormalized((next_approach.approach_transition_pos - ctx.npc.wpos).xy().with_z(0.0)).unwrap_or_default()),
1239                    FlightMode::Braking(BrakingMode::Normal),
1240                    false,
1241                    Duration::from_secs_f32(120.0),
1242                )
1243            ).then(
1244                // Fly 2D to Destination Cruise Checkpoint with infrequent radar checks
1245                fly_airship(
1246                    AirshipFlightPhase::DepartureCruise,
1247                    route_context.next_leg_cruise_checkpoint_pos,
1248                    50.0,
1249                    speed_factor_fn(ctx.npc.mode, 1.0),
1250                    next_approach.height,
1251                    true,
1252                    None,
1253                    FlightMode::FlyThrough,
1254                    true,
1255                    Duration::from_secs_f32(5.0),
1256                )
1257            )
1258            .map(|_, _| ()).boxed()
1259        } else {
1260            //  There are no routes assigned.
1261            //  This is unexpected and never happens in testing, just do nothing so the compiler doesn't complain.
1262            finish().map(|_, _| ()).boxed()
1263        }
1264    })
1265    .repeat()
1266    .with_state(AirshipRouteContext::default())
1267    .map(|_, _| ())
1268}
1269
1270#[cfg(feature = "airship_log")]
1271/// Get access to the global airship logger and log an airship position.
1272fn log_airship_position(ctx: &NpcCtx, phase: &AirshipFlightPhase) {
1273    if let Ok(mut logger) = airship_logger() {
1274        logger.log_position(
1275            ctx.npc_id,
1276            ctx.index.seed,
1277            phase,
1278            ctx.time.0,
1279            ctx.npc.wpos,
1280            matches!(ctx.npc.mode, SimulationMode::Loaded),
1281        );
1282    } else {
1283        warn!("Failed to log airship position for {:?}", ctx.npc_id);
1284    }
1285}
1286
1287#[cfg(not(feature = "airship_log"))]
1288/// When the logging feature is not enabled, this should become a no-op.
1289fn log_airship_position(_: &NpcCtx, _: &AirshipFlightPhase) {}
1290
1291#[cfg(test)]
1292mod tests {
1293    use super::{DistanceTrend, DistanceTrendTracker, MovingAverage};
1294    use vek::*;
1295
1296    #[test]
1297    fn moving_average_test() {
1298        let mut ma: MovingAverage<f32, 5, 3> = MovingAverage::default();
1299        ma.add(1.0);
1300        ma.add(2.0);
1301        ma.add(3.0);
1302        ma.add(4.0);
1303        ma.add(5.0);
1304        assert_eq!(ma.average().unwrap(), 3.0);
1305
1306        ma.add(6.0); // This will remove the first value (1.0)
1307        assert_eq!(ma.average().unwrap(), 4.0);
1308
1309        ma.add(7.0); // This will remove the second value (2.0)
1310        assert_eq!(ma.average().unwrap(), 5.0);
1311
1312        ma.add(8.0); // This will remove the third value (3.0)
1313        assert_eq!(ma.average().unwrap(), 6.0);
1314
1315        ma.add(9.0); // This will remove the fourth value (4.0)
1316        assert_eq!(ma.average().unwrap(), 7.0);
1317
1318        ma.add(10.0); // This will remove the fifth value (5.0)
1319        assert_eq!(ma.average().unwrap(), 8.0);
1320
1321        let mut ma2: MovingAverage<i64, 5, 3> = MovingAverage::default();
1322        ma2.add((1000.0f32 / 1000.0) as i64);
1323        ma2.add((2000.0f32 / 1000.0) as i64);
1324        ma2.add((3000.0f32 / 1000.0) as i64);
1325        ma2.add((4000.0f32 / 1000.0) as i64);
1326        ma2.add((5000.0f32 / 1000.0) as i64);
1327        assert_eq!(ma2.average().unwrap(), 3);
1328
1329        ma2.add((6000.0f32 / 1000.0) as i64);
1330        assert_eq!(ma2.average().unwrap(), 4);
1331
1332        ma2.add((7000.0f32 / 1000.0) as i64);
1333        assert_eq!(ma2.average().unwrap(), 5);
1334
1335        ma2.add((8000.0f32 / 1000.0) as i64);
1336        assert_eq!(ma2.average().unwrap(), 6);
1337
1338        ma2.add((9000.0f32 / 1000.0) as i64);
1339        assert_eq!(ma2.average().unwrap(), 7);
1340
1341        ma2.add((10000.0f32 / 1000.0) as i64);
1342        assert_eq!(ma2.average().unwrap(), 8);
1343
1344        let mut ma3: MovingAverage<i64, 5, 3> = MovingAverage::default();
1345        ma3.add((20.99467f32 * 10000.0) as i64);
1346        ma3.add((20.987871f32 * 10000.0) as i64);
1347        ma3.add((20.69861f32 * 10000.0) as i64);
1348        ma3.add((20.268217f32 * 10000.0) as i64);
1349        ma3.add((20.230164f32 * 10000.0) as i64);
1350        assert!((ma3.average().unwrap() as f64 / 10000.0 - 20.6358).abs() < 0.0001);
1351
1352        ma3.add((20.48151f32 * 10000.0) as i64);
1353        assert!((ma3.average().unwrap() as f64 / 10000.0 - 20.5332).abs() < 0.0001);
1354
1355        ma3.add((20.568598f32 * 10000.0) as i64);
1356        assert!((ma3.average().unwrap() as f64 / 10000.0 - 20.4493).abs() < 0.0001);
1357
1358        ma3.add((20.909971f32 * 10000.0) as i64);
1359        assert!((ma3.average().unwrap() as f64 / 10000.0 - 20.4916).abs() < 0.0001);
1360
1361        ma3.add((21.014437f32 * 10000.0) as i64);
1362        assert!((ma3.average().unwrap() as f64 / 10000.0 - 20.6408).abs() < 0.0001);
1363
1364        ma3.add((20.62308f32 * 10000.0) as i64);
1365        assert!((ma3.average().unwrap() as f64 / 10000.0 - 20.7194).abs() < 0.0001);
1366    }
1367
1368    #[test]
1369    fn distance_trend_tracker_test() {
1370        let mut tracker: DistanceTrendTracker<0> = DistanceTrendTracker::default();
1371        tracker.reset(Vec2::new(0.0, 0.0));
1372        assert!(tracker.update(Vec2::new(1.0, 0.0), 1.0).is_none());
1373        assert!(tracker.update(Vec2::new(2.0, 0.0), 2.0).is_none());
1374        assert!(matches!(
1375            tracker.update(Vec2::new(3.0, 0.0), 3.0).unwrap(),
1376            DistanceTrend::Away
1377        ));
1378        assert!(matches!(
1379            tracker.update(Vec2::new(4.0, 0.0), 4.0).unwrap(),
1380            DistanceTrend::Away
1381        ));
1382        assert!(matches!(
1383            tracker.update(Vec2::new(5.0, 0.0), 5.0).unwrap(),
1384            DistanceTrend::Away
1385        ));
1386
1387        tracker.reset(Vec2::new(0.0, 0.0));
1388        assert!(tracker.update(Vec2::new(5.0, 0.0), 1.0).is_none());
1389        assert!(tracker.update(Vec2::new(4.0, 0.0), 2.0).is_none());
1390        assert!(matches!(
1391            tracker.update(Vec2::new(3.0, 0.0), 3.0).unwrap(),
1392            DistanceTrend::Towards
1393        ));
1394        assert!(matches!(
1395            tracker.update(Vec2::new(2.0, 0.0), 4.0).unwrap(),
1396            DistanceTrend::Towards
1397        ));
1398        assert!(matches!(
1399            tracker.update(Vec2::new(1.0, 0.0), 5.0).unwrap(),
1400            DistanceTrend::Towards
1401        ));
1402        assert!(matches!(
1403            tracker.update(Vec2::new(0.0, 0.0), 6.0).unwrap(),
1404            DistanceTrend::Towards
1405        ));
1406        assert!(matches!(
1407            tracker.update(Vec2::new(-1.0, 0.0), 7.0).unwrap(),
1408            DistanceTrend::Towards
1409        ));
1410        assert!(matches!(
1411            tracker.update(Vec2::new(-2.0, 0.0), 8.0).unwrap(),
1412            DistanceTrend::Away
1413        ));
1414        assert!(matches!(
1415            tracker.update(Vec2::new(-3.0, 0.0), 9.0).unwrap(),
1416            DistanceTrend::Away
1417        ));
1418
1419        let mut tracker2: DistanceTrendTracker<5> = DistanceTrendTracker::default();
1420        assert!(tracker2.update(Vec2::new(100.0, 100.0), 10.0).is_none());
1421        assert!(tracker2.update(Vec2::new(100.0, 200.0), 20.0).is_none());
1422        assert!(matches!(
1423            tracker2.update(Vec2::new(100.0, 300.0), 30.0).unwrap(),
1424            DistanceTrend::Away
1425        ));
1426        assert!(matches!(
1427            tracker2.update(Vec2::new(100.0, 400.0), 40.0).unwrap(),
1428            DistanceTrend::Away
1429        ));
1430        assert!(matches!(
1431            tracker2.update(Vec2::new(100.0, 500.0), 50.0).unwrap(),
1432            DistanceTrend::Away
1433        ));
1434        assert!(matches!(
1435            tracker2.update(Vec2::new(100.0, 490.0), 60.0).unwrap(),
1436            DistanceTrend::Away
1437        ));
1438        assert!(matches!(
1439            tracker2.update(Vec2::new(100.0, 505.0), 70.0).unwrap(),
1440            DistanceTrend::Neutral
1441        ));
1442        assert!(matches!(
1443            tracker2.update(Vec2::new(100.0, 500.0), 80.0).unwrap(),
1444            DistanceTrend::Neutral
1445        ));
1446        assert!(matches!(
1447            tracker2.update(Vec2::new(100.0, 495.0), 90.0).unwrap(),
1448            DistanceTrend::Neutral
1449        ));
1450        assert!(matches!(
1451            tracker2.update(Vec2::new(100.0, 500.0), 100.0).unwrap(),
1452            DistanceTrend::Neutral
1453        ));
1454        assert!(matches!(
1455            tracker2.update(Vec2::new(100.0, 495.0), 110.0).unwrap(),
1456            DistanceTrend::Neutral
1457        ));
1458        assert!(matches!(
1459            tracker2.update(Vec2::new(100.0, 500.0), 120.0).unwrap(),
1460            DistanceTrend::Neutral
1461        ));
1462        assert!(matches!(
1463            tracker2.update(Vec2::new(100.0, 505.0), 130.0).unwrap(),
1464            DistanceTrend::Neutral
1465        ));
1466        assert!(matches!(
1467            tracker2.update(Vec2::new(100.0, 500.0), 140.0).unwrap(),
1468            DistanceTrend::Neutral
1469        ));
1470        assert!(matches!(
1471            tracker2.update(Vec2::new(100.0, 505.0), 150.0).unwrap(),
1472            DistanceTrend::Neutral
1473        ));
1474    }
1475}