Skip to main content

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