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        let nominal_speed = ctx.world.civs().airships.nominal_speed;
287        let leg_segment = &route_leg.segments[phase as usize];
288        // The actual leg start time was recorded when the previous leg ended.
289        let leg_ctx_time_begin = airship_context.leg_ctx_time_begin;
290
291        /*
292            Duration:
293            - starting leg: leg segment route time - spawning position route time
294            - all other legs: leg segment duration adjusted for early/late start
295            Distance to fly:
296            - Cruise phases: from the current pos to the leg segment target pos
297            - Descent: two steps, targeting above the dock then to the dock,
298              with random variation.
299            - Ascent: from the current pos to cruise height above the dock
300            - Docked: zero distance
301        */
302
303        let fly_duration = if airship_context.first_leg {
304            /*
305               if this is the very first leg for this airship,
306               then the leg duration is the leg segment (end) route time minus the
307               spawn route time.
308
309               airship_context.route_time_zero =
310                   ctx.time.0 - my_spawn_loc.spawn_route_time;
311               Spawn route time = ctx.time - airship_context.route_time_zero
312               dur = leg_segment.route_time - spawn_route_time
313                   = leg_segment.route_time - (ctx.time.0 - airship_context.route_time_zero)
314            */
315            debug_airships!(
316                4,
317                "Airship {} route {} leg {} first leg phase {} from {},{} dur {:.1}s",
318                format!("{:?}", ctx.npc_id),
319                airship_context.route_index,
320                airship_context.current_leg,
321                phase,
322                ctx.npc.wpos.x,
323                ctx.npc.wpos.y,
324                leg_segment.route_time - (ctx.time.0 - airship_context.route_time_zero)
325            );
326            ((leg_segment.route_time - (ctx.time.0 - airship_context.route_time_zero)) as f32)
327                .max(1.0)
328        } else {
329            // Duration is the leg segment duration adjusted for
330            // early/late starts caused by time errors in the
331            // previous leg.
332            let leg_start_route_time =
333                airship_context.leg_ctx_time_begin - airship_context.route_time_zero;
334            // Adjust the leg duration according to actual route start time vs expected
335            // route start time.
336            let expected_leg_route_start_time =
337                leg_segment.route_time - leg_segment.duration as f64;
338            let route_time_err = (leg_start_route_time - expected_leg_route_start_time) as f32;
339            if route_time_err > 10.0 {
340                debug_airships!(
341                    4,
342                    "Airship {} route {} leg {} starting {} late by {:.1}s, rt {:.2}, expected rt \
343                     {:.2}, leg dur {:.1}",
344                    format!("{:?}", ctx.npc_id),
345                    airship_context.route_index,
346                    airship_context.current_leg,
347                    phase,
348                    route_time_err,
349                    leg_start_route_time,
350                    expected_leg_route_start_time,
351                    leg_segment.duration - route_time_err
352                );
353                leg_segment.duration - route_time_err
354            } else if route_time_err < -10.0 {
355                debug_airships!(
356                    4,
357                    "Airship {} route {} leg {} starting {} early by {:.1}s, rt {:.2}, expected \
358                     rt {:.2}, leg dur {:.1}",
359                    format!("{:?}", ctx.npc_id),
360                    airship_context.route_index,
361                    airship_context.current_leg,
362                    phase,
363                    -route_time_err,
364                    leg_start_route_time,
365                    expected_leg_route_start_time,
366                    leg_segment.duration - route_time_err
367                );
368                leg_segment.duration - route_time_err
369            } else {
370                debug_airships!(
371                    4,
372                    "Airship {} route {} leg {} starting {} on time, leg dur {:.1}",
373                    format!("{:?}", ctx.npc_id),
374                    airship_context.route_index,
375                    airship_context.current_leg,
376                    phase,
377                    leg_segment.duration
378                );
379                leg_segment.duration
380            }
381            .max(1.0)
382        };
383
384        let fly_distance = match phase {
385            AirshipFlightPhase::DepartureCruise
386            | AirshipFlightPhase::ApproachCruise
387            | AirshipFlightPhase::Transition => {
388                ctx.npc.wpos.xy().distance(leg_segment.to_world_pos)
389            },
390            AirshipFlightPhase::Descent => ctx.npc.wpos.z - approach.airship_pos.z,
391            AirshipFlightPhase::Ascent => approach.airship_pos.z + approach.height - ctx.npc.wpos.z,
392            AirshipFlightPhase::Docked => 0.0,
393        };
394
395        let context_end_time = ctx.time.0 + fly_duration as f64;
396
397        match phase {
398            AirshipFlightPhase::DepartureCruise => {
399                airship_context.my_stuck_tracker = Some(StuckAirshipTracker::default());
400                airship_context.route_timer = Duration::from_secs_f32(1.0);
401                airship_context.cruise_direction =
402                    Dir::from_unnormalized((approach.midpoint - ctx.npc.wpos.xy()).with_z(0.0));
403                // v = d/t
404                // speed factor = v/nominal_speed = (d/t)/nominal_speed
405                let speed = (fly_distance / fly_duration) / nominal_speed;
406                debug_airships!(
407                    4,
408                    "Airship {} route {} leg {} DepartureCruise, fly_distance {:.1}, fly_duration \
409                     {:.1}s, speed factor {:.3}",
410                    format!("{:?}", ctx.npc_id),
411                    airship_context.route_index,
412                    airship_context.current_leg,
413                    fly_distance,
414                    fly_duration,
415                    speed
416                );
417                // Fly 2D to approach midpoint
418                fly_airship_inner(
419                    AirshipFlightPhase::DepartureCruise,
420                    ctx.npc.wpos,
421                    leg_segment.to_world_pos.with_z(0.0),
422                    50.0,
423                    leg_ctx_time_begin,
424                    fly_duration,
425                    context_end_time,
426                    speed,
427                    approach.height,
428                    true,
429                    None,
430                    FlightMode::FlyThrough,
431                )
432                .then(just(|ctx, airship_context: &mut AirshipRouteContext| {
433                    airship_context.leg_ctx_time_begin = ctx.time.0;
434                    airship_context.first_leg = false;
435                    #[cfg(debug_assertions)]
436                    check_phase_completion_time(
437                        ctx,
438                        airship_context,
439                        0,
440                        AirshipFlightPhase::DepartureCruise,
441                    );
442                }))
443                .map(|_, _| ())
444                .boxed()
445            },
446            AirshipFlightPhase::ApproachCruise => {
447                airship_context.route_timer = Duration::from_secs_f32(1.0);
448                airship_context.cruise_direction = Dir::from_unnormalized(
449                    (approach.approach_transition_pos - approach.midpoint).with_z(0.0),
450                );
451                // v = d/t
452                // speed factor = v/nominal_speed = (d/t)/nominal_speed
453                let speed = (fly_distance / fly_duration) / nominal_speed;
454                debug_airships!(
455                    4,
456                    "Airship {} route {} leg {} ApproachCruise, fly_distance {:.1}, fly_duration \
457                     {:.1}s, speed factor {:.3}",
458                    format!("{:?}", ctx.npc_id),
459                    airship_context.route_index,
460                    airship_context.current_leg,
461                    fly_distance,
462                    fly_duration,
463                    speed
464                );
465                // Fly 2D to transition point
466                fly_airship_inner(
467                    AirshipFlightPhase::ApproachCruise,
468                    ctx.npc.wpos,
469                    leg_segment.to_world_pos.with_z(0.0),
470                    50.0,
471                    leg_ctx_time_begin,
472                    fly_duration,
473                    context_end_time,
474                    speed,
475                    approach.height,
476                    true,
477                    None,
478                    FlightMode::FlyThrough,
479                )
480                .then(just(|ctx, airship_context: &mut AirshipRouteContext| {
481                    airship_context.leg_ctx_time_begin = ctx.time.0;
482                    airship_context.first_leg = false;
483                    #[cfg(debug_assertions)]
484                    check_phase_completion_time(
485                        ctx,
486                        airship_context,
487                        1,
488                        AirshipFlightPhase::ApproachCruise,
489                    );
490                }))
491                .map(|_, _| ())
492                .boxed()
493            },
494            AirshipFlightPhase::Transition => {
495                // let phase_duration = get_phase_duration(ctx, airship_context, route_leg, 2,
496                // phase); let context_end_time = ctx.time.0 + phase_duration;
497                airship_context.route_timer = Duration::from_secs_f32(1.0);
498                // v = d/t
499                // speed factor = v/nominal_speed = (d/t)/nominal_speed
500                let speed = (fly_distance / fly_duration) / nominal_speed;
501                debug_airships!(
502                    4,
503                    "Airship {} route {} leg {} Transition, fly_distance {:.1}, fly_duration \
504                     {:.1}s, speed factor {:.3}",
505                    format!("{:?}", ctx.npc_id),
506                    airship_context.route_index,
507                    airship_context.current_leg,
508                    fly_distance,
509                    fly_duration,
510                    speed
511                );
512                // fly 3D to descent point
513                fly_airship_inner(
514                    AirshipFlightPhase::Transition,
515                    ctx.npc.wpos,
516                    leg_segment
517                        .to_world_pos
518                        .with_z(approach.airship_pos.z + approach.height),
519                    20.0,
520                    leg_ctx_time_begin,
521                    fly_duration,
522                    context_end_time,
523                    speed,
524                    approach.height,
525                    true,
526                    Some(approach.airship_direction),
527                    FlightMode::Braking(BrakingMode::Normal),
528                )
529                .then(just(|ctx, airship_context: &mut AirshipRouteContext| {
530                    airship_context.leg_ctx_time_begin = ctx.time.0;
531                    airship_context.first_leg = false;
532                    #[cfg(debug_assertions)]
533                    check_phase_completion_time(
534                        ctx,
535                        airship_context,
536                        2,
537                        AirshipFlightPhase::Transition,
538                    );
539                }))
540                .map(|_, _| ())
541                .boxed()
542            },
543            AirshipFlightPhase::Descent => {
544                // Descend and Dock
545                airship_context.route_timer = Duration::from_secs_f32(1.0);
546                // v = d/t
547                // speed factor = v/nominal_speed = (d/t)/nominal_speed
548                /*
549                   Divide the descent into two steps with the 2nd step moving slowly
550                   (so more time and less distance) to give more variation.
551                   The total descent distance is fly_distance and the total duration is
552                   fly_duration. However, we want to stop the descent a bit above the dock
553                   so that any overshoot does not carry the airship much below the dock altitude.
554                   Account for the case that fly_distance <= desired_offset.
555                */
556                let desired_offset = ctx.rng.random_range(4.0..6.0);
557                let descent_offset = if fly_distance > desired_offset {
558                    desired_offset
559                } else {
560                    0.0
561                };
562                let descent_dist = fly_distance - descent_offset;
563                // step 1 dist is 60-80% of the descent distance
564                let step1_dist = descent_dist * ctx.rng.random_range(0.7..0.85);
565                let step1_dur = fly_duration * ctx.rng.random_range(0.4..0.55);
566                // Make loaded airships descend slightly faster
567                let speed_mult = if matches!(ctx.npc.mode, SimulationMode::Loaded) {
568                    ctx.rng.random_range(1.1..1.25)
569                } else {
570                    1.0
571                };
572                let speed1 = (step1_dist / step1_dur) / nominal_speed * speed_mult;
573                let speed2 = ((descent_dist - step1_dist) / (fly_duration - step1_dur))
574                    / nominal_speed
575                    * speed_mult;
576                debug_airships!(
577                    4,
578                    "Airship {} route {} leg {} Descent, fly_distance {:.1}, fly_duration {:.1}s, \
579                     desired_offset {:.1}, descent_offset {:.1}, descent_dist {:.1}, step1_dist \
580                     {:.1}, step1_dur {:.3}s, speedmult {:.1}, speed1 {:.3}, speed2 {:.3}",
581                    format!("{:?}", ctx.npc_id),
582                    airship_context.route_index,
583                    airship_context.current_leg,
584                    fly_distance,
585                    fly_duration,
586                    desired_offset,
587                    descent_offset,
588                    descent_dist,
589                    step1_dist,
590                    step1_dur,
591                    speed_mult,
592                    speed1,
593                    speed2
594                );
595
596                // fly 3D to the intermediate descent point
597                fly_airship_inner(
598                    AirshipFlightPhase::Descent,
599                    ctx.npc.wpos,
600                    ctx.npc.wpos - Vec3::unit_z() * step1_dist,
601                    20.0,
602                    leg_ctx_time_begin,
603                    step1_dur,
604                    context_end_time - step1_dur as f64,
605                    speed1,
606                    0.0,
607                    false,
608                    Some(approach.airship_direction),
609                    FlightMode::Braking(BrakingMode::Normal),
610                )
611                .then (fly_airship_inner(
612                    AirshipFlightPhase::Descent,
613                    ctx.npc.wpos - Vec3::unit_z() * step1_dist,
614                    approach.airship_pos + Vec3::unit_z() * descent_offset,
615                    20.0,
616                    leg_ctx_time_begin + step1_dur as f64,
617                    fly_duration - step1_dur,
618                    context_end_time,
619                    speed2,
620                    0.0,
621                    false,
622                    Some(approach.airship_direction),
623                    FlightMode::Braking(BrakingMode::Precise),
624                ))
625                // Announce arrival
626                .then(just(|ctx, airship_context: &mut AirshipRouteContext| {
627                    airship_context.leg_ctx_time_begin = ctx.time.0;
628                    airship_context.first_leg = false;
629                    #[cfg(debug_assertions)]
630                    check_phase_completion_time(ctx, airship_context, 3, AirshipFlightPhase::Descent);
631                    log_airship_position(ctx, airship_context.route_index, &AirshipFlightPhase::Docked);
632                    ctx.controller
633                        .say(None, Content::localized("npc-speech-pilot-landed"));
634                }))
635                .map(|_, _| ()).boxed()
636            },
637            AirshipFlightPhase::Docked => {
638                debug_airships!(
639                    4,
640                    "Airship {} route {} leg {} Docked ctx time {:.1}, \
641                     airship_context.leg_ctx_time_begin {:.1}, docking duration {:.1}s",
642                    format!("{:?}", ctx.npc_id),
643                    airship_context.route_index,
644                    airship_context.current_leg,
645                    leg_ctx_time_begin,
646                    airship_context.leg_ctx_time_begin,
647                    fly_duration,
648                );
649                /*
650                    Divide up the docking time into intervals of 10 to 16 seconds,
651                    and at each interval make an announcement. Make the last announcement
652                    approximately 10-12 seconds before the end of the docking time.
653                    Make the first announcement 5-8 seconds after starting the docking time.
654                    The minimum annoucment time after the start is 5 seconds, and the
655                    minimum time before departure announcement is 10 seconds.
656                    The minimum interval between announcements is 10 seconds.
657
658                    Docked      Announce        Announce           Announce       Depart
659                    |-----------|---------------|----------------------|--------------|
660                    0         5-8s             ...               duration-10-12s
661                        min 5s        min 10           min 10               min 10s
662
663                    If docking duration is less than 10 seconds, no announcements.
664                */
665                let announcement_times = {
666                    let mut times = Vec::new();
667                    if fly_duration > 10.0 {
668                        let first_time = ctx.rng.random_range(5.0..8.0);
669                        let last_time = fly_duration - ctx.rng.random_range(10.0..12.0);
670                        if first_time + 10.0 > last_time {
671                            // Can't do two, try one.
672                            let mid_time = fly_duration / 2.0;
673                            if mid_time > 5.0 && (fly_duration - mid_time) > 10.0 {
674                                times.push(mid_time);
675                            }
676                        } else {
677                            // use first, then fill forward with random 10..16s intervals
678                            times.push(first_time);
679                            let mut last_time = first_time;
680                            let mut t = first_time + ctx.rng.random_range(10.0..16.0);
681                            while t < fly_duration - 10.0 {
682                                times.push(t);
683                                last_time = t;
684                                t += ctx.rng.random_range(10.0..16.0);
685                            }
686                            if last_time < fly_duration - 22.0 {
687                                // add one last announcement before the final 10s
688                                times.push(last_time + 12.0);
689                            }
690                            times.sort_by(|a, b| {
691                                a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
692                            });
693                        }
694                    }
695                    times
696                };
697                airship_context.announcements = announcement_times.into_iter().collect();
698                now(move |ctx, airship_context: &mut AirshipRouteContext| {
699                    // get next announcement time or the docking end time.
700                    // Don't consume the announcement time yet.
701                    let dock_ctx_end_time = if let Some(at) = airship_context.announcements.front()
702                    {
703                        leg_ctx_time_begin + *at as f64
704                    } else {
705                        context_end_time
706                    };
707                    fly_airship_inner(
708                        AirshipFlightPhase::Docked,
709                        ctx.npc.wpos,
710                        approach.airship_pos,
711                        5.0,
712                        leg_ctx_time_begin,
713                        fly_duration,
714                        dock_ctx_end_time,
715                        0.75,
716                        0.0,
717                        false,
718                        Some(approach.airship_direction),
719                        FlightMode::Braking(BrakingMode::Precise),
720                    )
721                    .then(just(
722                        |ctx, airship_context: &mut AirshipRouteContext| {
723                            // Now consume the announcement time. If there was one, announce the
724                            // next site. If not, we're at the end and
725                            // will be exiting this repeat loop.
726                            if airship_context.announcements.pop_front().is_some() {
727                                // make announcement and log position
728                                let (dst_site_name, dst_site_dir) = if let Some(next_leg_approach) =
729                                    airship_context.next_leg_approach
730                                {
731                                    (
732                                        ctx.index
733                                            .sites
734                                            .get(next_leg_approach.site_id)
735                                            .name()
736                                            .unwrap_or("Unknown Site")
737                                            .to_string(),
738                                        Direction::from_dir(
739                                            next_leg_approach.approach_transition_pos
740                                                - ctx.npc.wpos.xy(),
741                                        )
742                                        .localize_npc(),
743                                    )
744                                } else {
745                                    ("Unknown Site".to_string(), Direction::North.localize_npc())
746                                };
747                                ctx.controller.say(
748                                    None,
749                                    Content::localized("npc-speech-pilot-announce_next")
750                                        .with_arg("dir", dst_site_dir)
751                                        .with_arg("dst", dst_site_name),
752                                );
753                                log_airship_position(
754                                    ctx,
755                                    airship_context.route_index,
756                                    &AirshipFlightPhase::Docked,
757                                );
758                            }
759                        },
760                    ))
761                })
762                .repeat()
763                .stop_if(move |ctx: &mut NpcCtx| ctx.time.0 >= context_end_time)
764                .then(just(|ctx, airship_context: &mut AirshipRouteContext| {
765                    airship_context.leg_ctx_time_begin = ctx.time.0;
766                    airship_context.first_leg = false;
767                    #[cfg(debug_assertions)]
768                    check_phase_completion_time(
769                        ctx,
770                        airship_context,
771                        4,
772                        AirshipFlightPhase::Docked,
773                    );
774                    log_airship_position(
775                        ctx,
776                        airship_context.route_index,
777                        &AirshipFlightPhase::Docked,
778                    );
779                }))
780                .map(|_, _| ())
781                .boxed()
782            },
783            AirshipFlightPhase::Ascent => {
784                log_airship_position(
785                    ctx,
786                    airship_context.route_index,
787                    &AirshipFlightPhase::Ascent,
788                );
789                airship_context.route_timer = Duration::from_secs_f32(0.5);
790                // v = d/t
791                let speed = (fly_distance / fly_duration) / nominal_speed;
792                debug_airships!(
793                    4,
794                    "Airship {} route {} leg {} Ascent, fly_distance {:.1}, fly_duration {:.1}s, \
795                     speed factor {:.3}",
796                    format!("{:?}", ctx.npc_id),
797                    airship_context.route_index,
798                    airship_context.current_leg,
799                    fly_distance,
800                    fly_duration,
801                    speed
802                );
803                let src_site_name = ctx
804                    .index
805                    .sites
806                    .get(approach.site_id)
807                    .name()
808                    .unwrap_or("Unknown Site")
809                    .to_string();
810                let dst_site_name =
811                    if let Some(next_leg_approach) = airship_context.next_leg_approach {
812                        ctx.index
813                            .sites
814                            .get(next_leg_approach.site_id)
815                            .name()
816                            .unwrap_or("Unknown Site")
817                            .to_string()
818                    } else {
819                        "Unknown Site".to_string()
820                    };
821                ctx.controller.say(
822                    None,
823                    Content::localized("npc-speech-pilot-takeoff")
824                        .with_arg("src", src_site_name)
825                        .with_arg("dst", dst_site_name),
826                );
827                fly_airship_inner(
828                    AirshipFlightPhase::Ascent,
829                    ctx.npc.wpos,
830                    approach.airship_pos + Vec3::unit_z() * approach.height,
831                    20.0,
832                    leg_ctx_time_begin,
833                    fly_duration,
834                    context_end_time,
835                    speed,
836                    0.0,
837                    false,
838                    Some(approach.airship_direction),
839                    FlightMode::Braking(BrakingMode::Normal),
840                )
841                .then(just(|ctx, airship_context: &mut AirshipRouteContext| {
842                    airship_context.leg_ctx_time_begin = ctx.time.0;
843                    airship_context.first_leg = false;
844                    #[cfg(debug_assertions)]
845                    check_phase_completion_time(
846                        ctx,
847                        airship_context,
848                        5,
849                        AirshipFlightPhase::Ascent,
850                    );
851                }))
852                .map(|_, _| ())
853                .boxed()
854            },
855        }
856    })
857}
858
859/// The action that moves the airship.
860fn fly_airship_inner(
861    phase: AirshipFlightPhase,
862    from: Vec3<f32>,
863    to: Vec3<f32>,
864    goal_dist: f32,
865    leg_ctx_time_begin: f64,
866    leg_duration: f32,
867    context_tgt_end_time: f64,
868    speed_factor: f32,
869    height_offset: f32,
870    with_terrain_following: bool,
871    direction_override: Option<Dir>,
872    flight_mode: FlightMode,
873) -> impl Action<AirshipRouteContext> {
874    just(move |ctx, airship_context: &mut AirshipRouteContext| {
875        // The target position is used for determining the
876        // reverse direction for 'unsticking' the airship if it gets stuck in
877        // one place.
878        let stuck_tracker_target_loc = to.xy();
879
880        // Determine where the airship should be.
881        let nominal_pos = match phase {
882            AirshipFlightPhase::DepartureCruise
883            | AirshipFlightPhase::ApproachCruise
884            | AirshipFlightPhase::Transition => {
885                // Flying 2d, compute nominal x,y pos.
886                let route_interpolation_ratio =
887                    ((ctx.time.0 - leg_ctx_time_begin) as f32 / leg_duration).clamp(0.0, 1.0);
888                (from.xy() + (to.xy() - from.xy()) * route_interpolation_ratio).with_z(to.z)
889            },
890            AirshipFlightPhase::Descent | AirshipFlightPhase::Ascent => {
891                // Only Z movement, compute z pos.
892                // Starting altitude is terrain altitude + height offset
893                // Ending altitude is to.z
894                let route_interpolation_ratio =
895                    ((ctx.time.0 - leg_ctx_time_begin) as f32 / leg_duration).clamp(0.0, 1.0);
896                let nominal_z = from.z + (to.z - from.z) * route_interpolation_ratio;
897                to.xy().with_z(nominal_z)
898            },
899            _ => {
900                // docking phase has no movement
901                to
902            },
903        };
904
905        // Periodically check if the airship is stuck.
906        let timer = airship_context
907            .route_timer
908            .checked_sub(Duration::from_secs_f32(ctx.dt));
909        // keep or reset the timer
910        airship_context.route_timer =
911            timer.unwrap_or(Duration::from_secs_f32(AIRSHIP_PROGRESS_UPDATE_INTERVAL));
912        if timer.is_none() {
913            // Timer expired.
914            // log my position
915            #[cfg(feature = "airship_log")]
916            {
917                // Check position error
918                let distance_to_nominal = match phase {
919                    AirshipFlightPhase::DepartureCruise
920                    | AirshipFlightPhase::ApproachCruise
921                    | AirshipFlightPhase::Transition => {
922                        // Flying 2d, compute nominal x,y pos.
923                        ctx.npc
924                            .wpos
925                            .xy()
926                            .as_::<f64>()
927                            .distance(nominal_pos.xy().as_())
928                    },
929                    _ => ctx.npc.wpos.as_::<f64>().distance(nominal_pos.as_()),
930                };
931                log_airship_position_plus(
932                    ctx,
933                    airship_context.route_index,
934                    &phase,
935                    distance_to_nominal,
936                    0.0,
937                );
938            }
939
940            // If in cruise phase, check if the airship is stuck and reset the cruise
941            // direction.
942            if matches!(
943                phase,
944                AirshipFlightPhase::DepartureCruise | AirshipFlightPhase::ApproachCruise
945            ) {
946                // Check if we're stuck
947                if let Some(stuck_tracker) = &mut airship_context.my_stuck_tracker
948                    && stuck_tracker.is_stuck(ctx, &ctx.npc.wpos, &stuck_tracker_target_loc)
949                    && let Some(backout_pos) = stuck_tracker.current_backout_pos(ctx)
950                {
951                    airship_context.stuck_backout_pos = Some(backout_pos);
952                } else if airship_context.stuck_backout_pos.is_some() {
953                    #[cfg(debug_assertions)]
954                    debug_airships!(
955                        2,
956                        "{:?} unstuck at pos: {} {}",
957                        ctx.npc_id,
958                        ctx.npc.wpos.x as i32,
959                        ctx.npc.wpos.y as i32,
960                    );
961                    airship_context.stuck_backout_pos = None;
962                };
963                // Reset cruise direction
964                airship_context.cruise_direction =
965                    Dir::from_unnormalized((to.xy() - ctx.npc.wpos.xy()).with_z(0.0));
966            }
967        }
968        // move the airship
969        if let Some(backout_pos) = airship_context.stuck_backout_pos {
970            // Unstick the airship
971            ctx.controller.do_goto_with_height_and_dir(
972                backout_pos,
973                1.5,
974                None,
975                None,
976                FlightMode::Braking(BrakingMode::Normal),
977            );
978        } else {
979            // Normal movement, not stuck.
980            let height_offset_opt = if with_terrain_following {
981                Some(height_offset)
982            } else {
983                None
984            };
985            // In the long cruise phases, the airship should face the target position.
986            // When the airship is loaded, the movement vector can change dramatically from
987            // velocity differences due to climbing or descending (terrain following), and
988            // due to wind effects. Use a fixed cruise direction that is updated
989            // periodically instead of relying on the action_nodes code that
990            // tries to align the airship direction with the instantaneous
991            // movement vector.
992            let dir_opt = if direction_override.is_some() {
993                direction_override
994            } else if matches!(
995                phase,
996                AirshipFlightPhase::DepartureCruise | AirshipFlightPhase::ApproachCruise
997            ) {
998                airship_context.cruise_direction
999            } else {
1000                None
1001            };
1002            ctx.controller.do_goto_with_height_and_dir(
1003                nominal_pos,
1004                speed_factor,
1005                height_offset_opt,
1006                dir_opt,
1007                flight_mode,
1008            );
1009        }
1010    })
1011    .repeat()
1012    .boxed()
1013    .stop_if(move |ctx: &mut NpcCtx| {
1014        match phase {
1015            AirshipFlightPhase::Descent | AirshipFlightPhase::Ascent => {
1016                ctx.time.0 >= context_tgt_end_time
1017                    || ctx.npc.wpos.as_::<f64>().distance_squared(to.as_())
1018                        < (goal_dist as f64).powi(2)
1019            },
1020            AirshipFlightPhase::Docked => {
1021                // docking phase has no movement, just wait for the duration
1022                if ctx.time.0 >= context_tgt_end_time {
1023                    debug_airships!(
1024                        4,
1025                        "Airship {} docking phase complete time now {:.1} >= context_tgt_end_time \
1026                         {:.1}",
1027                        format!("{:?}", ctx.npc_id),
1028                        ctx.time.0,
1029                        context_tgt_end_time,
1030                    );
1031                }
1032                ctx.time.0 >= context_tgt_end_time
1033            },
1034            _ => {
1035                if flight_mode == FlightMode::FlyThrough {
1036                    // we only care about the xy distance (just get close to the target position)
1037                    ctx.npc
1038                        .wpos
1039                        .xy()
1040                        .as_::<f64>()
1041                        .distance_squared(to.xy().as_())
1042                        < (goal_dist as f64).powi(2)
1043                } else {
1044                    // Braking mode means the PID controller will be controlling all three axes
1045                    ctx.npc.wpos.as_::<f64>().distance_squared(to.as_())
1046                        < (goal_dist as f64).powi(2)
1047                }
1048            },
1049        }
1050    })
1051    .debug(move || {
1052        format!(
1053            "fly airship, phase:{:?}, tgt pos:({}, {}, {}), goal dist:{}, leg dur: {}, initial \
1054             speed:{}, height:{}, terrain following:{}, FlightMode:{:?}",
1055            phase,
1056            to.x,
1057            to.y,
1058            to.z,
1059            goal_dist,
1060            leg_duration,
1061            speed_factor,
1062            height_offset,
1063            with_terrain_following,
1064            flight_mode,
1065        )
1066    })
1067    .map(|_, _| ())
1068}
1069
1070/// The NPC is the airship captain. This action defines the flight loop for the
1071/// airship. The captain NPC is autonomous and will fly the airship along the
1072/// assigned route. The routes are established and assigned to the captain NPCs
1073/// when the world is generated.
1074pub fn pilot_airship<S: State>() -> impl Action<S> {
1075    now(move |ctx, airship_context: &mut AirshipRouteContext| {
1076        // get the assigned route and start leg indexes
1077        if let Some((route_index, start_leg_index)) =
1078            ctx.data.airship_sim.assigned_routes.get(&ctx.npc_id)
1079        {
1080            // If airship_context.route_index is the default value (usize::MAX) it means the
1081            // server has just started.
1082            let is_initial_startup = airship_context.route_index == usize::MAX;
1083            if is_initial_startup {
1084                setup_airship_route_context(ctx, airship_context, route_index, start_leg_index);
1085            } else {
1086                // Increment the leg index with wrap around
1087                airship_context.current_leg = ctx
1088                    .world
1089                    .civs()
1090                    .airships
1091                    .increment_route_leg(airship_context.route_index, airship_context.current_leg);
1092                if airship_context.current_leg == 0 {
1093                    // We have wrapped around to the start of the route, add the route duration
1094                    // to route_time_zero.
1095                    airship_context.route_time_zero +=
1096                        ctx.world.civs().airships.routes[airship_context.route_index].total_time;
1097                    debug_airships!(
1098                        4,
1099                        "Airship {} route {} completed full route, route time zero now {:.1}, \
1100                         ctx.time.0 - route_time_zero = {:.3}",
1101                        format!("{:?}", ctx.npc_id),
1102                        airship_context.route_index,
1103                        airship_context.route_time_zero,
1104                        ctx.time.0 - airship_context.route_time_zero
1105                    );
1106                } else {
1107                    debug_airships!(
1108                        4,
1109                        "Airship {} route {} starting next leg {}, current route time {:.1}",
1110                        format!("{:?}", ctx.npc_id),
1111                        airship_context.route_index,
1112                        airship_context.current_leg,
1113                        ctx.time.0 - airship_context.route_time_zero
1114                    );
1115                }
1116            }
1117
1118            // set the approach data for the current leg
1119            // Needed: docking position and direction.
1120            airship_context.current_leg_approach =
1121                Some(ctx.world.civs().airships.approach_for_route_and_leg(
1122                    airship_context.route_index,
1123                    airship_context.current_leg,
1124                    &ctx.world.sim().map_size_lg(),
1125                ));
1126
1127            if airship_context.current_leg_approach.is_none() {
1128                tracing::error!(
1129                    "Airship pilot {:?} approach not found for route {} leg {}, stopping \
1130                     pilot_airship loop.",
1131                    ctx.npc_id,
1132                    airship_context.route_index,
1133                    airship_context.current_leg
1134                );
1135                return finish().map(|_, _| ()).boxed();
1136            }
1137
1138            // Get the next leg index.
1139            // The destination of the next leg is needed for announcements while docked.
1140            let next_leg_index = ctx
1141                .world
1142                .civs()
1143                .airships
1144                .increment_route_leg(airship_context.route_index, airship_context.current_leg);
1145            airship_context.next_leg_approach =
1146                Some(ctx.world.civs().airships.approach_for_route_and_leg(
1147                    airship_context.route_index,
1148                    next_leg_index,
1149                    &ctx.world.sim().map_size_lg(),
1150                ));
1151            if airship_context.next_leg_approach.is_none() {
1152                tracing::warn!(
1153                    "Airship pilot {:?} approach not found for next route {} leg {}",
1154                    ctx.npc_id,
1155                    airship_context.route_index,
1156                    next_leg_index
1157                );
1158            }
1159
1160            // The initial flight sequence is used when the server first starts up.
1161            if is_initial_startup {
1162                // Figure out what flight phase to start with.
1163                // Search the route's spawning locations for the one that is
1164                // closest to the airship's current position.
1165                let my_route = &ctx.world.civs().airships.routes[airship_context.route_index];
1166                if let Some(my_spawn_loc) = my_route.spawning_locations.iter().min_by(|a, b| {
1167                    let dist_a = ctx.npc.wpos.xy().as_::<f64>().distance_squared(a.pos.as_());
1168                    let dist_b = ctx.npc.wpos.xy().as_::<f64>().distance_squared(b.pos.as_());
1169                    dist_a.partial_cmp(&dist_b).unwrap_or(Ordering::Equal)
1170                }) {
1171                    // The airship starts somewhere along the route.
1172                    // Adjust the route_zero_time according to the route time in the spawn location
1173                    // data. At initialization, the airship is at the spawn
1174                    // location and the route time is whatever is in the spawn location data.
1175                    airship_context.route_time_zero = ctx.time.0 - my_spawn_loc.spawn_route_time;
1176                    airship_context.leg_ctx_time_begin = ctx.time.0;
1177                    airship_context.first_leg = true;
1178                    debug_airships!(
1179                        4,
1180                        "Airship {} route {} leg {}, initial start up on phase {:?}, setting \
1181                         route_time_zero to {:.1} (ctx.time.0 {} - my_spawn_loc.spawn_route_time \
1182                         {})",
1183                        format!("{:?}", ctx.npc_id),
1184                        airship_context.route_index,
1185                        airship_context.current_leg,
1186                        my_spawn_loc.flight_phase,
1187                        airship_context.route_time_zero,
1188                        ctx.time.0,
1189                        my_spawn_loc.spawn_route_time,
1190                    );
1191                    initial_flight_sequence(my_spawn_loc.flight_phase)
1192                        .map(|_, _| ())
1193                        .boxed()
1194                } else {
1195                    // No spawning location, should not happen
1196                    tracing::error!(
1197                        "Airship pilot {:?} spawning location not found for route {} leg {}",
1198                        ctx.npc_id,
1199                        airship_context.route_index,
1200                        next_leg_index
1201                    );
1202                    finish().map(|_, _| ()).boxed()
1203                }
1204            } else {
1205                nominal_flight_sequence().map(|_, _| ()).boxed()
1206            }
1207        } else {
1208            //  There are no routes assigned.
1209            //  This is unexpected and never happens in testing, just do nothing so the
1210            // compiler doesn't complain.
1211            finish().map(|_, _| ()).boxed()
1212        }
1213    })
1214    .repeat()
1215    .with_state(AirshipRouteContext::default())
1216    .map(|_, _| ())
1217}
1218
1219fn setup_airship_route_context(
1220    _ctx: &mut NpcCtx,
1221    route_context: &mut AirshipRouteContext,
1222    route_index: &usize,
1223    leg_index: &usize,
1224) {
1225    route_context.route_index = *route_index;
1226    route_context.current_leg = *leg_index;
1227
1228    #[cfg(debug_assertions)]
1229    {
1230        let current_approach = _ctx.world.civs().airships.approach_for_route_and_leg(
1231            route_context.route_index,
1232            route_context.current_leg,
1233            &_ctx.world.sim().map_size_lg(),
1234        );
1235        debug_airships!(
1236            4,
1237            "Server startup, airship pilot {:?} starting on route {} leg {}, target dock: {} {}",
1238            _ctx.npc_id,
1239            route_context.route_index,
1240            route_context.current_leg,
1241            current_approach.airship_pos.x as i32,
1242            current_approach.airship_pos.y as i32,
1243        );
1244    }
1245}
1246
1247fn initial_flight_sequence(start_phase: AirshipFlightPhase) -> impl Action<AirshipRouteContext> {
1248    now(move |_, airship_context: &mut AirshipRouteContext| {
1249        let approach = airship_context.current_leg_approach.unwrap();
1250        let phases = match start_phase {
1251            AirshipFlightPhase::DepartureCruise => vec![
1252                (AirshipFlightPhase::DepartureCruise, approach),
1253                (AirshipFlightPhase::ApproachCruise, approach),
1254                (AirshipFlightPhase::Transition, approach),
1255                (AirshipFlightPhase::Descent, approach),
1256                (AirshipFlightPhase::Docked, approach),
1257                (AirshipFlightPhase::Ascent, approach),
1258            ],
1259            AirshipFlightPhase::ApproachCruise => vec![
1260                (AirshipFlightPhase::ApproachCruise, approach),
1261                (AirshipFlightPhase::Transition, approach),
1262                (AirshipFlightPhase::Descent, approach),
1263                (AirshipFlightPhase::Docked, approach),
1264                (AirshipFlightPhase::Ascent, approach),
1265            ],
1266            AirshipFlightPhase::Transition => vec![
1267                (AirshipFlightPhase::Transition, approach),
1268                (AirshipFlightPhase::Descent, approach),
1269                (AirshipFlightPhase::Docked, approach),
1270                (AirshipFlightPhase::Ascent, approach),
1271            ],
1272            AirshipFlightPhase::Descent => vec![
1273                (AirshipFlightPhase::Descent, approach),
1274                (AirshipFlightPhase::Docked, approach),
1275                (AirshipFlightPhase::Ascent, approach),
1276            ],
1277            AirshipFlightPhase::Docked => {
1278                // Adjust the initial docking time.
1279                vec![
1280                    (AirshipFlightPhase::Docked, approach),
1281                    (AirshipFlightPhase::Ascent, approach),
1282                ]
1283            },
1284            AirshipFlightPhase::Ascent => vec![(AirshipFlightPhase::Ascent, approach)],
1285        };
1286        seq(phases
1287            .into_iter()
1288            .map(|(phase, current_approach)| fly_airship(phase, current_approach)))
1289    })
1290}
1291
1292fn nominal_flight_sequence() -> impl Action<AirshipRouteContext> {
1293    now(move |_, airship_context: &mut AirshipRouteContext| {
1294        let approach = airship_context.current_leg_approach.unwrap();
1295        let phases = vec![
1296            (AirshipFlightPhase::DepartureCruise, approach),
1297            (AirshipFlightPhase::ApproachCruise, approach),
1298            (AirshipFlightPhase::Transition, approach),
1299            (AirshipFlightPhase::Descent, approach),
1300            (AirshipFlightPhase::Docked, approach),
1301            (AirshipFlightPhase::Ascent, approach),
1302        ];
1303        seq(phases
1304            .into_iter()
1305            .map(|(phase, current_approach)| fly_airship(phase, current_approach)))
1306    })
1307}
1308
1309#[cfg(feature = "airship_log")]
1310/// Get access to the global airship logger and log an airship position.
1311fn log_airship_position(ctx: &NpcCtx, route_index: usize, phase: &AirshipFlightPhase) {
1312    log_airship_position_plus(ctx, route_index, phase, 0.0, 0.0);
1313}
1314
1315#[cfg(feature = "airship_log")]
1316fn log_airship_position_plus(
1317    ctx: &NpcCtx,
1318    route_index: usize,
1319    phase: &AirshipFlightPhase,
1320    value1: f64,
1321    value2: f64,
1322) {
1323    if let Ok(mut logger) = airship_logger() {
1324        logger.log_position(
1325            ctx.npc_id,
1326            ctx.index.seed,
1327            route_index,
1328            phase,
1329            ctx.time.0,
1330            ctx.npc.wpos,
1331            matches!(ctx.npc.mode, SimulationMode::Loaded),
1332            value1,
1333            value2,
1334        );
1335    } else {
1336        tracing::warn!("Failed to log airship position for {:?}", ctx.npc_id);
1337    }
1338}
1339
1340#[cfg(not(feature = "airship_log"))]
1341/// When the logging feature is not enabled, this should become a no-op.
1342fn log_airship_position(_: &NpcCtx, _: usize, _: &AirshipFlightPhase) {}