veloren_rtsim/rule/npc_ai/
airship_ai.rs

1#[cfg(feature = "airship_log")]
2use crate::rule::npc_ai::airship_logger::airship_logger;
3
4use crate::{
5    ai::{Action, NpcCtx, State, finish, just, now, seq},
6    data::npc::SimulationMode,
7};
8use common::{
9    comp::{
10        Content,
11        agent::{BrakingMode, FlightMode},
12        compass::Direction,
13    },
14    util::Dir,
15};
16use rand::prelude::*;
17use std::{cmp::Ordering, collections::VecDeque, time::Duration};
18use vek::*;
19use world::civ::airship_travel::{AirshipDockingApproach, AirshipFlightPhase};
20
21#[cfg(debug_assertions)]
22macro_rules! debug_airships {
23    ($level:expr, $($arg:tt)*) => {
24        match $level {
25            0 => tracing::error!($($arg)*),
26            1 => tracing::warn!($($arg)*),
27            2 => tracing::info!($($arg)*),
28            3 => tracing::debug!($($arg)*),
29            4 => tracing::trace!($($arg)*),
30            _ => tracing::trace!($($arg)*),
31        }
32    }
33}
34
35#[cfg(not(debug_assertions))]
36macro_rules! debug_airships {
37    ($($arg:tt)*) => {};
38}
39
40const AIRSHIP_PROGRESS_UPDATE_INTERVAL: f32 = 5.0; // seconds
41
42/// The context data for the pilot_airship action.
43#[derive(Debug, Clone)]
44struct AirshipRouteContext {
45    /// The route index (index into the outer vec of airships.routes)
46    route_index: usize,
47    /// The next route leg index.
48    current_leg: usize,
49    /// True for the first leg (initial startup), false otherwise.
50    first_leg: bool,
51    /// The current approach.
52    current_leg_approach: Option<AirshipDockingApproach>,
53    /// The direction override for departure and approach phases.
54    cruise_direction: Option<Dir>,
55    /// The next route leg approach.
56    next_leg_approach: Option<AirshipDockingApproach>,
57
58    // Timing
59    /// The context time at the start of the route. All route leg segment
60    /// times are measured relative to this time.
61    route_time_zero: f64,
62    /// Timer used for various periodic countdowns.
63    route_timer: Duration,
64    /// The context time at the beginning of the current route leg.
65    leg_ctx_time_begin: f64,
66
67    // Docking phase
68    /// The times at which announcements are made during docking.
69    announcements: VecDeque<f32>,
70
71    /// For tracking the airship's position history to determine if the airship
72    /// is stuck.
73    my_stuck_tracker: Option<StuckAirshipTracker>,
74    /// Timer for checking the airship trackers.
75    stuck_timer: Duration,
76    /// Timer used when holding, either on approach or at the dock.
77    stuck_backout_pos: Option<Vec3<f32>>,
78}
79
80impl Default for AirshipRouteContext {
81    fn default() -> Self {
82        Self {
83            route_index: usize::MAX,
84            current_leg: 0,
85            first_leg: true,
86            current_leg_approach: None,
87            cruise_direction: None,
88            next_leg_approach: None,
89            route_time_zero: 0.0,
90            route_timer: Duration::default(),
91            leg_ctx_time_begin: 0.0,
92            announcements: VecDeque::new(),
93            my_stuck_tracker: None,
94            stuck_timer: Duration::default(),
95            stuck_backout_pos: None,
96        }
97    }
98}
99
100/// Tracks the airship position history.
101/// Used for determining if an airship is stuck.
102#[derive(Debug, Default, Clone)]
103struct StuckAirshipTracker {
104    /// The airship's position history. Used for determining if the airship is
105    /// stuck in one place.
106    pos_history: Vec<Vec3<f32>>,
107    /// The route to follow for backing out of a stuck position.
108    backout_route: Vec<Vec3<f32>>,
109}
110
111impl StuckAirshipTracker {
112    /// The distance to back out from the stuck position.
113    const BACKOUT_DIST: f32 = 100.0;
114    /// The tolerance for determining if the airship has reached a backout
115    /// position.
116    const BACKOUT_TARGET_DIST: f64 = 50.0;
117    /// The number of positions to track in the position history.
118    const MAX_POS_HISTORY_SIZE: usize = 5;
119    /// The height for testing if the airship is near the ground.
120    const NEAR_GROUND_HEIGHT: f32 = 10.0;
121
122    /// Add a new position to the position history, maintaining a fixed size.
123    fn add_position(&mut self, new_pos: Vec3<f32>) {
124        if self.pos_history.len() >= StuckAirshipTracker::MAX_POS_HISTORY_SIZE {
125            self.pos_history.remove(0);
126        }
127        self.pos_history.push(new_pos);
128    }
129
130    /// Get the current backout position.
131    /// If the backout route is not empty, return the first position in the
132    /// route. As a side effect, if the airship position is within the
133    /// target distance of the first backout position, remove the backout
134    /// position. If there are no more backout postions, the position
135    /// history is cleared (because it will be stale data), and return None.
136    fn current_backout_pos(&mut self, ctx: &mut NpcCtx) -> Option<Vec3<f32>> {
137        if !self.backout_route.is_empty()
138            && let Some(pos) = self.backout_route.first().cloned()
139        {
140            if ctx.npc.wpos.as_::<f64>().distance_squared(pos.as_::<f64>())
141                < StuckAirshipTracker::BACKOUT_TARGET_DIST.powi(2)
142            {
143                self.backout_route.remove(0);
144            }
145            Some(pos)
146        } else {
147            self.pos_history.clear();
148            None
149        }
150    }
151
152    /// Check if the airship is stuck in one place. This check is done only in
153    /// cruise flight when the PID controller is affecting the Z axis
154    /// movement only. When the airship gets stuck, it will stop moving. The
155    /// only recourse is reverse direction, back up, and then ascend to
156    /// hopefully fly over the top of the obstacle. This may be repeated if the
157    /// airship gets stuck again. When the determination is made that the
158    /// airship is stuck, two positions are generated for the backout
159    /// procedure: the first is in the reverse of the direction the airship
160    /// was recently moving, and the second is straight up from the first
161    /// position. If the airship was near the ground when it got stuck, the
162    /// initial backout is done while climbing slightly to avoid any other
163    /// near-ground objects. If the airship was not near the ground, the
164    /// initial backout position is at the same height as the current position,
165    fn is_stuck(
166        &mut self,
167        ctx: &mut NpcCtx,
168        current_pos: &Vec3<f32>,
169        target_pos: &Vec2<f32>,
170    ) -> bool {
171        self.add_position(*current_pos);
172        // The position history must be full to determine if the airship is stuck.
173        if self.pos_history.len() == StuckAirshipTracker::MAX_POS_HISTORY_SIZE
174            && self.backout_route.is_empty()
175            && let Some(last_pos) = self.pos_history.last()
176        {
177            // If all the positions in the history are within 10 of the last position,
178            if self
179                .pos_history
180                .iter()
181                .all(|pos| pos.as_::<f64>().distance_squared(last_pos.as_::<f64>()) < 10.0)
182            {
183                // Airship is stuck on some obstacle.
184
185                // The direction to backout is opposite to the direction from the airship
186                // to where it was going before it got stuck.
187                if let Some(backout_dir) = (ctx.npc.wpos.xy() - target_pos)
188                    .with_z(0.0)
189                    .try_normalized()
190                {
191                    let ground = ctx
192                        .world
193                        .sim()
194                        .get_surface_alt_approx(last_pos.xy().map(|e| e as i32));
195                    // The position to backout to is the current position + a distance in the
196                    // backout direction.
197                    let mut backout_pos =
198                        ctx.npc.wpos + backout_dir * StuckAirshipTracker::BACKOUT_DIST;
199                    // Add a z offset to the backout pos if the airship is near the ground.
200                    if (ctx.npc.wpos.z - ground).abs() < StuckAirshipTracker::NEAR_GROUND_HEIGHT {
201                        backout_pos.z += 50.0;
202                    }
203                    self.backout_route = vec![backout_pos, backout_pos + Vec3::unit_z() * 200.0];
204                    // The airship is stuck.
205                    #[cfg(debug_assertions)]
206                    debug_airships!(
207                        2,
208                        "Airship {} Stuck! at {} {} {}, backout_dir:{:?}, backout_pos:{:?}",
209                        format!("{:?}", ctx.npc_id),
210                        ctx.npc.wpos.x,
211                        ctx.npc.wpos.y,
212                        ctx.npc.wpos.z,
213                        backout_dir,
214                        backout_pos
215                    );
216                    self.backout_route = vec![backout_pos, backout_pos + Vec3::unit_z() * 200.0];
217                }
218            }
219        }
220        !self.backout_route.is_empty()
221    }
222}
223
224#[cfg(debug_assertions)]
225fn check_phase_completion_time(
226    ctx: &mut NpcCtx,
227    airship_context: &mut AirshipRouteContext,
228    leg_index: usize,
229    phase: AirshipFlightPhase,
230) {
231    let route_leg = &ctx.world.civs().airships.routes[airship_context.route_index].legs
232        [airship_context.current_leg];
233    // route time = context time - route time zero
234    let completion_route_time = ctx.time.0 - airship_context.route_time_zero;
235    let time_delta = completion_route_time - route_leg.segments[leg_index].route_time;
236    if time_delta > 10.0 {
237        debug_airships!(
238            4,
239            "Airship {} route {} leg {} completed phase {:?} late by {:.1} seconds, crt {}, \
240             scheduled end time {}",
241            format!("{:?}", ctx.npc_id),
242            airship_context.route_index,
243            airship_context.current_leg,
244            phase,
245            time_delta,
246            completion_route_time,
247            route_leg.segments[leg_index].route_time
248        );
249    } else if time_delta < -10.0 {
250        debug_airships!(
251            4,
252            "Airship {} route {} leg {} completed phase {:?} early by {:.1} seconds, crt {}, \
253             scheduled end time {}",
254            format!("{:?}", ctx.npc_id),
255            airship_context.route_index,
256            airship_context.current_leg,
257            phase,
258            time_delta,
259            completion_route_time,
260            route_leg.segments[leg_index].route_time
261        );
262    } else {
263        debug_airships!(
264            4,
265            "Airship {} route {} leg {} completed phase {:?} on time, crt {}, scheduled end time \
266             {}",
267            format!("{:?}", ctx.npc_id),
268            airship_context.route_index,
269            airship_context.current_leg,
270            phase,
271            completion_route_time,
272            route_leg.segments[leg_index].route_time
273        );
274    }
275}
276
277fn fly_airship(
278    phase: AirshipFlightPhase,
279    approach: AirshipDockingApproach,
280) -> impl Action<AirshipRouteContext> {
281    now(move |ctx, airship_context: &mut AirshipRouteContext| {
282        airship_context.stuck_timer = Duration::from_secs_f32(5.0);
283        airship_context.stuck_backout_pos = None;
284        let route_leg = &ctx.world.civs().airships.routes[airship_context.route_index].legs
285            [airship_context.current_leg];
286
287        ctx.controller.current_airship_pilot_leg = Some((airship_context.current_leg, phase));
288
289        let nominal_speed = ctx.world.civs().airships.nominal_speed;
290        let leg_segment = &route_leg.segments[phase as usize];
291        // The actual leg start time was recorded when the previous leg ended.
292        let leg_ctx_time_begin = airship_context.leg_ctx_time_begin;
293
294        /*
295            Duration:
296            - starting leg: leg segment route time - spawning position route time
297            - all other legs: leg segment duration adjusted for early/late start
298            Distance to fly:
299            - Cruise phases: from the current pos to the leg segment target pos
300            - Descent: two steps, targeting above the dock then to the dock,
301              with random variation.
302            - Ascent: from the current pos to cruise height above the dock
303            - Docked: zero distance
304        */
305
306        let fly_duration = if airship_context.first_leg {
307            /*
308               if this is the very first leg for this airship,
309               then the leg duration is the leg segment (end) route time minus the
310               spawn route time.
311
312               airship_context.route_time_zero =
313                   ctx.time.0 - my_spawn_loc.spawn_route_time;
314               Spawn route time = ctx.time - airship_context.route_time_zero
315               dur = leg_segment.route_time - spawn_route_time
316                   = leg_segment.route_time - (ctx.time.0 - airship_context.route_time_zero)
317            */
318            debug_airships!(
319                4,
320                "Airship {} route {} leg {} first leg phase {} from {},{} dur {:.1}s",
321                format!("{:?}", ctx.npc_id),
322                airship_context.route_index,
323                airship_context.current_leg,
324                phase,
325                ctx.npc.wpos.x,
326                ctx.npc.wpos.y,
327                leg_segment.route_time - (ctx.time.0 - airship_context.route_time_zero)
328            );
329            ((leg_segment.route_time - (ctx.time.0 - airship_context.route_time_zero)) as f32)
330                .max(1.0)
331        } else {
332            // Duration is the leg segment duration adjusted for
333            // early/late starts caused by time errors in the
334            // previous leg.
335            let leg_start_route_time =
336                airship_context.leg_ctx_time_begin - airship_context.route_time_zero;
337            // Adjust the leg duration according to actual route start time vs expected
338            // route start time.
339            let expected_leg_route_start_time =
340                leg_segment.route_time - leg_segment.duration as f64;
341            let route_time_err = (leg_start_route_time - expected_leg_route_start_time) as f32;
342            if route_time_err > 10.0 {
343                debug_airships!(
344                    4,
345                    "Airship {} route {} leg {} starting {} late by {:.1}s, rt {:.2}, expected rt \
346                     {:.2}, leg dur {:.1}",
347                    format!("{:?}", ctx.npc_id),
348                    airship_context.route_index,
349                    airship_context.current_leg,
350                    phase,
351                    route_time_err,
352                    leg_start_route_time,
353                    expected_leg_route_start_time,
354                    leg_segment.duration - route_time_err
355                );
356                leg_segment.duration - route_time_err
357            } else if route_time_err < -10.0 {
358                debug_airships!(
359                    4,
360                    "Airship {} route {} leg {} starting {} early by {:.1}s, rt {:.2}, expected \
361                     rt {:.2}, leg dur {:.1}",
362                    format!("{:?}", ctx.npc_id),
363                    airship_context.route_index,
364                    airship_context.current_leg,
365                    phase,
366                    -route_time_err,
367                    leg_start_route_time,
368                    expected_leg_route_start_time,
369                    leg_segment.duration - route_time_err
370                );
371                leg_segment.duration - route_time_err
372            } else {
373                debug_airships!(
374                    4,
375                    "Airship {} route {} leg {} starting {} on time, leg dur {:.1}",
376                    format!("{:?}", ctx.npc_id),
377                    airship_context.route_index,
378                    airship_context.current_leg,
379                    phase,
380                    leg_segment.duration
381                );
382                leg_segment.duration
383            }
384            .max(1.0)
385        };
386
387        let fly_distance = match phase {
388            AirshipFlightPhase::DepartureCruise
389            | AirshipFlightPhase::ApproachCruise
390            | AirshipFlightPhase::Transition => {
391                ctx.npc.wpos.xy().distance(leg_segment.to_world_pos)
392            },
393            AirshipFlightPhase::Descent => ctx.npc.wpos.z - approach.airship_pos.z,
394            AirshipFlightPhase::Ascent => approach.airship_pos.z + approach.height - ctx.npc.wpos.z,
395            AirshipFlightPhase::Docked => 0.0,
396        };
397
398        let context_end_time = ctx.time.0 + fly_duration as f64;
399
400        match phase {
401            AirshipFlightPhase::DepartureCruise => {
402                airship_context.my_stuck_tracker = Some(StuckAirshipTracker::default());
403                airship_context.route_timer = Duration::from_secs_f32(1.0);
404                airship_context.cruise_direction =
405                    Dir::from_unnormalized((approach.midpoint - ctx.npc.wpos.xy()).with_z(0.0));
406                // v = d/t
407                // speed factor = v/nominal_speed = (d/t)/nominal_speed
408                let speed = (fly_distance / fly_duration) / nominal_speed;
409                debug_airships!(
410                    4,
411                    "Airship {} route {} leg {} DepartureCruise, fly_distance {:.1}, fly_duration \
412                     {:.1}s, speed factor {:.3}",
413                    format!("{:?}", ctx.npc_id),
414                    airship_context.route_index,
415                    airship_context.current_leg,
416                    fly_distance,
417                    fly_duration,
418                    speed
419                );
420                // Fly 2D to approach midpoint
421                fly_airship_inner(
422                    AirshipFlightPhase::DepartureCruise,
423                    ctx.npc.wpos,
424                    leg_segment.to_world_pos.with_z(0.0),
425                    50.0,
426                    leg_ctx_time_begin,
427                    fly_duration,
428                    context_end_time,
429                    speed,
430                    approach.height,
431                    true,
432                    None,
433                    FlightMode::FlyThrough,
434                )
435                .then(just(|ctx, airship_context: &mut AirshipRouteContext| {
436                    airship_context.leg_ctx_time_begin = ctx.time.0;
437                    airship_context.first_leg = false;
438                    #[cfg(debug_assertions)]
439                    check_phase_completion_time(
440                        ctx,
441                        airship_context,
442                        0,
443                        AirshipFlightPhase::DepartureCruise,
444                    );
445                }))
446                .map(|_, _| ())
447                .boxed()
448            },
449            AirshipFlightPhase::ApproachCruise => {
450                airship_context.route_timer = Duration::from_secs_f32(1.0);
451                airship_context.cruise_direction = Dir::from_unnormalized(
452                    (approach.approach_transition_pos - approach.midpoint).with_z(0.0),
453                );
454                // v = d/t
455                // speed factor = v/nominal_speed = (d/t)/nominal_speed
456                let speed = (fly_distance / fly_duration) / nominal_speed;
457                debug_airships!(
458                    4,
459                    "Airship {} route {} leg {} ApproachCruise, fly_distance {:.1}, fly_duration \
460                     {:.1}s, speed factor {:.3}",
461                    format!("{:?}", ctx.npc_id),
462                    airship_context.route_index,
463                    airship_context.current_leg,
464                    fly_distance,
465                    fly_duration,
466                    speed
467                );
468                // Fly 2D to transition point
469                fly_airship_inner(
470                    AirshipFlightPhase::ApproachCruise,
471                    ctx.npc.wpos,
472                    leg_segment.to_world_pos.with_z(0.0),
473                    50.0,
474                    leg_ctx_time_begin,
475                    fly_duration,
476                    context_end_time,
477                    speed,
478                    approach.height,
479                    true,
480                    None,
481                    FlightMode::FlyThrough,
482                )
483                .then(just(|ctx, airship_context: &mut AirshipRouteContext| {
484                    airship_context.leg_ctx_time_begin = ctx.time.0;
485                    airship_context.first_leg = false;
486                    #[cfg(debug_assertions)]
487                    check_phase_completion_time(
488                        ctx,
489                        airship_context,
490                        1,
491                        AirshipFlightPhase::ApproachCruise,
492                    );
493                }))
494                .map(|_, _| ())
495                .boxed()
496            },
497            AirshipFlightPhase::Transition => {
498                // let phase_duration = get_phase_duration(ctx, airship_context, route_leg, 2,
499                // phase); let context_end_time = ctx.time.0 + phase_duration;
500                airship_context.route_timer = Duration::from_secs_f32(1.0);
501                // v = d/t
502                // speed factor = v/nominal_speed = (d/t)/nominal_speed
503                let speed = (fly_distance / fly_duration) / nominal_speed;
504                debug_airships!(
505                    4,
506                    "Airship {} route {} leg {} Transition, fly_distance {:.1}, fly_duration \
507                     {:.1}s, speed factor {:.3}",
508                    format!("{:?}", ctx.npc_id),
509                    airship_context.route_index,
510                    airship_context.current_leg,
511                    fly_distance,
512                    fly_duration,
513                    speed
514                );
515                // fly 3D to descent point
516                fly_airship_inner(
517                    AirshipFlightPhase::Transition,
518                    ctx.npc.wpos,
519                    leg_segment
520                        .to_world_pos
521                        .with_z(approach.airship_pos.z + approach.height),
522                    20.0,
523                    leg_ctx_time_begin,
524                    fly_duration,
525                    context_end_time,
526                    speed,
527                    approach.height,
528                    true,
529                    Some(approach.airship_direction),
530                    FlightMode::Braking(BrakingMode::Normal),
531                )
532                .then(just(|ctx, airship_context: &mut AirshipRouteContext| {
533                    airship_context.leg_ctx_time_begin = ctx.time.0;
534                    airship_context.first_leg = false;
535                    #[cfg(debug_assertions)]
536                    check_phase_completion_time(
537                        ctx,
538                        airship_context,
539                        2,
540                        AirshipFlightPhase::Transition,
541                    );
542                }))
543                .map(|_, _| ())
544                .boxed()
545            },
546            AirshipFlightPhase::Descent => {
547                // Descend and Dock
548                airship_context.route_timer = Duration::from_secs_f32(1.0);
549                // v = d/t
550                // speed factor = v/nominal_speed = (d/t)/nominal_speed
551                /*
552                   Divide the descent into two steps with the 2nd step moving slowly
553                   (so more time and less distance) to give more variation.
554                   The total descent distance is fly_distance and the total duration is
555                   fly_duration. However, we want to stop the descent a bit above the dock
556                   so that any overshoot does not carry the airship much below the dock altitude.
557                   Account for the case that fly_distance <= desired_offset.
558                */
559                let desired_offset = ctx.rng.random_range(4.0..6.0);
560                let descent_offset = if fly_distance > desired_offset {
561                    desired_offset
562                } else {
563                    0.0
564                };
565                let descent_dist = fly_distance - descent_offset;
566                // step 1 dist is 60-80% of the descent distance
567                let step1_dist = descent_dist * ctx.rng.random_range(0.7..0.85);
568                let step1_dur = fly_duration * ctx.rng.random_range(0.4..0.55);
569                // Make loaded airships descend slightly faster
570                let speed_mult = if matches!(ctx.npc.mode, SimulationMode::Loaded) {
571                    ctx.rng.random_range(1.1..1.25)
572                } else {
573                    1.0
574                };
575                let speed1 = (step1_dist / step1_dur) / nominal_speed * speed_mult;
576                let speed2 = ((descent_dist - step1_dist) / (fly_duration - step1_dur))
577                    / nominal_speed
578                    * speed_mult;
579                debug_airships!(
580                    4,
581                    "Airship {} route {} leg {} Descent, fly_distance {:.1}, fly_duration {:.1}s, \
582                     desired_offset {:.1}, descent_offset {:.1}, descent_dist {:.1}, step1_dist \
583                     {:.1}, step1_dur {:.3}s, speedmult {:.1}, speed1 {:.3}, speed2 {:.3}",
584                    format!("{:?}", ctx.npc_id),
585                    airship_context.route_index,
586                    airship_context.current_leg,
587                    fly_distance,
588                    fly_duration,
589                    desired_offset,
590                    descent_offset,
591                    descent_dist,
592                    step1_dist,
593                    step1_dur,
594                    speed_mult,
595                    speed1,
596                    speed2
597                );
598
599                // fly 3D to the intermediate descent point
600                fly_airship_inner(
601                    AirshipFlightPhase::Descent,
602                    ctx.npc.wpos,
603                    ctx.npc.wpos - Vec3::unit_z() * step1_dist,
604                    20.0,
605                    leg_ctx_time_begin,
606                    step1_dur,
607                    context_end_time - step1_dur as f64,
608                    speed1,
609                    0.0,
610                    false,
611                    Some(approach.airship_direction),
612                    FlightMode::Braking(BrakingMode::Normal),
613                )
614                .then (fly_airship_inner(
615                    AirshipFlightPhase::Descent,
616                    ctx.npc.wpos - Vec3::unit_z() * step1_dist,
617                    approach.airship_pos + Vec3::unit_z() * descent_offset,
618                    20.0,
619                    leg_ctx_time_begin + step1_dur as f64,
620                    fly_duration - step1_dur,
621                    context_end_time,
622                    speed2,
623                    0.0,
624                    false,
625                    Some(approach.airship_direction),
626                    FlightMode::Braking(BrakingMode::Precise),
627                ))
628                // Announce arrival
629                .then(just(|ctx, airship_context: &mut AirshipRouteContext| {
630                    airship_context.leg_ctx_time_begin = ctx.time.0;
631                    airship_context.first_leg = false;
632                    #[cfg(debug_assertions)]
633                    check_phase_completion_time(ctx, airship_context, 3, AirshipFlightPhase::Descent);
634                    log_airship_position(ctx, airship_context.route_index, &AirshipFlightPhase::Docked);
635                    ctx.controller
636                        .say(None, Content::localized("npc-speech-pilot-landed"));
637                }))
638                .map(|_, _| ()).boxed()
639            },
640            AirshipFlightPhase::Docked => {
641                debug_airships!(
642                    4,
643                    "Airship {} route {} leg {} Docked ctx time {:.1}, \
644                     airship_context.leg_ctx_time_begin {:.1}, docking duration {:.1}s",
645                    format!("{:?}", ctx.npc_id),
646                    airship_context.route_index,
647                    airship_context.current_leg,
648                    leg_ctx_time_begin,
649                    airship_context.leg_ctx_time_begin,
650                    fly_duration,
651                );
652                /*
653                    Divide up the docking time into intervals of 10 to 16 seconds,
654                    and at each interval make an announcement. Make the last announcement
655                    approximately 10-12 seconds before the end of the docking time.
656                    Make the first announcement 5-8 seconds after starting the docking time.
657                    The minimum announcement time after the start is 5 seconds, and the
658                    minimum time before departure announcement is 10 seconds.
659                    The minimum interval between announcements is 10 seconds.
660
661                    Docked      Announce        Announce           Announce       Depart
662                    |-----------|---------------|----------------------|--------------|
663                    0         5-8s             ...               duration-10-12s
664                        min 5s        min 10           min 10               min 10s
665
666                    If docking duration is less than 10 seconds, no announcements.
667                */
668                let announcement_times = {
669                    let mut times = Vec::new();
670                    if fly_duration > 10.0 {
671                        let first_time = ctx.rng.random_range(5.0..8.0);
672                        let last_time = fly_duration - ctx.rng.random_range(10.0..12.0);
673                        if first_time + 10.0 > last_time {
674                            // Can't do two, try one.
675                            let mid_time = fly_duration / 2.0;
676                            if mid_time > 5.0 && (fly_duration - mid_time) > 10.0 {
677                                times.push(mid_time);
678                            }
679                        } else {
680                            // use first, then fill forward with random 10..16s intervals
681                            times.push(first_time);
682                            let mut last_time = first_time;
683                            let mut t = first_time + ctx.rng.random_range(10.0..16.0);
684                            while t < fly_duration - 10.0 {
685                                times.push(t);
686                                last_time = t;
687                                t += ctx.rng.random_range(10.0..16.0);
688                            }
689                            if last_time < fly_duration - 22.0 {
690                                // add one last announcement before the final 10s
691                                times.push(last_time + 12.0);
692                            }
693                            times.sort_by(|a, b| {
694                                a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
695                            });
696                        }
697                    }
698                    times
699                };
700                airship_context.announcements = announcement_times.into_iter().collect();
701                now(move |ctx, airship_context: &mut AirshipRouteContext| {
702                    // get next announcement time or the docking end time.
703                    // Don't consume the announcement time yet.
704                    let dock_ctx_end_time = if let Some(at) = airship_context.announcements.front()
705                    {
706                        leg_ctx_time_begin + *at as f64
707                    } else {
708                        context_end_time
709                    };
710                    fly_airship_inner(
711                        AirshipFlightPhase::Docked,
712                        ctx.npc.wpos,
713                        approach.airship_pos,
714                        5.0,
715                        leg_ctx_time_begin,
716                        fly_duration,
717                        dock_ctx_end_time,
718                        0.75,
719                        0.0,
720                        false,
721                        Some(approach.airship_direction),
722                        FlightMode::Braking(BrakingMode::Precise),
723                    )
724                    .then(just(
725                        |ctx, airship_context: &mut AirshipRouteContext| {
726                            // Now consume the announcement time. If there was one, announce the
727                            // next site. If not, we're at the end and
728                            // will be exiting this repeat loop.
729                            if airship_context.announcements.pop_front().is_some() {
730                                // make announcement and log position
731                                let (dst_site_name, dst_site_dir) = if let Some(next_leg_approach) =
732                                    airship_context.next_leg_approach
733                                {
734                                    (
735                                        ctx.index
736                                            .sites
737                                            .get(next_leg_approach.site_id)
738                                            .name()
739                                            .unwrap_or("Unknown Site")
740                                            .to_string(),
741                                        Direction::from_dir(
742                                            next_leg_approach.approach_transition_pos
743                                                - ctx.npc.wpos.xy(),
744                                        )
745                                        .localize_npc(),
746                                    )
747                                } else {
748                                    ("Unknown Site".to_string(), Direction::North.localize_npc())
749                                };
750                                ctx.controller.say(
751                                    None,
752                                    Content::localized("npc-speech-pilot-announce_next")
753                                        .with_arg("dir", dst_site_dir)
754                                        .with_arg("dst", dst_site_name),
755                                );
756                                log_airship_position(
757                                    ctx,
758                                    airship_context.route_index,
759                                    &AirshipFlightPhase::Docked,
760                                );
761                            }
762                        },
763                    ))
764                })
765                .repeat()
766                .stop_if(move |ctx: &mut NpcCtx| ctx.time.0 >= context_end_time)
767                .then(just(|ctx, airship_context: &mut AirshipRouteContext| {
768                    airship_context.leg_ctx_time_begin = ctx.time.0;
769                    airship_context.first_leg = false;
770                    #[cfg(debug_assertions)]
771                    check_phase_completion_time(
772                        ctx,
773                        airship_context,
774                        4,
775                        AirshipFlightPhase::Docked,
776                    );
777                    log_airship_position(
778                        ctx,
779                        airship_context.route_index,
780                        &AirshipFlightPhase::Docked,
781                    );
782                }))
783                .map(|_, _| ())
784                .boxed()
785            },
786            AirshipFlightPhase::Ascent => {
787                log_airship_position(
788                    ctx,
789                    airship_context.route_index,
790                    &AirshipFlightPhase::Ascent,
791                );
792                airship_context.route_timer = Duration::from_secs_f32(0.5);
793                // v = d/t
794                let speed = (fly_distance / fly_duration) / nominal_speed;
795                debug_airships!(
796                    4,
797                    "Airship {} route {} leg {} Ascent, fly_distance {:.1}, fly_duration {:.1}s, \
798                     speed factor {:.3}",
799                    format!("{:?}", ctx.npc_id),
800                    airship_context.route_index,
801                    airship_context.current_leg,
802                    fly_distance,
803                    fly_duration,
804                    speed
805                );
806                let src_site_name = ctx
807                    .index
808                    .sites
809                    .get(approach.site_id)
810                    .name()
811                    .unwrap_or("Unknown Site")
812                    .to_string();
813                let dst_site_name =
814                    if let Some(next_leg_approach) = airship_context.next_leg_approach {
815                        ctx.index
816                            .sites
817                            .get(next_leg_approach.site_id)
818                            .name()
819                            .unwrap_or("Unknown Site")
820                            .to_string()
821                    } else {
822                        "Unknown Site".to_string()
823                    };
824                ctx.controller.say(
825                    None,
826                    Content::localized("npc-speech-pilot-takeoff")
827                        .with_arg("src", src_site_name)
828                        .with_arg("dst", dst_site_name),
829                );
830                fly_airship_inner(
831                    AirshipFlightPhase::Ascent,
832                    ctx.npc.wpos,
833                    approach.airship_pos + Vec3::unit_z() * approach.height,
834                    20.0,
835                    leg_ctx_time_begin,
836                    fly_duration,
837                    context_end_time,
838                    speed,
839                    0.0,
840                    false,
841                    Some(approach.airship_direction),
842                    FlightMode::Braking(BrakingMode::Normal),
843                )
844                .then(just(|ctx, airship_context: &mut AirshipRouteContext| {
845                    airship_context.leg_ctx_time_begin = ctx.time.0;
846                    airship_context.first_leg = false;
847                    #[cfg(debug_assertions)]
848                    check_phase_completion_time(
849                        ctx,
850                        airship_context,
851                        5,
852                        AirshipFlightPhase::Ascent,
853                    );
854                }))
855                .map(|_, _| ())
856                .boxed()
857            },
858        }
859    })
860}
861
862/// The action that moves the airship.
863fn fly_airship_inner(
864    phase: AirshipFlightPhase,
865    from: Vec3<f32>,
866    to: Vec3<f32>,
867    goal_dist: f32,
868    leg_ctx_time_begin: f64,
869    leg_duration: f32,
870    context_tgt_end_time: f64,
871    speed_factor: f32,
872    height_offset: f32,
873    with_terrain_following: bool,
874    direction_override: Option<Dir>,
875    flight_mode: FlightMode,
876) -> impl Action<AirshipRouteContext> {
877    just(move |ctx, airship_context: &mut AirshipRouteContext| {
878        // The target position is used for determining the
879        // reverse direction for 'unsticking' the airship if it gets stuck in
880        // one place.
881        let stuck_tracker_target_loc = to.xy();
882
883        // Determine where the airship should be.
884        let nominal_pos = match phase {
885            AirshipFlightPhase::DepartureCruise
886            | AirshipFlightPhase::ApproachCruise
887            | AirshipFlightPhase::Transition => {
888                // Flying 2d, compute nominal x,y pos.
889                let route_interpolation_ratio =
890                    ((ctx.time.0 - leg_ctx_time_begin) as f32 / leg_duration).clamp(0.0, 1.0);
891                (from.xy() + (to.xy() - from.xy()) * route_interpolation_ratio).with_z(to.z)
892            },
893            AirshipFlightPhase::Descent | AirshipFlightPhase::Ascent => {
894                // Only Z movement, compute z pos.
895                // Starting altitude is terrain altitude + height offset
896                // Ending altitude is to.z
897                let route_interpolation_ratio =
898                    ((ctx.time.0 - leg_ctx_time_begin) as f32 / leg_duration).clamp(0.0, 1.0);
899                let nominal_z = from.z + (to.z - from.z) * route_interpolation_ratio;
900                to.xy().with_z(nominal_z)
901            },
902            _ => {
903                // docking phase has no movement
904                to
905            },
906        };
907
908        // Periodically check if the airship is stuck.
909        let timer = airship_context
910            .route_timer
911            .checked_sub(Duration::from_secs_f32(ctx.dt));
912        // keep or reset the timer
913        airship_context.route_timer =
914            timer.unwrap_or(Duration::from_secs_f32(AIRSHIP_PROGRESS_UPDATE_INTERVAL));
915        if timer.is_none() {
916            // Timer expired.
917            // log my position
918            #[cfg(feature = "airship_log")]
919            {
920                // Check position error
921                let distance_to_nominal = match phase {
922                    AirshipFlightPhase::DepartureCruise
923                    | AirshipFlightPhase::ApproachCruise
924                    | AirshipFlightPhase::Transition => {
925                        // Flying 2d, compute nominal x,y pos.
926                        ctx.npc
927                            .wpos
928                            .xy()
929                            .as_::<f64>()
930                            .distance(nominal_pos.xy().as_())
931                    },
932                    _ => ctx.npc.wpos.as_::<f64>().distance(nominal_pos.as_()),
933                };
934                log_airship_position_plus(
935                    ctx,
936                    airship_context.route_index,
937                    &phase,
938                    distance_to_nominal,
939                    0.0,
940                );
941            }
942
943            // If in cruise phase, check if the airship is stuck and reset the cruise
944            // direction.
945            if matches!(
946                phase,
947                AirshipFlightPhase::DepartureCruise | AirshipFlightPhase::ApproachCruise
948            ) {
949                // Check if we're stuck
950                if let Some(stuck_tracker) = &mut airship_context.my_stuck_tracker
951                    && stuck_tracker.is_stuck(ctx, &ctx.npc.wpos, &stuck_tracker_target_loc)
952                    && let Some(backout_pos) = stuck_tracker.current_backout_pos(ctx)
953                {
954                    airship_context.stuck_backout_pos = Some(backout_pos);
955                } else if airship_context.stuck_backout_pos.is_some() {
956                    #[cfg(debug_assertions)]
957                    debug_airships!(
958                        2,
959                        "{:?} unstuck at pos: {} {}",
960                        ctx.npc_id,
961                        ctx.npc.wpos.x as i32,
962                        ctx.npc.wpos.y as i32,
963                    );
964                    airship_context.stuck_backout_pos = None;
965                };
966                // Reset cruise direction
967                airship_context.cruise_direction =
968                    Dir::from_unnormalized((to.xy() - ctx.npc.wpos.xy()).with_z(0.0));
969            }
970        }
971        // move the airship
972        if let Some(backout_pos) = airship_context.stuck_backout_pos {
973            // Unstick the airship
974            ctx.controller.do_goto_with_height_and_dir(
975                backout_pos,
976                1.5,
977                None,
978                None,
979                FlightMode::Braking(BrakingMode::Normal),
980            );
981        } else {
982            // Normal movement, not stuck.
983            let height_offset_opt = if with_terrain_following {
984                Some(height_offset)
985            } else {
986                None
987            };
988            // In the long cruise phases, the airship should face the target position.
989            // When the airship is loaded, the movement vector can change dramatically from
990            // velocity differences due to climbing or descending (terrain following), and
991            // due to wind effects. Use a fixed cruise direction that is updated
992            // periodically instead of relying on the action_nodes code that
993            // tries to align the airship direction with the instantaneous
994            // movement vector.
995            let dir_opt = if direction_override.is_some() {
996                direction_override
997            } else if matches!(
998                phase,
999                AirshipFlightPhase::DepartureCruise | AirshipFlightPhase::ApproachCruise
1000            ) {
1001                airship_context.cruise_direction
1002            } else {
1003                None
1004            };
1005            ctx.controller.do_goto_with_height_and_dir(
1006                nominal_pos,
1007                speed_factor,
1008                height_offset_opt,
1009                dir_opt,
1010                flight_mode,
1011            );
1012        }
1013    })
1014    .repeat()
1015    .boxed()
1016    .stop_if(move |ctx: &mut NpcCtx| {
1017        match phase {
1018            AirshipFlightPhase::Descent | AirshipFlightPhase::Ascent => {
1019                ctx.time.0 >= context_tgt_end_time
1020                    || ctx.npc.wpos.as_::<f64>().distance_squared(to.as_())
1021                        < (goal_dist as f64).powi(2)
1022            },
1023            AirshipFlightPhase::Docked => {
1024                // docking phase has no movement, just wait for the duration
1025                if ctx.time.0 >= context_tgt_end_time {
1026                    debug_airships!(
1027                        4,
1028                        "Airship {} docking phase complete time now {:.1} >= context_tgt_end_time \
1029                         {:.1}",
1030                        format!("{:?}", ctx.npc_id),
1031                        ctx.time.0,
1032                        context_tgt_end_time,
1033                    );
1034                }
1035                ctx.time.0 >= context_tgt_end_time
1036            },
1037            _ => {
1038                if flight_mode == FlightMode::FlyThrough {
1039                    // we only care about the xy distance (just get close to the target position)
1040                    ctx.npc
1041                        .wpos
1042                        .xy()
1043                        .as_::<f64>()
1044                        .distance_squared(to.xy().as_())
1045                        < (goal_dist as f64).powi(2)
1046                } else {
1047                    // Braking mode means the PID controller will be controlling all three axes
1048                    ctx.npc.wpos.as_::<f64>().distance_squared(to.as_())
1049                        < (goal_dist as f64).powi(2)
1050                }
1051            },
1052        }
1053    })
1054    .debug(move || {
1055        format!(
1056            "fly airship, phase:{:?}, tgt pos:({}, {}, {}), goal dist:{}, leg dur: {}, initial \
1057             speed:{}, height:{}, terrain following:{}, FlightMode:{:?}",
1058            phase,
1059            to.x,
1060            to.y,
1061            to.z,
1062            goal_dist,
1063            leg_duration,
1064            speed_factor,
1065            height_offset,
1066            with_terrain_following,
1067            flight_mode,
1068        )
1069    })
1070    .map(|_, _| ())
1071}
1072
1073/// The NPC is the airship captain. This action defines the flight loop for the
1074/// airship. The captain NPC is autonomous and will fly the airship along the
1075/// assigned route. The routes are established and assigned to the captain NPCs
1076/// when the world is generated.
1077pub fn pilot_airship<S: State>() -> impl Action<S> {
1078    now(move |ctx, airship_context: &mut AirshipRouteContext| {
1079        // get the assigned route and start leg indexes
1080        if let Some((route_index, start_leg_index)) =
1081            ctx.data.airship_sim.assigned_routes.get(&ctx.npc_id)
1082        {
1083            // If airship_context.route_index is the default value (usize::MAX) it means the
1084            // server has just started.
1085            let is_initial_startup = airship_context.route_index == usize::MAX;
1086            if is_initial_startup {
1087                setup_airship_route_context(ctx, airship_context, route_index, start_leg_index);
1088            } else {
1089                // Increment the leg index with wrap around
1090                airship_context.current_leg = ctx
1091                    .world
1092                    .civs()
1093                    .airships
1094                    .increment_route_leg(airship_context.route_index, airship_context.current_leg);
1095                if airship_context.current_leg == 0 {
1096                    // We have wrapped around to the start of the route, add the route duration
1097                    // to route_time_zero.
1098                    airship_context.route_time_zero +=
1099                        ctx.world.civs().airships.routes[airship_context.route_index].total_time;
1100                    debug_airships!(
1101                        4,
1102                        "Airship {} route {} completed full route, route time zero now {:.1}, \
1103                         ctx.time.0 - route_time_zero = {:.3}",
1104                        format!("{:?}", ctx.npc_id),
1105                        airship_context.route_index,
1106                        airship_context.route_time_zero,
1107                        ctx.time.0 - airship_context.route_time_zero
1108                    );
1109                } else {
1110                    debug_airships!(
1111                        4,
1112                        "Airship {} route {} starting next leg {}, current route time {:.1}",
1113                        format!("{:?}", ctx.npc_id),
1114                        airship_context.route_index,
1115                        airship_context.current_leg,
1116                        ctx.time.0 - airship_context.route_time_zero
1117                    );
1118                }
1119            }
1120
1121            // set the approach data for the current leg
1122            // Needed: docking position and direction.
1123            airship_context.current_leg_approach =
1124                Some(ctx.world.civs().airships.approach_for_route_and_leg(
1125                    airship_context.route_index,
1126                    airship_context.current_leg,
1127                    &ctx.world.sim().map_size_lg(),
1128                ));
1129
1130            if airship_context.current_leg_approach.is_none() {
1131                tracing::error!(
1132                    "Airship pilot {:?} approach not found for route {} leg {}, stopping \
1133                     pilot_airship loop.",
1134                    ctx.npc_id,
1135                    airship_context.route_index,
1136                    airship_context.current_leg
1137                );
1138                return finish().map(|_, _| ()).boxed();
1139            }
1140
1141            // Get the next leg index.
1142            // The destination of the next leg is needed for announcements while docked.
1143            let next_leg_index = ctx
1144                .world
1145                .civs()
1146                .airships
1147                .increment_route_leg(airship_context.route_index, airship_context.current_leg);
1148            airship_context.next_leg_approach =
1149                Some(ctx.world.civs().airships.approach_for_route_and_leg(
1150                    airship_context.route_index,
1151                    next_leg_index,
1152                    &ctx.world.sim().map_size_lg(),
1153                ));
1154            if airship_context.next_leg_approach.is_none() {
1155                tracing::warn!(
1156                    "Airship pilot {:?} approach not found for next route {} leg {}",
1157                    ctx.npc_id,
1158                    airship_context.route_index,
1159                    next_leg_index
1160                );
1161            }
1162
1163            // The initial flight sequence is used when the server first starts up.
1164            if is_initial_startup {
1165                // Figure out what flight phase to start with.
1166                // Search the route's spawning locations for the one that is
1167                // closest to the airship's current position.
1168                let my_route = &ctx.world.civs().airships.routes[airship_context.route_index];
1169                if let Some(my_spawn_loc) = my_route.spawning_locations.iter().min_by(|a, b| {
1170                    let dist_a = ctx.npc.wpos.xy().as_::<f64>().distance_squared(a.pos.as_());
1171                    let dist_b = ctx.npc.wpos.xy().as_::<f64>().distance_squared(b.pos.as_());
1172                    dist_a.partial_cmp(&dist_b).unwrap_or(Ordering::Equal)
1173                }) {
1174                    // The airship starts somewhere along the route.
1175                    // Adjust the route_zero_time according to the route time in the spawn location
1176                    // data. At initialization, the airship is at the spawn
1177                    // location and the route time is whatever is in the spawn location data.
1178                    airship_context.route_time_zero = ctx.time.0 - my_spawn_loc.spawn_route_time;
1179                    airship_context.leg_ctx_time_begin = ctx.time.0;
1180                    airship_context.first_leg = true;
1181                    debug_airships!(
1182                        4,
1183                        "Airship {} route {} leg {}, initial start up on phase {:?}, setting \
1184                         route_time_zero to {:.1} (ctx.time.0 {} - my_spawn_loc.spawn_route_time \
1185                         {})",
1186                        format!("{:?}", ctx.npc_id),
1187                        airship_context.route_index,
1188                        airship_context.current_leg,
1189                        my_spawn_loc.flight_phase,
1190                        airship_context.route_time_zero,
1191                        ctx.time.0,
1192                        my_spawn_loc.spawn_route_time,
1193                    );
1194                    initial_flight_sequence(my_spawn_loc.flight_phase)
1195                        .map(|_, _| ())
1196                        .boxed()
1197                } else {
1198                    // No spawning location, should not happen
1199                    tracing::error!(
1200                        "Airship pilot {:?} spawning location not found for route {} leg {}",
1201                        ctx.npc_id,
1202                        airship_context.route_index,
1203                        next_leg_index
1204                    );
1205                    finish().map(|_, _| ()).boxed()
1206                }
1207            } else {
1208                nominal_flight_sequence().map(|_, _| ()).boxed()
1209            }
1210        } else {
1211            //  There are no routes assigned.
1212            //  This is unexpected and never happens in testing, just do nothing so the
1213            // compiler doesn't complain.
1214            finish().map(|_, _| ()).boxed()
1215        }
1216    })
1217    .repeat()
1218    .with_state(AirshipRouteContext::default())
1219    .map(|_, _| ())
1220}
1221
1222fn setup_airship_route_context(
1223    _ctx: &mut NpcCtx,
1224    route_context: &mut AirshipRouteContext,
1225    route_index: &usize,
1226    leg_index: &usize,
1227) {
1228    route_context.route_index = *route_index;
1229    route_context.current_leg = *leg_index;
1230
1231    #[cfg(debug_assertions)]
1232    {
1233        let current_approach = _ctx.world.civs().airships.approach_for_route_and_leg(
1234            route_context.route_index,
1235            route_context.current_leg,
1236            &_ctx.world.sim().map_size_lg(),
1237        );
1238        debug_airships!(
1239            4,
1240            "Server startup, airship pilot {:?} starting on route {} leg {}, target dock: {} {}",
1241            _ctx.npc_id,
1242            route_context.route_index,
1243            route_context.current_leg,
1244            current_approach.airship_pos.x as i32,
1245            current_approach.airship_pos.y as i32,
1246        );
1247    }
1248}
1249
1250fn initial_flight_sequence(start_phase: AirshipFlightPhase) -> impl Action<AirshipRouteContext> {
1251    now(move |_, airship_context: &mut AirshipRouteContext| {
1252        let approach = airship_context.current_leg_approach.unwrap();
1253        let phases = match start_phase {
1254            AirshipFlightPhase::DepartureCruise => vec![
1255                (AirshipFlightPhase::DepartureCruise, approach),
1256                (AirshipFlightPhase::ApproachCruise, approach),
1257                (AirshipFlightPhase::Transition, approach),
1258                (AirshipFlightPhase::Descent, approach),
1259                (AirshipFlightPhase::Docked, approach),
1260                (AirshipFlightPhase::Ascent, approach),
1261            ],
1262            AirshipFlightPhase::ApproachCruise => vec![
1263                (AirshipFlightPhase::ApproachCruise, approach),
1264                (AirshipFlightPhase::Transition, approach),
1265                (AirshipFlightPhase::Descent, approach),
1266                (AirshipFlightPhase::Docked, approach),
1267                (AirshipFlightPhase::Ascent, approach),
1268            ],
1269            AirshipFlightPhase::Transition => vec![
1270                (AirshipFlightPhase::Transition, approach),
1271                (AirshipFlightPhase::Descent, approach),
1272                (AirshipFlightPhase::Docked, approach),
1273                (AirshipFlightPhase::Ascent, approach),
1274            ],
1275            AirshipFlightPhase::Descent => vec![
1276                (AirshipFlightPhase::Descent, approach),
1277                (AirshipFlightPhase::Docked, approach),
1278                (AirshipFlightPhase::Ascent, approach),
1279            ],
1280            AirshipFlightPhase::Docked => {
1281                // Adjust the initial docking time.
1282                vec![
1283                    (AirshipFlightPhase::Docked, approach),
1284                    (AirshipFlightPhase::Ascent, approach),
1285                ]
1286            },
1287            AirshipFlightPhase::Ascent => vec![(AirshipFlightPhase::Ascent, approach)],
1288        };
1289        seq(phases
1290            .into_iter()
1291            .map(|(phase, current_approach)| fly_airship(phase, current_approach)))
1292    })
1293}
1294
1295fn nominal_flight_sequence() -> impl Action<AirshipRouteContext> {
1296    now(move |_, airship_context: &mut AirshipRouteContext| {
1297        let approach = airship_context.current_leg_approach.unwrap();
1298        let phases = vec![
1299            (AirshipFlightPhase::DepartureCruise, approach),
1300            (AirshipFlightPhase::ApproachCruise, approach),
1301            (AirshipFlightPhase::Transition, approach),
1302            (AirshipFlightPhase::Descent, approach),
1303            (AirshipFlightPhase::Docked, approach),
1304            (AirshipFlightPhase::Ascent, approach),
1305        ];
1306        seq(phases
1307            .into_iter()
1308            .map(|(phase, current_approach)| fly_airship(phase, current_approach)))
1309    })
1310}
1311
1312#[cfg(feature = "airship_log")]
1313/// Get access to the global airship logger and log an airship position.
1314fn log_airship_position(ctx: &NpcCtx, route_index: usize, phase: &AirshipFlightPhase) {
1315    log_airship_position_plus(ctx, route_index, phase, 0.0, 0.0);
1316}
1317
1318#[cfg(feature = "airship_log")]
1319fn log_airship_position_plus(
1320    ctx: &NpcCtx,
1321    route_index: usize,
1322    phase: &AirshipFlightPhase,
1323    value1: f64,
1324    value2: f64,
1325) {
1326    if let Ok(mut logger) = airship_logger() {
1327        logger.log_position(
1328            ctx.npc_id,
1329            ctx.index.seed,
1330            route_index,
1331            phase,
1332            ctx.time.0,
1333            ctx.npc.wpos,
1334            matches!(ctx.npc.mode, SimulationMode::Loaded),
1335            value1,
1336            value2,
1337        );
1338    } else {
1339        tracing::warn!("Failed to log airship position for {:?}", ctx.npc_id);
1340    }
1341}
1342
1343#[cfg(not(feature = "airship_log"))]
1344/// When the logging feature is not enabled, this should become a no-op.
1345fn log_airship_position(_: &NpcCtx, _: usize, _: &AirshipFlightPhase) {}