veloren_rtsim/rule/npc_ai/
airship_ai.rs

1use crate::{
2    ai::{Action, NpcCtx, State, finish, just, now, predicate::timeout},
3    data::npc::SimulationMode,
4};
5use common::{
6    comp::{
7        Content,
8        agent::{BrakingMode, FlightMode},
9        compass::Direction,
10    },
11    rtsim::NpcId,
12    store::Id,
13    util::Dir,
14};
15use rand::prelude::*;
16use std::{cmp::Ordering, time::Duration};
17use vek::*;
18use world::{
19    civ::airship_travel::{AirshipDockingApproach, Airships},
20    site::Site,
21    util::{CARDINALS, DHashMap},
22};
23
24#[cfg(debug_assertions)] use tracing::debug;
25
26/// Airships can slow down or hold position to avoid collisions with other
27/// airships.
28#[derive(Debug, Copy, Clone, Default, PartialEq)]
29enum AirshipAvoidanceMode {
30    #[default]
31    None,
32    Hold(Vec3<f32>, Dir),
33    SlowDown,
34    Stuck(Vec3<f32>),
35}
36
37// Context data for the airship route.
38// This is the context data for the pilot_airship action.
39#[derive(Debug, Default, Clone)]
40struct AirshipRouteContext {
41    // The current approach index, 0 1 or none
42    current_approach_index: Option<usize>,
43    // The route's site ids.
44    site_ids: Option<[Id<Site>; 2]>,
45
46    // For fly_airship action
47    // For determining the velocity and direction of this and other airships on the route.
48    trackers: DHashMap<NpcId, ZoneDistanceTracker>,
49    // For tracking the airship's position history to determine if the airship is stuck.
50    pos_trackers: DHashMap<NpcId, PositionTracker>,
51    // Timer for checking the airship trackers.
52    avoidance_timer: Duration,
53    // Timer used when holding, either on approach or at the dock.
54    hold_timer: f32,
55    // Whether the initial hold message has been sent to the client.
56    hold_announced: bool,
57    // The original speed factor passed to the fly_airship action.
58    speed_factor: f32,
59    // The current avoidance mode for the airship.
60    avoid_mode: AirshipAvoidanceMode,
61    // Whether the airship had to hold during the last flight.
62    did_hold: bool,
63    // number of times the airship had to slow down during the last flight.
64    slow_count: u32,
65    // The extra docking time due to holding.
66    extra_hold_dock_time: f32,
67    // The extra docking time due to holding.
68    extra_slowdown_dock_time: f32,
69}
70
71/// Tracks the airship position history.
72/// Used for determining if an airship is stuck.
73#[derive(Debug, Default, Clone)]
74struct PositionTracker {
75    // The airship's position history. Used for determining if the airship is stuck in one place.
76    pos_history: Vec<Vec3<f32>>,
77    // The route to follow for backing out of a stuck position.
78    backout_route: Vec<Vec3<f32>>,
79}
80
81impl PositionTracker {
82    const BACKOUT_TARGET_DIST: f32 = 50.0;
83    const MAX_POS_HISTORY_SIZE: usize = 5;
84
85    // Add a new position to the position history, maintaining a fixed size.
86    fn add_position(&mut self, new_pos: Vec3<f32>) {
87        if self.pos_history.len() >= PositionTracker::MAX_POS_HISTORY_SIZE {
88            self.pos_history.remove(0);
89        }
90        self.pos_history.push(new_pos);
91    }
92
93    // Check if the airship is stuck in one place.
94    fn is_stuck(&mut self, ctx: &mut NpcCtx, approach: &AirshipDockingApproach) -> bool {
95        // The position history must be full to determine if the airship is stuck.
96        if self.pos_history.len() == PositionTracker::MAX_POS_HISTORY_SIZE
97            && self.backout_route.is_empty()
98        {
99            if let Some(last_pos) = self.pos_history.last() {
100                // If all the positions in the history are within 10 of the last position,
101                if self
102                    .pos_history
103                    .iter()
104                    .all(|pos| pos.distance_squared(*last_pos) < 10.0)
105                {
106                    // Airship is stuck on some obstacle.
107                    // If current position is near the ground, backout while gaining altitude
108                    // else backout at the current height and then ascend to clear the obstacle.
109                    // This function is only called in cruise phase, so the direction to backout
110                    // will be the opposite of the direction from the current pos to the approach
111                    // initial pos.
112                    if let Some(backout_dir) = (ctx.npc.wpos.xy() - approach.approach_initial_pos)
113                        .with_z(0.0)
114                        .try_normalized()
115                    {
116                        let ground = ctx
117                            .world
118                            .sim()
119                            .get_surface_alt_approx(last_pos.xy().map(|e| e as i32));
120                        let mut backout_pos = ctx.npc.wpos + backout_dir * 100.0;
121                        if (ctx.npc.wpos.z - ground).abs() < 10.0 {
122                            backout_pos.z += 50.0;
123                        }
124                        self.backout_route =
125                            vec![backout_pos, backout_pos + Vec3::unit_z() * 200.0];
126                        // The airship is stuck.
127                        #[cfg(debug_assertions)]
128                        debug!(
129                            "Airship {} Stuck! at {:?}, backout_dir:{:?}, backout_pos:{:?}",
130                            format!("{:?}", ctx.npc_id),
131                            ctx.npc.wpos,
132                            backout_dir,
133                            backout_pos
134                        );
135                    }
136                }
137            }
138        }
139        !self.backout_route.is_empty()
140    }
141
142    fn next_backout_pos(&mut self, ctx: &mut NpcCtx) -> Option<Vec3<f32>> {
143        if let Some(pos) = self.backout_route.first().cloned() {
144            if ctx.npc.wpos.distance_squared(pos) < PositionTracker::BACKOUT_TARGET_DIST.powi(2) {
145                self.backout_route.remove(0);
146            }
147            Some(pos)
148        } else {
149            self.pos_history.clear();
150            None
151        }
152    }
153}
154
155#[derive(Debug, PartialEq)]
156enum DistanceTrend {
157    ApproachingDock,
158    DepartingDock,
159    Docked,
160}
161
162#[derive(Debug, PartialEq)]
163enum DistanceZone {
164    InsideReference,
165    InsideMyDistance,
166    OutsideMyDistance,
167}
168
169/// Tracks airship distance trend from a fixed position. Used for airship
170/// traffic control.
171#[derive(Debug, Default, Clone)]
172struct ZoneDistanceTracker {
173    fixed_pos: Vec2<f32>,
174    stable_tolerance: f32,
175    ref_dist: Option<f32>,
176    prev_dist: Option<f32>,
177    avg_dist: Option<f32>,
178}
179
180impl ZoneDistanceTracker {
181    fn update(
182        &mut self,
183        mypos: Vec2<f32>,
184        otherpos: Vec2<f32>,
185    ) -> (Option<DistanceTrend>, Option<DistanceZone>) {
186        const SMOOTHING_FACTOR: f32 = 0.3; // Damp out some fluctuations so that we know if the trend is stable.
187        // There is no delta time here because the measurements are taken at larger time
188        // intervals, on the order of seconds.
189        let current_dist = otherpos.distance_squared(self.fixed_pos);
190        let trend = if let Some(prev_dist) = self.prev_dist {
191            let my_dist = mypos.distance_squared(self.fixed_pos);
192            let avg_dist = self
193                .avg_dist
194                .map(|prev_avg_dist| Lerp::lerp(prev_avg_dist, current_dist, SMOOTHING_FACTOR))
195                .unwrap_or(current_dist);
196            self.avg_dist = Some(avg_dist);
197            let zone = if current_dist < my_dist {
198                if let Some(ref_dist) = self.ref_dist {
199                    if current_dist < ref_dist {
200                        DistanceZone::InsideReference
201                    } else {
202                        DistanceZone::InsideMyDistance
203                    }
204                } else {
205                    DistanceZone::InsideMyDistance
206                }
207            } else {
208                DistanceZone::OutsideMyDistance
209            };
210            if avg_dist.abs() < self.stable_tolerance {
211                (Some(DistanceTrend::Docked), Some(zone))
212            } else if current_dist < prev_dist {
213                (Some(DistanceTrend::ApproachingDock), Some(zone))
214            } else {
215                (Some(DistanceTrend::DepartingDock), Some(zone))
216            }
217        } else {
218            self.avg_dist = Some(current_dist);
219            (None, None)
220        };
221        self.prev_dist = Some(current_dist);
222        trend
223    }
224}
225
226/// The flight phases of an airship.
227/// When the airship is loaded from RTSim data there is no "current approach
228/// index", so the 'Reset' phases are used to get the airship back on the route.
229/// The non-'Reset' phases are used for the normal flight loop.
230#[derive(Debug, Copy, Clone, PartialEq, Default)]
231enum AirshipFlightPhase {
232    Ascent,
233    Cruise,
234    ApproachFinal,
235    Transition,
236    // Docking,
237    #[default]
238    Docked,
239}
240
241const AIRSHIP_APPROACH_FINAL_HEIGHT_DELTA: f32 = 100.0;
242const AIRSHIP_DOCK_TRANSITION_HEIGHT_DELTA: f32 = 150.0;
243
244/// Wrapper for the fly_airship action, so the route context fields can be
245/// reset.
246fn fly_airship(
247    phase: AirshipFlightPhase,
248    wpos: Vec3<f32>,
249    goal_dist: f32,
250    initial_speed_factor: f32,
251    height_offset: f32,
252    with_terrain_following: bool,
253    direction_override: Option<Dir>,
254    flight_mode: FlightMode,
255    with_collision_avoidance: bool,
256    radar_interval: Duration,
257) -> impl Action<AirshipRouteContext> {
258    now(move |_, airship_context: &mut AirshipRouteContext| {
259        airship_context.speed_factor = initial_speed_factor;
260        airship_context.avoidance_timer = radar_interval;
261        airship_context.avoid_mode = AirshipAvoidanceMode::None;
262        airship_context.trackers.clear();
263        fly_airship_inner(
264            phase,
265            wpos,
266            goal_dist,
267            initial_speed_factor,
268            height_offset,
269            with_terrain_following,
270            direction_override,
271            flight_mode,
272            with_collision_avoidance,
273            radar_interval,
274        )
275    })
276}
277
278/// Called from pilot_airship to move the airship along phases of the route
279/// and for initial routing after server startup. The bulk of this action
280/// is collision-avoidance monitoring. The frequency of the collision-avoidance
281/// tests is controlled by the radar_interval parameter.
282/// The collision-avoidance logic has the ability to change the airship's speed
283/// and to hold position.
284///
285/// # Avoidance Logic
286/// All airships on the same route follow what is essentially a race track.
287/// All collision issues are caused by airships catching up to each other on the
288/// route. To avoid collisions, the postion and movement of other airships on
289/// the route is monitored at a interval of between 2 and 4 seconds. If another
290/// airship is moving towards the same docking position, it may be ahead or
291/// behind the monitoring airship. If the other airship is ahead, and the
292/// monitoring airship is approaching the docking position, the monitoring
293/// airship will slow down or stop to avoid a potential conflict.
294///
295/// # Parameters
296///
297/// - `route_context`: The AirshipRouteContext owned by the pilot_airship
298///   action.
299/// - `phase`: One of the phases of the airship flight loop or reset stages.
300/// - `wpos`: The fly-to target position for the airship.
301/// - `goal_dist`: The distance to the target position at which this action
302///   (this phase) will stop.
303/// - `initial_speed_factor`: The initial speed factor for the airship. Can be
304///   modified by collision-avoidance.
305/// - `height_offset`: The height offset for the airship (height above terrain).
306///   This is only used if with_terrain_following is true.
307/// - `with_terrain_following`: Whether to follow the terrain. If true, the
308///   airship will fly at a height above the terrain. If false, the airship
309///   flies directly to the target position.
310/// - `direction_override`: An optional direction override for the airship. If
311///   Some, the airship will be oriented (pointed) in this direction.
312/// - `flight_mode`: Influences the positioning of the airship. When approaching
313///   or at the target position, the airship either slows (brakes) and holds or
314///   flys through, expecting to continue on the next flight phase.
315/// - `with_collision_avoidance`: Whether to perform collision avoidance. It's
316///   not needed for docking or initial ascent because other airships must give
317///   way to this airship.
318/// - `radar_interval`: The interval at which to check on the positions and
319///   movements of other airships on the same route.
320///
321/// # Returns
322///
323/// An Action
324fn fly_airship_inner(
325    phase: AirshipFlightPhase,
326    wpos: Vec3<f32>,
327    goal_dist: f32,
328    initial_speed_factor: f32,
329    height_offset: f32,
330    with_terrain_following: bool,
331    direction_override: Option<Dir>,
332    flight_mode: FlightMode,
333    with_collision_avoidance: bool,
334    radar_interval: Duration,
335) -> impl Action<AirshipRouteContext> {
336    just(move |ctx, airship_context: &mut AirshipRouteContext| {
337        let remaining = airship_context
338            .avoidance_timer
339            .checked_sub(Duration::from_secs_f32(ctx.dt));
340        if remaining.is_none() {
341            airship_context.avoidance_timer = radar_interval;
342            // The collision avoidance checks are not done every tick (no dt required), only
343            // every 2-4 seconds.
344            if with_collision_avoidance {
345                if let Some(approach_index) = airship_context.current_approach_index
346                    && let Some(route_id) = ctx
347                        .state
348                        .data()
349                        .airship_sim
350                        .assigned_routes
351                        .get(&ctx.npc_id)
352                    && let Some(route) = ctx.world.civs().airships.routes.get(route_id)
353                    && let Some(approach) = route.approaches.get(approach_index)
354                    && let Some(pilots) = ctx.state.data().airship_sim.route_pilots.get(route_id)
355                {
356                    let mypos = ctx.npc.wpos;
357
358                    // The intermediate reference distance is either the approach initial point
359                    // (when cruising) or final point (when on
360                    // approach).
361                    let tracker_ref_dist = match phase {
362                        AirshipFlightPhase::Cruise => Some(
363                            approach
364                                .approach_initial_pos
365                                .distance_squared(approach.airship_pos.xy()),
366                        ),
367                        AirshipFlightPhase::ApproachFinal => Some(
368                            approach
369                                .approach_final_pos
370                                .distance_squared(approach.airship_pos.xy()),
371                        ),
372                        _ => None,
373                    };
374
375                    if phase == AirshipFlightPhase::Cruise {
376                        let position_tracker =
377                            airship_context.pos_trackers.entry(ctx.npc_id).or_default();
378                        position_tracker.add_position(mypos);
379                        // Check if the airship is stuck in one place.
380                        if position_tracker.is_stuck(ctx, approach)
381                            && let Some(backout_pos) = position_tracker.next_backout_pos(ctx)
382                        {
383                            airship_context.avoid_mode = AirshipAvoidanceMode::Stuck(backout_pos);
384                        } else {
385                            // If the mode was stuck, but the airship is no longer stuck, clear the
386                            // mode.
387                            if matches!(airship_context.avoid_mode, AirshipAvoidanceMode::Stuck(..))
388                            {
389                                airship_context.avoid_mode = AirshipAvoidanceMode::None;
390                            }
391                        }
392                    }
393
394                    if !matches!(airship_context.avoid_mode, AirshipAvoidanceMode::Stuck(..)) {
395                        // Collect the avoidance modes for other airships on the route.
396                        let avoidance: Vec<AirshipAvoidanceMode> = pilots
397                            .iter()
398                            .filter(|pilot_id| **pilot_id != ctx.npc_id)
399                            .filter_map(|pilot_id| {
400                                ctx.state.data().npcs.get(*pilot_id).and_then(|pilot| {
401                                    let pilot_wpos = pilot.wpos;
402                                    let other_tracker = airship_context
403                                        .trackers
404                                        .entry(*pilot_id)
405                                        .or_insert_with(|| ZoneDistanceTracker {
406                                            fixed_pos: approach.airship_pos.xy(),
407                                            stable_tolerance: 20.0,
408                                            ref_dist: tracker_ref_dist,
409                                            ..Default::default()
410                                        });
411                                    // the tracker determines if the other airship is moving towards
412                                    // or away from my docking position
413                                    // and if towards, whether it is inside of my position (ahead of
414                                    // me) and if ahead, whether it is
415                                    // inside the reference distance. If ahead of me but no inside
416                                    // the reference distance, I should slow down.
417                                    // If ahead of me and inside the reference distance, I should
418                                    // slow down or hold depending on which
419                                    // phase I'm in.
420                                    let (trend, zone) =
421                                        other_tracker.update(mypos.xy(), pilot_wpos.xy());
422                                    match trend {
423                                        Some(DistanceTrend::ApproachingDock) => {
424                                            // other airship is moving towards the (my) docking
425                                            // position
426                                            match zone {
427                                                Some(DistanceZone::InsideMyDistance) => {
428                                                    // other airship is ahead, on the same route,
429                                                    // but outside
430                                                    // the reference distance (either the approach
431                                                    // initial point or final point)
432                                                    match phase {
433                                                        // If I'm currently cruising, slow down if
434                                                        // the other airship is
435                                                        // within 2000 blocks
436                                                        AirshipFlightPhase::Cruise => {
437                                                            let dist2 = mypos
438                                                                .xy()
439                                                                .distance_squared(pilot_wpos.xy());
440                                                            if dist2 < 2000.0f32.powi(2) {
441                                                                Some(AirshipAvoidanceMode::SlowDown)
442                                                            } else {
443                                                                None
444                                                            }
445                                                        },
446                                                        // If I'm currently on approach, stop and
447                                                        // hold
448                                                        AirshipFlightPhase::ApproachFinal => {
449                                                            Some(AirshipAvoidanceMode::Hold(
450                                                                mypos,
451                                                                Dir::from_unnormalized(
452                                                                    (approach.approach_final_pos
453                                                                        - mypos.xy())
454                                                                    .with_z(0.0),
455                                                                )
456                                                                .unwrap_or_default(),
457                                                            ))
458                                                        },
459                                                        // If I'm currently transitioning to above
460                                                        // the dock, hold position
461                                                        AirshipFlightPhase::Transition => {
462                                                            Some(AirshipAvoidanceMode::Hold(
463                                                                mypos,
464                                                                approach.airship_direction,
465                                                            ))
466                                                        },
467                                                        _ => None,
468                                                    }
469                                                },
470                                                Some(DistanceZone::InsideReference) => {
471                                                    // other airship is ahead, on the same route,
472                                                    // and inside
473                                                    // the reference distance (either the approach
474                                                    // initial point or final point)
475                                                    match phase {
476                                                        // If I'm currently on approach, slow down,
477                                                        // to eventually
478                                                        // hold near the dock.
479                                                        AirshipFlightPhase::ApproachFinal => {
480                                                            Some(AirshipAvoidanceMode::SlowDown)
481                                                        },
482                                                        // else I'm cruising, the other airship is
483                                                        // well ahead, and
484                                                        // I might eventually have to hold nearer
485                                                        // the dock.
486                                                        // There is no reference distance if on
487                                                        // final.
488                                                        _ => None,
489                                                    }
490                                                },
491                                                // else other airship is behind me, ignore
492                                                _ => None,
493                                            }
494                                        },
495                                        // other airship is at the dock (or desending to the dock)
496                                        Some(DistanceTrend::Docked) => {
497                                            // other airship is ahead, on the same route, near the
498                                            // dock.
499                                            match phase {
500                                                // If I'm currently on approach, slow down, to
501                                                // eventually probably hold near the dock.
502                                                AirshipFlightPhase::ApproachFinal => {
503                                                    Some(AirshipAvoidanceMode::SlowDown)
504                                                },
505                                                // If I'm currently transitioning to above the dock,
506                                                // hold position
507                                                AirshipFlightPhase::Transition => {
508                                                    Some(AirshipAvoidanceMode::Hold(
509                                                        mypos,
510                                                        approach.airship_direction,
511                                                    ))
512                                                },
513                                                // otherwise continue until some other condition is
514                                                // met
515                                                _ => None,
516                                            }
517                                        },
518                                        // else other airship is moving away from my dock or there
519                                        // isn't enough data to determine the trend.
520                                        // Do nothing.
521                                        _ => None,
522                                    }
523                                })
524                            })
525                            .collect();
526
527                        if let Some((hold_pos, hold_dir)) = avoidance.iter().find_map(|mode| {
528                            if let AirshipAvoidanceMode::Hold(hold_pos, hold_dir) = mode {
529                                Some((hold_pos, hold_dir))
530                            } else {
531                                None
532                            }
533                        }) {
534                            if !matches!(airship_context.avoid_mode, AirshipAvoidanceMode::Hold(..))
535                            {
536                                airship_context.did_hold = true;
537                                airship_context.avoid_mode =
538                                    AirshipAvoidanceMode::Hold(*hold_pos, *hold_dir);
539                                airship_context.hold_timer = ctx.rng.gen_range(4.0..7.0);
540                                airship_context.hold_announced = false;
541                            }
542                        } else if avoidance
543                            .iter()
544                            .any(|mode| matches!(mode, AirshipAvoidanceMode::SlowDown))
545                        {
546                            if !matches!(airship_context.avoid_mode, AirshipAvoidanceMode::SlowDown)
547                            {
548                                airship_context.slow_count += 1;
549                                airship_context.avoid_mode = AirshipAvoidanceMode::SlowDown;
550                                airship_context.speed_factor = initial_speed_factor * 0.5;
551                            }
552                        } else {
553                            airship_context.avoid_mode = AirshipAvoidanceMode::None;
554                            airship_context.speed_factor = initial_speed_factor;
555                        }
556                    }
557                }
558            } else {
559                airship_context.avoid_mode = AirshipAvoidanceMode::None;
560                airship_context.speed_factor = initial_speed_factor;
561            }
562        } else {
563            airship_context.avoidance_timer = remaining.unwrap_or(radar_interval);
564        }
565
566        if let AirshipAvoidanceMode::Stuck(unstick_target) = airship_context.avoid_mode {
567            // Unstick the airship
568            ctx.controller.do_goto_with_height_and_dir(
569                unstick_target,
570                1.5,
571                None,
572                None,
573                FlightMode::Braking(BrakingMode::Normal),
574            );
575        } else if let AirshipAvoidanceMode::Hold(hold_pos, hold_dir) = airship_context.avoid_mode {
576            airship_context.hold_timer -= ctx.dt;
577            if airship_context.hold_timer <= 0.0 {
578                if !airship_context.hold_announced {
579                    airship_context.hold_announced = true;
580                    ctx.controller
581                        .say(None, Content::localized("npc-speech-pilot-announce_hold"));
582                } else {
583                    ctx.controller
584                        .say(None, Content::localized("npc-speech-pilot-continue_hold"));
585                }
586                airship_context.hold_timer = ctx.rng.gen_range(10.0..20.0);
587            }
588            // Hold position (same idea as holding station at the dock except allow
589            // oscillations)
590            let hold_pos = if matches!(ctx.npc.mode, SimulationMode::Simulated) {
591                hold_pos
592            } else {
593                // Airship is loaded, add some randomness to the hold position
594                // so that the airship doesn't look like it's stuck in one place.
595                // This also keeps the propellers spinning slowly and somewhat randomly.
596                hold_pos
597                    + (Vec3::new(0.7, 0.8, 0.9).map(|e| (e * ctx.time.0).sin())
598                        * Vec3::new(5.0, 5.0, 10.0))
599                    .map(|e| e as f32)
600            };
601
602            // Holding position
603            ctx.controller.do_goto_with_height_and_dir(
604                hold_pos,
605                0.25,
606                None,
607                Some(hold_dir),
608                FlightMode::Braking(BrakingMode::Normal),
609            );
610        } else {
611            // use terrain height offset if specified
612            let height_offset_opt = if with_terrain_following {
613                Some(height_offset)
614            } else {
615                None
616            };
617            // Move the airship
618            ctx.controller.do_goto_with_height_and_dir(
619                wpos,
620                airship_context.speed_factor,
621                height_offset_opt,
622                direction_override,
623                flight_mode,
624            );
625        }
626    })
627    .repeat()
628    .boxed()
629    .stop_if(move |ctx: &mut NpcCtx| {
630        if flight_mode == FlightMode::FlyThrough {
631            // we only care about the xy distance (just get close to the target position)
632            ctx.npc.wpos.xy().distance_squared(wpos.xy()) < goal_dist.powi(2)
633        } else {
634            // Braking mode means the PID controller will be controlling all three axes
635            ctx.npc.wpos.distance_squared(wpos) < goal_dist.powi(2)
636        }
637    })
638    .debug(move || {
639        format!(
640            "fly airship, phase:{:?}, tgt pos:({}, {}, {}), goal dist:{}, speed:{}, height:{}, \
641             terrain following:{}, FlightMode:{:?}, collision avoidance:{}, radar interval:{}",
642            phase,
643            wpos.x,
644            wpos.y,
645            wpos.z,
646            goal_dist,
647            initial_speed_factor,
648            height_offset,
649            with_terrain_following,
650            flight_mode,
651            with_collision_avoidance,
652            radar_interval.as_secs_f32()
653        )
654    })
655    .map(|_, _| ())
656}
657
658/// Get the target position for airship movement given the target position, the
659/// default height above terrain, and the height above terrain for the airship
660/// route cruise phase. This samples terrain points aound the target pos to get
661/// the maximum terrain altitude in a 200 block radius of the target position
662/// (only checking 4 cardinal directions). and returns the input approach_pos
663/// with z equal to the maximum terrain alt + height or the default_alt
664/// whichever is greater.
665fn approach_target_pos(
666    ctx: &mut NpcCtx,
667    approach_pos: Vec2<f32>,
668    default_alt: f32,
669    height: f32,
670) -> Vec3<f32> {
671    // sample 4 terrain altitudes around the final approach point and take the max.
672    let max_alt = CARDINALS
673        .iter()
674        .map(|rpos| {
675            ctx.world
676                .sim()
677                .get_surface_alt_approx(approach_pos.as_() + rpos * 200)
678        })
679        .max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
680        .unwrap_or(default_alt);
681    approach_pos.with_z(max_alt + height)
682}
683
684/// Calculates how to resume a route. Called when the server starts up.
685/// The airship will be at the approach final point, and this function
686/// just needs to figure out to which approach index that correlates.
687/// Returns the index of the approach to resume on.
688fn resume_route(airships: &Airships, route_id: &u32, ctx: &mut NpcCtx) -> usize {
689    if let Some(route) = airships.routes.get(route_id) {
690        route
691            .approaches
692            .iter()
693            .enumerate()
694            .min_by_key(|(_, approach)| {
695                approach
696                    .approach_final_pos
697                    .distance_squared(ctx.npc.wpos.xy()) as i32
698            })
699            .map(|(index, _)| index)
700            .unwrap_or(0)
701    } else {
702        0
703    }
704}
705
706/// The NPC is the airship captain. This action defines the flight loop for the
707/// airship. The captain NPC is autonomous and will fly the airship along the
708/// assigned route. The routes are established and assigned to the captain NPCs
709/// when the world is generated.
710pub fn pilot_airship<S: State>() -> impl Action<S> {
711    /*
712        Phases of the airship flight:
713        1. Docked at a docking position. Hovering in place. Mode 3d with direction vector.
714        2. Ascending staight up, to flight transition point. Mode 3d with direction vector.
715        3. Cruising to destination initial point. Mode 2d with variable height offset (from terrain).
716        4. Flying to final point. Mode 3d with direction vector.
717        5. Flying to docking transition point. Mode 3d with direction vector.
718        6. Descending and docking. Mode 3d with direction vector.
719
720                                                                                +
721                                                                                +...+
722                                                                                +  <------------ Docking Position
723                                                                                |
724                                                            Aligned with        |
725                                                            Docking Direction   |
726                                                                                |
727                                                                                |
728                                                                                |
729                                                                Turn Towards    | <----------- Final Point
730                                                                Docking Pos    /-
731        Docked Position (start)                                              /-
732            |                                              Turn Towards    /--
733            |                                              Final Point  /-
734            -> -------------------Cruise------------------------------  <-------------------- Initial Point
735            +
736            +...+
737            +
738
739        Algorithm for piloting the airship:
740
741        Phase  State    Parameters                      Completion Conditions   Collision Avoidance
742        1      Docked   3D Position, Dir                Docking Timeout         No
743        2      Ascent   3D Position, Dir                Altitude reached        No
744        3      Cruise   2D Position, Height             2D Position reached     Yes
745        4      Approach 3D Position, Height, Dir        2D Position reached     Yes
746        5      Final    3D Position, Dir                3D Position reached     Yes
747        6      Landing  3D Position, Dir                3D Position reached     No
748    */
749
750    now(move |ctx, route_context: &mut AirshipRouteContext| {
751
752        // get the assigned route
753        if let Some(route_id) = ctx.state.data().airship_sim.assigned_routes.get(&ctx.npc_id)
754            && let Some(route) = ctx.world.civs().airships.routes.get(route_id) {
755
756            route_context.site_ids = Some([route.approaches[0].site_id, route.approaches[1].site_id]);
757
758            // pilot_airship action is called the first time, there will be no current approach index
759            // but the airship will be sitting at the approach final point.
760            let (current_approach_index, resuming) = if let Some(current_approach_index) = route_context.current_approach_index {
761                (current_approach_index, false)
762            } else {
763                let resume_approach_index = resume_route(&ctx.world.civs().airships, route_id, ctx);
764                route_context.current_approach_index = Some(resume_approach_index);
765                (resume_approach_index, true)
766            };
767
768            // when current_approach_index exists, it means we're repeating the flight loop
769            // if approach index is 0, then the airship is fly from site 0 to site 1, and vice versa
770
771            // the approach flips in the middle of this loop after waiting at the dock.
772            // The route_context.current_approach_index is used to determine the current approach at the
773            // top of the function but any changes to route_context are not seen until the next iteration.
774            // For this loop, these are the two approaches. We use #2 after docking is completed.
775
776            let approach1 = route.approaches[current_approach_index].clone();
777            let approach2 = route.approaches[(current_approach_index + 1) % 2].clone();
778
779            // Startup delay
780            // If the server has just started (resuming is true)
781            // then the airship is at the approach final point.
782            // It should hover there for a random time so that not all airships land at the same time.
783            let start_delay = if resuming {
784                ctx.rng.gen_range(1..5) as f64 * 10.0
785            } else {
786                0.02
787            };
788            just(move |ctx, _| {
789                ctx.controller
790                .do_goto_with_height_and_dir(
791                    approach1.approach_final_pos.with_z(approach1.airship_pos.z + approach1.height - AIRSHIP_APPROACH_FINAL_HEIGHT_DELTA),
792                    0.4, None,
793                    Some(approach1.airship_direction),
794                    FlightMode::Braking(BrakingMode::Precise),
795                );
796            })
797            .repeat()
798            .stop_if(timeout(start_delay))
799
800            // Regular Flight Loop
801            // Fly 3D to Docking Transition Point, full PID control
802
803            .then(
804                fly_airship(
805                    AirshipFlightPhase::Transition,
806                    approach1.airship_pos + Vec3::unit_z() * (approach1.height - AIRSHIP_DOCK_TRANSITION_HEIGHT_DELTA),
807                    50.0,
808                    0.4,
809                    approach1.height - AIRSHIP_DOCK_TRANSITION_HEIGHT_DELTA,
810                    true,
811                    Some(approach1.airship_direction),
812                    FlightMode::Braking(BrakingMode::Normal),
813                    true,
814                    Duration::from_secs_f32(2.0),
815            ))
816            // Descend and Dock
817            //    Docking
818            //      Stop in increments and settle at 150 blocks and then 50 blocks from the dock.
819            //      This helps to ensure that the airship docks vertically and avoids collisions
820            //      with other airships and the dock. The speed_factor is high to
821            //      give a strong response to the PID controller for the first
822            //      three docking phases. The speed_factor is reduced for the final docking phase
823            //      to give the impression that the airship propellers are not rotating.
824            //      Vary the timeout to get variation in the docking sequence.
825            .then(
826                // descend to 125 blocks above the dock
827                just(move |ctx, _| {
828                    ctx.controller
829                        .do_goto_with_height_and_dir(
830                            approach1.airship_pos + Vec3::unit_z() * 125.0,
831                            0.8, None,
832                            Some(approach1.airship_direction),
833                            FlightMode::Braking(BrakingMode::Normal),
834                        );
835                })
836                .repeat()
837                .stop_if(timeout(ctx.rng.gen_range(10.0..14.0))))
838            .then(
839                // descend to 35 blocks above the dock
840                just(move |ctx, _| {
841                    ctx.controller
842                        .do_goto_with_height_and_dir(
843                            approach1.airship_pos + Vec3::unit_z() * 35.0,
844                            0.7, None,
845                            Some(approach1.airship_direction),
846                            FlightMode::Braking(BrakingMode::Normal),
847                        );
848                })
849                .repeat()
850                .stop_if(timeout(ctx.rng.gen_range(7.0..9.5))))
851            .then(
852                // descend to docking position
853                just(move |ctx: &mut NpcCtx, _| {
854                    ctx.controller
855                        .do_goto_with_height_and_dir(
856                            approach1.airship_pos,
857                            0.7, None,
858                            Some(approach1.airship_direction),
859                            FlightMode::Braking(BrakingMode::Precise),
860                        );
861                })
862                .repeat()
863                .stop_if(timeout(ctx.rng.gen_range(6.0..8.0))))
864            // Announce arrival
865            .then(just(|ctx: &mut NpcCtx, _| {
866                ctx.controller
867                    .say(None, Content::localized("npc-speech-pilot-landed"));
868            }))
869
870            // Docked - Wait at Dock
871            .then(
872                now(move |ctx, route_context:&mut AirshipRouteContext| {
873                    // The extra time to hold at the dock is a route_context variable.
874                    // Adjust the extra time based on the airship's behavior during the previous
875                    // flight. If the airship had to hold position, add 30 seconds to the dock time.
876                    // If the airship did not hold position, subtract 45 seconds from the dock time
877                    // because we want to reverse the extra penalty faster than building it up in case
878                    // the airship transitions to simulation mode.
879                    // If the airship had to slow down add 15 and if not subtract 20.
880                    if route_context.did_hold {
881                        route_context.extra_hold_dock_time += 30.0;
882                    } else if route_context.extra_hold_dock_time > 45.0 {
883                        route_context.extra_hold_dock_time -= 45.0;
884                    } else {
885                        route_context.extra_hold_dock_time = 0.0;
886                    }
887                    if route_context.slow_count > 0 {
888                        route_context.extra_slowdown_dock_time += 15.0;
889                    } else if route_context.extra_slowdown_dock_time > 20.0 {
890                        route_context.extra_slowdown_dock_time -= 20.0;
891                    } else {
892                        route_context.extra_slowdown_dock_time = 0.0;
893                    }
894
895                    let docking_time = route_context.extra_hold_dock_time + route_context.extra_slowdown_dock_time + Airships::docking_duration();
896                    #[cfg(debug_assertions)]
897                    {
898                        if let Some(site_ids) = route_context.site_ids
899                        && let Some(approach_index) = route_context.current_approach_index {
900                            let docked_site_id = site_ids[approach_index];
901                            let docked_site_name = ctx.index.sites.get(docked_site_id).name().to_string();
902                            debug!("{}, Docked at {}, did_hold:{}, slow_count:{}, extra_hold_dock_time:{}, extra_slowdown_dock_time:{}, docking_time:{}", format!("{:?}", ctx.npc_id), docked_site_name, route_context.did_hold, route_context.slow_count, route_context.extra_hold_dock_time, route_context.extra_slowdown_dock_time, docking_time);
903                        }
904                    }
905                    route_context.did_hold = false;
906                    route_context.slow_count = 0;
907
908                    just(move |ctx, _| {
909                        ctx.controller
910                        .do_goto_with_height_and_dir(
911                            approach1.airship_pos,
912                            0.75, None,
913                            Some(approach1.airship_direction),
914                            FlightMode::Braking(BrakingMode::Precise),
915                        );
916                    })
917                    .repeat()
918                    .stop_if(timeout(ctx.rng.gen_range(10.0..16.0)))
919                    // While waiting, every now and then announce where the airship is going next.
920                    .then(
921                        just(move |ctx, route_context:&mut AirshipRouteContext| {
922                            // get the name of the site the airship is going to next.
923                            // The route_context.current_approach_index has not been switched yet,
924                            // so the index is the opposite of the current approach index.
925                            if let Some(site_ids) = route_context.site_ids {
926                                let next_site_id = site_ids[(route_context.current_approach_index.unwrap_or(0) + 1) % 2];
927                                let next_site_name = ctx.index.sites.get(next_site_id).name().to_string();
928                                ctx.controller.say(
929                                    None,
930                                    Content::localized_with_args("npc-speech-pilot-announce_next", [
931                                    (
932                                        "dir",
933                                        Direction::from_dir(approach2.approach_initial_pos - ctx.npc.wpos.xy()).localize_npc(),
934                                    ),
935                                    ("dst", Content::Plain(next_site_name.to_string())),
936                                    ]),
937                                );
938                            }
939                        })
940                    )
941                    .repeat()
942                    .stop_if(timeout(docking_time as f64))
943                })
944            ).then(
945                // rotate the approach to the next approach index. Note the approach2 is already known,
946                // this is just changing the approach index in the context data for the next loop.
947                just(move |ctx, route_context:&mut AirshipRouteContext| {
948                    let from_index = route_context.current_approach_index.unwrap_or(0);
949                    let next_approach_index = (from_index + 1) % 2;
950                    route_context.current_approach_index = Some(next_approach_index);
951                    if let Some(site_ids) = route_context.site_ids {
952                        ctx.controller.say(
953                        None,
954                            Content::localized_with_args("npc-speech-pilot-takeoff", [
955                                ("src", Content::Plain(ctx.index.sites.get(site_ids[from_index]).name().to_string())),
956                                ("dst", Content::Plain(ctx.index.sites.get(site_ids[next_approach_index]).name().to_string())),
957                            ]),
958                        );
959                    }
960                })
961            ).then(
962                // Ascend to Cruise Alt, full PID control
963                fly_airship(
964                    AirshipFlightPhase::Ascent,
965                    approach1.airship_pos + Vec3::unit_z() * Airships::takeoff_ascent_height(),
966                    50.0,
967                    0.2,
968                    0.0,
969                    false,
970                    Some(Dir::from_unnormalized((approach2.approach_initial_pos - ctx.npc.wpos.xy()).with_z(0.0)).unwrap_or_default()),
971                    FlightMode::Braking(BrakingMode::Normal),
972                    false,
973                    Duration::from_secs_f32(120.0),
974                )
975            ).then(
976                // Fly 2D to Destination Initial Point
977                fly_airship(
978                    AirshipFlightPhase::Cruise,
979                    approach_target_pos(ctx, approach2.approach_initial_pos, approach2.airship_pos.z + approach2.height, approach2.height),
980                    250.0,
981                    1.0,
982                    approach2.height,
983                    true,
984                    None,
985                    FlightMode::FlyThrough,
986                    true,
987                    Duration::from_secs_f32(4.0),
988                )
989            ).then(
990                // Fly 3D to Destination Final Point, z PID control
991                fly_airship(
992                    AirshipFlightPhase::ApproachFinal,
993                    approach_target_pos(ctx, approach2.approach_final_pos, approach2.airship_pos.z + approach2.height - AIRSHIP_APPROACH_FINAL_HEIGHT_DELTA, approach2.height - AIRSHIP_APPROACH_FINAL_HEIGHT_DELTA),
994                    250.0,
995                    0.5,
996                    approach2.height - AIRSHIP_APPROACH_FINAL_HEIGHT_DELTA,
997                    true,
998                    Some(approach2.airship_direction),
999                    FlightMode::FlyThrough,
1000                    true,
1001                    Duration::from_secs_f32(2.0),
1002                )
1003            ).map(|_, _| ()).boxed()
1004        } else {
1005            //  There are no routes assigned.
1006            //  This is unexpected and never happens in testing, just do nothing so the compiler doesn't complain.
1007            finish().map(|_, _| ()).boxed()
1008        }
1009    })
1010    .repeat()
1011    .with_state(AirshipRouteContext::default())
1012    .map(|_, _| ())
1013}
1014
1015#[cfg(test)]
1016mod tests {
1017    use super::{DistanceTrend, DistanceZone, ZoneDistanceTracker};
1018    use vek::{Vec2, Vec3};
1019
1020    #[test]
1021    fn transition_zone_other_approaching_test() {
1022        let dock_pos = Vec2::new(0.0, 0.0);
1023        let my_pos: Vec2<f32> = Vec2::new(0.0, -100.0);
1024        let mut other_pos = Vec2::new(0.0, -50.0);
1025        let mut tracker = ZoneDistanceTracker {
1026            fixed_pos: dock_pos,
1027            stable_tolerance: 20.0,
1028            ..Default::default()
1029        };
1030        for _ in 0..50 {
1031            other_pos.y += 1.0;
1032            let (trend, zone) = tracker.update(my_pos, other_pos);
1033            assert!(!(matches!(trend, Some(DistanceTrend::DepartingDock))));
1034            assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1035        }
1036    }
1037
1038    #[test]
1039    fn transition_zone_other_docked_test() {
1040        let dock_pos = Vec3::new(1050.0, 8654.33, 874.2);
1041        let my_pos: Vec3<f32> = Vec3::new(1000.0, 8454.33, 574.2);
1042        let mut tracker = ZoneDistanceTracker {
1043            fixed_pos: dock_pos.xy(),
1044            stable_tolerance: 20.0,
1045            ..Default::default()
1046        };
1047        let time_0 = 27334.98f64;
1048        for i in 0..100 {
1049            let other_pos = dock_pos
1050                + (Vec3::new(0.7, 0.8, 0.9).map(|e| e * (time_0 + i as f64 * 1.37).sin())
1051                    * Vec3::new(5.0, 5.0, 10.0))
1052                .map(|e| e as f32)
1053                .xy();
1054            let (trend, zone) = tracker.update(my_pos.xy(), other_pos.xy());
1055            assert!(matches!(trend, Some(DistanceTrend::Docked) | None));
1056            assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1057        }
1058    }
1059
1060    #[test]
1061    fn transition_zone_other_departing_test() {
1062        let dock_pos = Vec2::new(0.0, 0.0);
1063        let my_pos: Vec2<f32> = Vec2::new(-100.0, -100.0);
1064        let mut other_pos = Vec2::new(0.0, -1.0);
1065        let mut tracker = ZoneDistanceTracker {
1066            fixed_pos: dock_pos,
1067            stable_tolerance: 20.0,
1068            ..Default::default()
1069        };
1070        for _ in 0..50 {
1071            other_pos.y -= 1.0;
1072            let (trend, zone) = tracker.update(my_pos, other_pos);
1073            assert!(!(matches!(trend, Some(DistanceTrend::ApproachingDock))));
1074            assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1075        }
1076    }
1077
1078    #[test]
1079    fn approach_other_approaching_behind_test() {
1080        let dock_pos = Vec2::new(10987.0, 5634.0);
1081        let afp_pos = Vec2::new(10642.0, 5518.5);
1082        let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1083        let mut other_pos = Vec2::new(9965.0, 4501.8);
1084        let mut tracker = ZoneDistanceTracker {
1085            fixed_pos: dock_pos,
1086            stable_tolerance: 20.0,
1087            ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1088            ..Default::default()
1089        };
1090        let step_y = (my_pos.y - other_pos.y) / 51.0;
1091        for _ in 0..50 {
1092            other_pos.y += step_y;
1093            let (trend, zone) = tracker.update(my_pos, other_pos);
1094            assert!(matches!(trend, Some(DistanceTrend::ApproachingDock) | None));
1095            assert!(matches!(zone, Some(DistanceZone::OutsideMyDistance) | None));
1096        }
1097    }
1098
1099    #[test]
1100    fn approach_other_approaching_in_zone2() {
1101        let dock_pos = Vec2::new(10987.0, 5634.0);
1102        let afp_pos = Vec2::new(10642.0, 5518.5);
1103        let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1104        let mut other_pos = Vec2::new(9965.0, 5407.3);
1105        let mut tracker = ZoneDistanceTracker {
1106            fixed_pos: dock_pos,
1107            stable_tolerance: 20.0,
1108            ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1109            ..Default::default()
1110        };
1111        let step_y = (afp_pos.y - other_pos.y) / 51.0;
1112        for _ in 0..50 {
1113            other_pos.y += step_y;
1114            let (trend, zone) = tracker.update(my_pos, other_pos);
1115            assert!(matches!(trend, Some(DistanceTrend::ApproachingDock) | None));
1116            assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1117        }
1118    }
1119
1120    #[test]
1121    fn approach_other_departing_in_zone2() {
1122        let dock_pos = Vec2::new(10987.0, 5634.0);
1123        let afp_pos = Vec2::new(10642.0, 5518.5);
1124        let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1125        let mut other_pos = Vec2::new(9965.0, 5518.3);
1126        let mut tracker = ZoneDistanceTracker {
1127            fixed_pos: dock_pos,
1128            stable_tolerance: 20.0,
1129            ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1130            ..Default::default()
1131        };
1132        let step_y = (my_pos.y - other_pos.y) / 51.0;
1133        for _ in 0..50 {
1134            other_pos.y += step_y;
1135            let (trend, zone) = tracker.update(my_pos, other_pos);
1136            assert!(matches!(trend, Some(DistanceTrend::DepartingDock) | None));
1137            assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1138        }
1139    }
1140
1141    #[test]
1142    fn approach_other_approaching_in_zone1() {
1143        let dock_pos = Vec2::new(10987.0, 5634.0);
1144        let afp_pos = Vec2::new(10642.0, 5518.5);
1145        let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1146        let mut other_pos = Vec2::new(10655.0, 5518.7);
1147        let mut tracker = ZoneDistanceTracker {
1148            fixed_pos: dock_pos,
1149            stable_tolerance: 20.0,
1150            ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1151            ..Default::default()
1152        };
1153        let step_x = (dock_pos.x - other_pos.x) / 50.0;
1154        let step_y = (dock_pos.y - other_pos.y) / 50.0;
1155        for _ in 0..50 {
1156            other_pos.x += step_x;
1157            other_pos.y += step_y;
1158            let (trend, zone) = tracker.update(my_pos, other_pos);
1159            assert!(matches!(trend, Some(DistanceTrend::ApproachingDock) | None));
1160            assert!(matches!(zone, Some(DistanceZone::InsideReference) | None));
1161        }
1162    }
1163
1164    #[test]
1165    fn approach_other_docked() {
1166        let dock_pos = Vec2::new(10987.0, 5634.0);
1167        let afp_pos = Vec2::new(10642.0, 5518.5);
1168        let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1169        let mut tracker = ZoneDistanceTracker {
1170            fixed_pos: dock_pos,
1171            stable_tolerance: 20.0,
1172            ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1173            ..Default::default()
1174        };
1175        let time_0 = 354334.98f64;
1176        for i in 0..50 {
1177            let other_pos = dock_pos
1178                + (Vec3::new(0.7, 0.8, 0.9).map(|e| e * (time_0 + i as f64 * 1.37).sin())
1179                    * Vec3::new(5.0, 5.0, 10.0))
1180                .map(|e| e as f32)
1181                .xy();
1182            let (trend, zone) = tracker.update(my_pos, other_pos);
1183            assert!(matches!(trend, Some(DistanceTrend::Docked) | None));
1184            assert!(matches!(zone, Some(DistanceZone::InsideReference) | None));
1185        }
1186    }
1187
1188    #[test]
1189    fn approach_other_departing_in_zone1() {
1190        let dock_pos = Vec2::new(10987.0, 5634.0);
1191        let afp_pos = Vec2::new(10642.0, 5518.5);
1192        let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1193        let mut other_pos = Vec2::new(10987.0, 5634.0);
1194        let mut tracker = ZoneDistanceTracker {
1195            fixed_pos: dock_pos,
1196            stable_tolerance: 20.0,
1197            ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1198            ..Default::default()
1199        };
1200        let step_x = (afp_pos.x - dock_pos.x) / 51.0;
1201        let step_y = (afp_pos.y - dock_pos.y) / 51.0;
1202        for _ in 0..50 {
1203            other_pos.x += step_x;
1204            other_pos.y += step_y;
1205            let (trend, zone) = tracker.update(my_pos, other_pos);
1206            assert!(!(matches!(trend, Some(DistanceTrend::ApproachingDock))));
1207            assert!(matches!(zone, Some(DistanceZone::InsideReference) | None));
1208        }
1209    }
1210
1211    #[test]
1212    fn approach_other_departing_behind() {
1213        let dock_pos = Vec2::new(10987.0, 5634.0);
1214        let afp_pos = Vec2::new(10642.0, 5518.5);
1215        let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1216        let mut other_pos = Vec2::new(9964.8, 5406.55);
1217        let mut tracker = ZoneDistanceTracker {
1218            fixed_pos: dock_pos,
1219            stable_tolerance: 20.0,
1220            ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1221            ..Default::default()
1222        };
1223        let step_x = -11.37;
1224        let step_y = -23.87;
1225        for _ in 0..50 {
1226            other_pos.x += step_x;
1227            other_pos.y += step_y;
1228            let (trend, zone) = tracker.update(my_pos, other_pos);
1229            assert!(matches!(trend, Some(DistanceTrend::DepartingDock) | None));
1230            assert!(matches!(zone, Some(DistanceZone::OutsideMyDistance) | None));
1231        }
1232    }
1233}