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        self, 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>(ship: common::comp::ship::Body) -> 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            let ship_body = comp::Body::from(ship);
772            // the approach flips in the middle of this loop after waiting at the dock.
773            // The route_context.current_approach_index is used to determine the current approach at the
774            // top of the function but any changes to route_context are not seen until the next iteration.
775            // For this loop, these are the two approaches. We use #2 after docking is completed.
776
777            let approach1 = route.approaches[current_approach_index].clone();
778            let approach2 = route.approaches[(current_approach_index + 1) % 2].clone();
779
780            // Startup delay
781            // If the server has just started (resuming is true)
782            // then the airship is at the approach final point.
783            // It should hover there for a random time so that not all airships land at the same time.
784            let start_delay = if resuming {
785                ctx.rng.gen_range(1..5) as f64 * 10.0
786            } else {
787                0.02
788            };
789            just(move |ctx, _| {
790                ctx.controller
791                .do_goto_with_height_and_dir(
792                    approach1.approach_final_pos.with_z(approach1.airship_pos.z + approach1.height - AIRSHIP_APPROACH_FINAL_HEIGHT_DELTA),
793                    0.4, None,
794                    Some(approach1.airship_direction),
795                    FlightMode::Braking(BrakingMode::Precise),
796                );
797            })
798            .repeat()
799            .stop_if(timeout(start_delay))
800
801            // Regular Flight Loop
802            // Fly 3D to Docking Transition Point, full PID control
803
804            .then(
805                fly_airship(
806                    AirshipFlightPhase::Transition,
807                    approach1.airship_pos + Vec3::unit_z() * (approach1.height - AIRSHIP_DOCK_TRANSITION_HEIGHT_DELTA),
808                    50.0,
809                    0.4,
810                    approach1.height - AIRSHIP_DOCK_TRANSITION_HEIGHT_DELTA,
811                    true,
812                    Some(approach1.airship_direction),
813                    FlightMode::Braking(BrakingMode::Normal),
814                    true,
815                    Duration::from_secs_f32(2.0),
816            ))
817            // Descend and Dock
818            //    Docking
819            //      Stop in increments and settle at 150 blocks and then 50 blocks from the dock.
820            //      This helps to ensure that the airship docks vertically and avoids collisions
821            //      with other airships and the dock. The speed_factor is high to
822            //      give a strong response to the PID controller for the first
823            //      three docking phases. The speed_factor is reduced for the final docking phase
824            //      to give the impression that the airship propellers are not rotating.
825            //      Vary the timeout to get variation in the docking sequence.
826            .then(
827                // descend to 125 blocks above the dock
828                just(move |ctx, _| {
829                    ctx.controller
830                        .do_goto_with_height_and_dir(
831                            approach1.airship_pos + Vec3::unit_z() * 125.0,
832                            0.8, None,
833                            Some(approach1.airship_direction),
834                            FlightMode::Braking(BrakingMode::Normal),
835                        );
836                })
837                .repeat()
838                .stop_if(timeout(ctx.rng.gen_range(10.0..14.0))))
839            .then(
840                // descend to 35 blocks above the dock
841                just(move |ctx, _| {
842                    ctx.controller
843                        .do_goto_with_height_and_dir(
844                            approach1.airship_pos + Vec3::unit_z() * 35.0,
845                            0.7, None,
846                            Some(approach1.airship_direction),
847                            FlightMode::Braking(BrakingMode::Normal),
848                        );
849                })
850                .repeat()
851                .stop_if(timeout(ctx.rng.gen_range(7.0..9.5))))
852            .then(
853                // descend to docking position
854                just(move |ctx: &mut NpcCtx, _| {
855                    ctx.controller
856                        .do_goto_with_height_and_dir(
857                            approach1.airship_pos + ship_body.mount_offset(),
858                            0.7, None,
859                            Some(approach1.airship_direction),
860                            FlightMode::Braking(BrakingMode::Precise),
861                        );
862                })
863                .repeat()
864                .stop_if(timeout(ctx.rng.gen_range(6.0..8.0))))
865            // Announce arrival
866            .then(just(|ctx: &mut NpcCtx, _| {
867                ctx.controller
868                    .say(None, Content::localized("npc-speech-pilot-landed"));
869            }))
870
871            // Docked - Wait at Dock
872            .then(
873                now(move |ctx, route_context:&mut AirshipRouteContext| {
874                    // The extra time to hold at the dock is a route_context variable.
875                    // Adjust the extra time based on the airship's behavior during the previous
876                    // flight. If the airship had to hold position, add 30 seconds to the dock time.
877                    // If the airship did not hold position, subtract 45 seconds from the dock time
878                    // because we want to reverse the extra penalty faster than building it up in case
879                    // the airship transitions to simulation mode.
880                    // If the airship had to slow down add 15 and if not subtract 20.
881                    if route_context.did_hold {
882                        route_context.extra_hold_dock_time += 30.0;
883                    } else if route_context.extra_hold_dock_time > 45.0 {
884                        route_context.extra_hold_dock_time -= 45.0;
885                    } else {
886                        route_context.extra_hold_dock_time = 0.0;
887                    }
888                    if route_context.slow_count > 0 {
889                        route_context.extra_slowdown_dock_time += 15.0;
890                    } else if route_context.extra_slowdown_dock_time > 20.0 {
891                        route_context.extra_slowdown_dock_time -= 20.0;
892                    } else {
893                        route_context.extra_slowdown_dock_time = 0.0;
894                    }
895
896                    let docking_time = route_context.extra_hold_dock_time + route_context.extra_slowdown_dock_time + Airships::docking_duration();
897                    #[cfg(debug_assertions)]
898                    {
899                        if let Some(site_ids) = route_context.site_ids
900                        && let Some(approach_index) = route_context.current_approach_index {
901                            let docked_site_id = site_ids[approach_index];
902                            let docked_site_name = ctx.index.sites.get(docked_site_id).name().to_string();
903                            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);
904                        }
905                    }
906                    route_context.did_hold = false;
907                    route_context.slow_count = 0;
908
909                    just(move |ctx, _| {
910                        ctx.controller
911                        .do_goto_with_height_and_dir(
912                            approach1.airship_pos + ship_body.mount_offset(),
913                            0.75, None,
914                            Some(approach1.airship_direction),
915                            FlightMode::Braking(BrakingMode::Precise),
916                        );
917                    })
918                    .repeat()
919                    .stop_if(timeout(ctx.rng.gen_range(10.0..16.0)))
920                    // While waiting, every now and then announce where the airship is going next.
921                    .then(
922                        just(move |ctx, route_context:&mut AirshipRouteContext| {
923                            // get the name of the site the airship is going to next.
924                            // The route_context.current_approach_index has not been switched yet,
925                            // so the index is the opposite of the current approach index.
926                            if let Some(site_ids) = route_context.site_ids {
927                                let next_site_id = site_ids[(route_context.current_approach_index.unwrap_or(0) + 1) % 2];
928                                let next_site_name = ctx.index.sites.get(next_site_id).name().to_string();
929                                ctx.controller.say(
930                                    None,
931                                    Content::localized_with_args("npc-speech-pilot-announce_next", [
932                                    (
933                                        "dir",
934                                        Direction::from_dir(approach2.approach_initial_pos - ctx.npc.wpos.xy()).localize_npc(),
935                                    ),
936                                    ("dst", Content::Plain(next_site_name.to_string())),
937                                    ]),
938                                );
939                            }
940                        })
941                    )
942                    .repeat()
943                    .stop_if(timeout(docking_time as f64))
944                })
945            ).then(
946                // rotate the approach to the next approach index. Note the approach2 is already known,
947                // this is just changing the approach index in the context data for the next loop.
948                just(move |ctx, route_context:&mut AirshipRouteContext| {
949                    let from_index = route_context.current_approach_index.unwrap_or(0);
950                    let next_approach_index = (from_index + 1) % 2;
951                    route_context.current_approach_index = Some(next_approach_index);
952                    if let Some(site_ids) = route_context.site_ids {
953                        ctx.controller.say(
954                        None,
955                            Content::localized_with_args("npc-speech-pilot-takeoff", [
956                                ("src", Content::Plain(ctx.index.sites.get(site_ids[from_index]).name().to_string())),
957                                ("dst", Content::Plain(ctx.index.sites.get(site_ids[next_approach_index]).name().to_string())),
958                            ]),
959                        );
960                    }
961                })
962            ).then(
963                // Ascend to Cruise Alt, full PID control
964                fly_airship(
965                    AirshipFlightPhase::Ascent,
966                    approach1.airship_pos + Vec3::unit_z() * Airships::takeoff_ascent_height(),
967                    50.0,
968                    0.2,
969                    0.0,
970                    false,
971                    Some(Dir::from_unnormalized((approach2.approach_initial_pos - ctx.npc.wpos.xy()).with_z(0.0)).unwrap_or_default()),
972                    FlightMode::Braking(BrakingMode::Normal),
973                    false,
974                    Duration::from_secs_f32(120.0),
975                )
976            ).then(
977                // Fly 2D to Destination Initial Point
978                fly_airship(
979                    AirshipFlightPhase::Cruise,
980                    approach_target_pos(ctx, approach2.approach_initial_pos, approach2.airship_pos.z + approach2.height, approach2.height),
981                    250.0,
982                    1.0,
983                    approach2.height,
984                    true,
985                    None,
986                    FlightMode::FlyThrough,
987                    true,
988                    Duration::from_secs_f32(4.0),
989                )
990            ).then(
991                // Fly 3D to Destination Final Point, z PID control
992                fly_airship(
993                    AirshipFlightPhase::ApproachFinal,
994                    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),
995                    250.0,
996                    0.5,
997                    approach2.height - AIRSHIP_APPROACH_FINAL_HEIGHT_DELTA,
998                    true,
999                    Some(approach2.airship_direction),
1000                    FlightMode::FlyThrough,
1001                    true,
1002                    Duration::from_secs_f32(2.0),
1003                )
1004            ).map(|_, _| ()).boxed()
1005        } else {
1006            //  There are no routes assigned.
1007            //  This is unexpected and never happens in testing, just do nothing so the compiler doesn't complain.
1008            finish().map(|_, _| ()).boxed()
1009        }
1010    })
1011    .repeat()
1012    .with_state(AirshipRouteContext::default())
1013    .map(|_, _| ())
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018    use super::{DistanceTrend, DistanceZone, ZoneDistanceTracker};
1019    use vek::{Vec2, Vec3};
1020
1021    #[test]
1022    fn transition_zone_other_approaching_test() {
1023        let dock_pos = Vec2::new(0.0, 0.0);
1024        let my_pos: Vec2<f32> = Vec2::new(0.0, -100.0);
1025        let mut other_pos = Vec2::new(0.0, -50.0);
1026        let mut tracker = ZoneDistanceTracker {
1027            fixed_pos: dock_pos,
1028            stable_tolerance: 20.0,
1029            ..Default::default()
1030        };
1031        for _ in 0..50 {
1032            other_pos.y += 1.0;
1033            let (trend, zone) = tracker.update(my_pos, other_pos);
1034            assert!(!(matches!(trend, Some(DistanceTrend::DepartingDock))));
1035            assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1036        }
1037    }
1038
1039    #[test]
1040    fn transition_zone_other_docked_test() {
1041        let dock_pos = Vec3::new(1050.0, 8654.33, 874.2);
1042        let my_pos: Vec3<f32> = Vec3::new(1000.0, 8454.33, 574.2);
1043        let mut tracker = ZoneDistanceTracker {
1044            fixed_pos: dock_pos.xy(),
1045            stable_tolerance: 20.0,
1046            ..Default::default()
1047        };
1048        let time_0 = 27334.98f64;
1049        for i in 0..100 {
1050            let other_pos = dock_pos
1051                + (Vec3::new(0.7, 0.8, 0.9).map(|e| e * (time_0 + i as f64 * 1.37).sin())
1052                    * Vec3::new(5.0, 5.0, 10.0))
1053                .map(|e| e as f32)
1054                .xy();
1055            let (trend, zone) = tracker.update(my_pos.xy(), other_pos.xy());
1056            assert!(matches!(trend, Some(DistanceTrend::Docked) | None));
1057            assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1058        }
1059    }
1060
1061    #[test]
1062    fn transition_zone_other_departing_test() {
1063        let dock_pos = Vec2::new(0.0, 0.0);
1064        let my_pos: Vec2<f32> = Vec2::new(-100.0, -100.0);
1065        let mut other_pos = Vec2::new(0.0, -1.0);
1066        let mut tracker = ZoneDistanceTracker {
1067            fixed_pos: dock_pos,
1068            stable_tolerance: 20.0,
1069            ..Default::default()
1070        };
1071        for _ in 0..50 {
1072            other_pos.y -= 1.0;
1073            let (trend, zone) = tracker.update(my_pos, other_pos);
1074            assert!(!(matches!(trend, Some(DistanceTrend::ApproachingDock))));
1075            assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1076        }
1077    }
1078
1079    #[test]
1080    fn approach_other_approaching_behind_test() {
1081        let dock_pos = Vec2::new(10987.0, 5634.0);
1082        let afp_pos = Vec2::new(10642.0, 5518.5);
1083        let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1084        let mut other_pos = Vec2::new(9965.0, 4501.8);
1085        let mut tracker = ZoneDistanceTracker {
1086            fixed_pos: dock_pos,
1087            stable_tolerance: 20.0,
1088            ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1089            ..Default::default()
1090        };
1091        let step_y = (my_pos.y - other_pos.y) / 51.0;
1092        for _ in 0..50 {
1093            other_pos.y += step_y;
1094            let (trend, zone) = tracker.update(my_pos, other_pos);
1095            assert!(matches!(trend, Some(DistanceTrend::ApproachingDock) | None));
1096            assert!(matches!(zone, Some(DistanceZone::OutsideMyDistance) | None));
1097        }
1098    }
1099
1100    #[test]
1101    fn approach_other_approaching_in_zone2() {
1102        let dock_pos = Vec2::new(10987.0, 5634.0);
1103        let afp_pos = Vec2::new(10642.0, 5518.5);
1104        let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1105        let mut other_pos = Vec2::new(9965.0, 5407.3);
1106        let mut tracker = ZoneDistanceTracker {
1107            fixed_pos: dock_pos,
1108            stable_tolerance: 20.0,
1109            ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1110            ..Default::default()
1111        };
1112        let step_y = (afp_pos.y - other_pos.y) / 51.0;
1113        for _ in 0..50 {
1114            other_pos.y += step_y;
1115            let (trend, zone) = tracker.update(my_pos, other_pos);
1116            assert!(matches!(trend, Some(DistanceTrend::ApproachingDock) | None));
1117            assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1118        }
1119    }
1120
1121    #[test]
1122    fn approach_other_departing_in_zone2() {
1123        let dock_pos = Vec2::new(10987.0, 5634.0);
1124        let afp_pos = Vec2::new(10642.0, 5518.5);
1125        let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1126        let mut other_pos = Vec2::new(9965.0, 5518.3);
1127        let mut tracker = ZoneDistanceTracker {
1128            fixed_pos: dock_pos,
1129            stable_tolerance: 20.0,
1130            ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1131            ..Default::default()
1132        };
1133        let step_y = (my_pos.y - other_pos.y) / 51.0;
1134        for _ in 0..50 {
1135            other_pos.y += step_y;
1136            let (trend, zone) = tracker.update(my_pos, other_pos);
1137            assert!(matches!(trend, Some(DistanceTrend::DepartingDock) | None));
1138            assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1139        }
1140    }
1141
1142    #[test]
1143    fn approach_other_approaching_in_zone1() {
1144        let dock_pos = Vec2::new(10987.0, 5634.0);
1145        let afp_pos = Vec2::new(10642.0, 5518.5);
1146        let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1147        let mut other_pos = Vec2::new(10655.0, 5518.7);
1148        let mut tracker = ZoneDistanceTracker {
1149            fixed_pos: dock_pos,
1150            stable_tolerance: 20.0,
1151            ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1152            ..Default::default()
1153        };
1154        let step_x = (dock_pos.x - other_pos.x) / 50.0;
1155        let step_y = (dock_pos.y - other_pos.y) / 50.0;
1156        for _ in 0..50 {
1157            other_pos.x += step_x;
1158            other_pos.y += step_y;
1159            let (trend, zone) = tracker.update(my_pos, other_pos);
1160            assert!(matches!(trend, Some(DistanceTrend::ApproachingDock) | None));
1161            assert!(matches!(zone, Some(DistanceZone::InsideReference) | None));
1162        }
1163    }
1164
1165    #[test]
1166    fn approach_other_docked() {
1167        let dock_pos = Vec2::new(10987.0, 5634.0);
1168        let afp_pos = Vec2::new(10642.0, 5518.5);
1169        let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1170        let mut tracker = ZoneDistanceTracker {
1171            fixed_pos: dock_pos,
1172            stable_tolerance: 20.0,
1173            ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1174            ..Default::default()
1175        };
1176        let time_0 = 354334.98f64;
1177        for i in 0..50 {
1178            let other_pos = dock_pos
1179                + (Vec3::new(0.7, 0.8, 0.9).map(|e| e * (time_0 + i as f64 * 1.37).sin())
1180                    * Vec3::new(5.0, 5.0, 10.0))
1181                .map(|e| e as f32)
1182                .xy();
1183            let (trend, zone) = tracker.update(my_pos, other_pos);
1184            assert!(matches!(trend, Some(DistanceTrend::Docked) | None));
1185            assert!(matches!(zone, Some(DistanceZone::InsideReference) | None));
1186        }
1187    }
1188
1189    #[test]
1190    fn approach_other_departing_in_zone1() {
1191        let dock_pos = Vec2::new(10987.0, 5634.0);
1192        let afp_pos = Vec2::new(10642.0, 5518.5);
1193        let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1194        let mut other_pos = Vec2::new(10987.0, 5634.0);
1195        let mut tracker = ZoneDistanceTracker {
1196            fixed_pos: dock_pos,
1197            stable_tolerance: 20.0,
1198            ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1199            ..Default::default()
1200        };
1201        let step_x = (afp_pos.x - dock_pos.x) / 51.0;
1202        let step_y = (afp_pos.y - dock_pos.y) / 51.0;
1203        for _ in 0..50 {
1204            other_pos.x += step_x;
1205            other_pos.y += step_y;
1206            let (trend, zone) = tracker.update(my_pos, other_pos);
1207            assert!(!(matches!(trend, Some(DistanceTrend::ApproachingDock))));
1208            assert!(matches!(zone, Some(DistanceZone::InsideReference) | None));
1209        }
1210    }
1211
1212    #[test]
1213    fn approach_other_departing_behind() {
1214        let dock_pos = Vec2::new(10987.0, 5634.0);
1215        let afp_pos = Vec2::new(10642.0, 5518.5);
1216        let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1217        let mut other_pos = Vec2::new(9964.8, 5406.55);
1218        let mut tracker = ZoneDistanceTracker {
1219            fixed_pos: dock_pos,
1220            stable_tolerance: 20.0,
1221            ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1222            ..Default::default()
1223        };
1224        let step_x = -11.37;
1225        let step_y = -23.87;
1226        for _ in 0..50 {
1227            other_pos.x += step_x;
1228            other_pos.y += step_y;
1229            let (trend, zone) = tracker.update(my_pos, other_pos);
1230            assert!(matches!(trend, Some(DistanceTrend::DepartingDock) | None));
1231            assert!(matches!(zone, Some(DistanceZone::OutsideMyDistance) | None));
1232        }
1233    }
1234}