veloren_rtsim/rule/npc_ai/
airship_ai.rs

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