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