veloren_server/rtsim/
tick.rs

1use super::*;
2use crate::{ServerConstants, sys::terrain::SpawnEntityData};
3use common::{
4    LoadoutBuilder,
5    calendar::Calendar,
6    comp::{
7        self, Body, Item, Presence, PresenceKind,
8        inventory::trade_pricing::TradePricing,
9        item::{ItemDefinitionIdOwned, Quality},
10        slot::ArmorSlot,
11    },
12    event::{CreateNpcEvent, CreateShipEvent, DeleteEvent, EventBus, NpcBuilder},
13    generation::{BodyBuilder, EntityConfig, EntityInfo},
14    resources::{DeltaTime, Time, TimeOfDay},
15    rtsim::{Actor, NpcId, RtSimEntity},
16    slowjob::SlowJobPool,
17    terrain::CoordinateConversions,
18    trade::{Good, SiteInformation},
19    uid::IdMaps,
20    util::Dir,
21    weather::WeatherGrid,
22};
23use common_ecs::{Job, Origin, Phase, System};
24use rand::RngExt;
25use rtsim::{
26    ai::NpcSystemData,
27    data::{
28        Npc, Sites,
29        npc::{Profession, SimulationMode},
30    },
31};
32use specs::{Entities, Join, LendJoin, Read, ReadExpect, ReadStorage, WriteExpect, WriteStorage};
33use std::{
34    ops::Range,
35    sync::{Arc, Mutex},
36    time::Duration,
37};
38use tracing::error;
39
40pub fn trader_loadout(
41    loadout_builder: LoadoutBuilder,
42    economy: Option<&SiteInformation>,
43    permitted: impl FnMut(Good) -> bool,
44    mut permitted_quality: impl FnMut(Quality) -> bool,
45    coin_range: Range<f32>,
46) -> LoadoutBuilder {
47    let rng = &mut rand::rng();
48    let mut backpack = Item::new_from_asset_expect("common.items.armor.misc.back.backpack");
49    let mut bag1 = Item::new_from_asset_expect("common.items.armor.misc.bag.sturdy_red_backpack");
50    let mut bag2 = Item::new_from_asset_expect("common.items.armor.misc.bag.sturdy_red_backpack");
51    let mut bag3 = Item::new_from_asset_expect("common.items.armor.misc.bag.sturdy_red_backpack");
52    let mut bag4 = Item::new_from_asset_expect("common.items.armor.misc.bag.sturdy_red_backpack");
53    let slots = backpack.slots().len() + 4 * bag1.slots().len();
54    let mut stockmap: hashbrown::HashMap<Good, f32> = economy
55        .map(|e| e.unconsumed_stock.clone().into_iter().collect())
56        .unwrap_or_default();
57    // modify stock for better gameplay
58    stockmap
59        .entry(Good::Ingredients)
60        .and_modify(|e| {
61            *e = e.max(10_000.0);
62            *e *= 100_000.0;
63            *e = e.min(2_000_000.0);
64        })
65        .or_insert(1_000_000.0);
66
67    // economy isn't economying sometimes
68    stockmap
69        .entry(Good::Wood)
70        .and_modify(|e| {
71            *e = e.max(10_000.0);
72            *e *= 100_000.0;
73            *e = e.min(2_000_000.0);
74        })
75        .or_insert(1_000_000.0);
76
77    // econsim doesn't produce recipes at all
78    stockmap
79        .entry(Good::Recipe)
80        .and_modify(|e| *e = e.max(10_000.0))
81        .or_insert(10_000.0);
82
83    // TODO: currently econsim spends all its food on population, resulting in none
84    // for the players to buy; the `.max` is temporary to ensure that there's some
85    // food for sale at every site, to be used until we have some solution like NPC
86    // houses as a limit on econsim population growth
87    stockmap
88        .entry(Good::Food)
89        .and_modify(|e| *e = e.max(10_000.0))
90        .or_insert(10_000.0);
91    // Reduce amount of potions so merchants do not oversupply potions.
92    // TODO: Maybe remove when merchants and their inventories are rtsim?
93    // Note: Likely without effect now that potions are counted as food
94    stockmap
95        .entry(Good::Potions)
96        .and_modify(|e| *e = e.powf(0.25));
97    stockmap
98        .entry(Good::Coin)
99        .and_modify(|e| *e = e.min(rng.random_range(coin_range)));
100
101    // assume roughly 10 merchants sharing a town's stock (other logic for coins)
102    stockmap
103        .iter_mut()
104        .filter(|(good, _amount)| **good != Good::Coin)
105        .for_each(|(_good, amount)| *amount *= 0.1);
106    // Fill bags with stuff according to unclaimed stock
107    let ability_map = &comp::tool::AbilityMap::load().read();
108    let msm = &comp::item::MaterialStatManifest::load().read();
109
110    let mut allow_item = |n: ItemDefinitionIdOwned, a: &u32| -> Option<Item> {
111        let i = Item::new_from_item_definition_id(n.as_ref(), ability_map, msm).ok();
112        if !permitted_quality(i.as_ref()?.quality()) {
113            return None;
114        }
115        i.map(|mut i| {
116            i.set_amount(*a)
117                .map_err(|_| tracing::error!("merchant loadout amount failure"))
118                .ok();
119            i
120        })
121    };
122
123    let mut wares: Vec<Item> =
124        TradePricing::random_items(&mut stockmap, slots as u32, true, true, 16, permitted)
125            .into_iter()
126            .filter_map(|(n, a)| allow_item(n, &a))
127            .collect();
128    sort_wares(&mut wares);
129    transfer(&mut wares, &mut backpack);
130    transfer(&mut wares, &mut bag1);
131    transfer(&mut wares, &mut bag2);
132    transfer(&mut wares, &mut bag3);
133    transfer(&mut wares, &mut bag4);
134
135    loadout_builder
136        .back(Some(backpack))
137        .bag(ArmorSlot::Bag1, Some(bag1))
138        .bag(ArmorSlot::Bag2, Some(bag2))
139        .bag(ArmorSlot::Bag3, Some(bag3))
140        .bag(ArmorSlot::Bag4, Some(bag4))
141}
142
143fn sort_wares(bag: &mut [Item]) {
144    use common::comp::item::TagExampleInfo;
145
146    bag.sort_by(|a, b| {
147        a.quality()
148            .cmp(&b.quality())
149        // sort by kind
150        .then(
151            Ord::cmp(
152                a.tags().first().map_or("", |tag| tag.name()),
153                b.tags().first().map_or("", |tag| tag.name()),
154            )
155        )
156        // sort by name
157        // TODO: figure out the better way here
158        .then(#[expect(deprecated)] Ord::cmp(&a.legacy_name(), &b.legacy_name()))
159    });
160}
161
162fn transfer(wares: &mut Vec<Item>, bag: &mut Item) {
163    let capacity = bag.slots().len();
164    for (s, w) in bag
165        .slots_mut()
166        .iter_mut()
167        .zip(wares.drain(0..wares.len().min(capacity)))
168    {
169        *s = Some(w);
170    }
171}
172
173fn humanoid_config(profession: &Profession) -> &'static str {
174    match profession {
175        Profession::Farmer => "common.entity.village.farmer",
176        Profession::Hunter => "common.entity.village.hunter",
177        Profession::Herbalist => "common.entity.village.herbalist",
178        Profession::Captain => "common.entity.village.captain",
179        Profession::Merchant => "common.entity.village.merchant",
180        Profession::Guard => "common.entity.village.guard",
181        Profession::Adventurer(rank) => match rank {
182            0 => "common.entity.world.traveler0",
183            1 => "common.entity.world.traveler1",
184            2 => "common.entity.world.traveler2",
185            3 => "common.entity.world.traveler3",
186            _ => {
187                error!(
188                    "Tried to get configuration for invalid adventurer rank {}",
189                    rank
190                );
191                "common.entity.world.traveler3"
192            },
193        },
194        Profession::Blacksmith => "common.entity.village.blacksmith",
195        Profession::Chef => "common.entity.village.chef",
196        Profession::Alchemist => "common.entity.village.alchemist",
197        Profession::Pirate(leader) => match leader {
198            false => "common.entity.spot.pirate",
199            true => "common.entity.spot.buccaneer",
200        },
201        Profession::Cultist => "common.entity.dungeon.cultist.cultist",
202    }
203}
204
205fn loadout_default(
206    loadout: LoadoutBuilder,
207    _economy: Option<&SiteInformation>,
208    _time: Option<&(TimeOfDay, Calendar)>,
209) -> LoadoutBuilder {
210    loadout
211}
212
213fn merchant_loadout(
214    loadout_builder: LoadoutBuilder,
215    economy: Option<&SiteInformation>,
216    _time: Option<&(TimeOfDay, Calendar)>,
217) -> LoadoutBuilder {
218    trader_loadout(
219        loadout_builder,
220        economy,
221        |good| {
222            !matches!(
223                good,
224                Good::Ingredients | Good::Tools | Good::Armor | Good::Wood
225            )
226        },
227        |quality| matches!(quality, Quality::Low | Quality::Common | Quality::Moderate),
228        1000.0..3000.0,
229    )
230}
231
232fn farmer_loadout(
233    loadout_builder: LoadoutBuilder,
234    economy: Option<&SiteInformation>,
235    _time: Option<&(TimeOfDay, Calendar)>,
236) -> LoadoutBuilder {
237    trader_loadout(
238        loadout_builder,
239        economy,
240        |good| matches!(good, Good::Food | Good::Coin),
241        |quality| matches!(quality, Quality::Low | Quality::Common | Quality::Moderate),
242        200.0..300.0,
243    )
244}
245
246fn herbalist_loadout(
247    loadout_builder: LoadoutBuilder,
248    economy: Option<&SiteInformation>,
249    _time: Option<&(TimeOfDay, Calendar)>,
250) -> LoadoutBuilder {
251    trader_loadout(
252        loadout_builder,
253        economy,
254        |good| {
255            matches!(
256                good,
257                Good::Potions | Good::Stone | Good::Wood | Good::Ingredients | Good::Coin
258            )
259        },
260        |quality| matches!(quality, Quality::Low | Quality::Common | Quality::Moderate),
261        200.0..300.0,
262    )
263}
264
265fn chef_loadout(
266    loadout_builder: LoadoutBuilder,
267    economy: Option<&SiteInformation>,
268    _time: Option<&(TimeOfDay, Calendar)>,
269) -> LoadoutBuilder {
270    trader_loadout(
271        loadout_builder,
272        economy,
273        |good| matches!(good, Good::Food | Good::Coin),
274        |quality| matches!(quality, Quality::Low | Quality::Common | Quality::Moderate),
275        200.0..300.0,
276    )
277}
278
279fn blacksmith_loadout(
280    loadout_builder: LoadoutBuilder,
281    economy: Option<&SiteInformation>,
282    _time: Option<&(TimeOfDay, Calendar)>,
283) -> LoadoutBuilder {
284    trader_loadout(
285        loadout_builder,
286        economy,
287        |good| matches!(good, Good::Armor | Good::Coin),
288        |quality| matches!(quality, Quality::Low | Quality::Common | Quality::Moderate),
289        200.0..300.0,
290    )
291}
292
293fn hunter_loadout(
294    loadout_builder: LoadoutBuilder,
295    economy: Option<&SiteInformation>,
296    _time: Option<&(TimeOfDay, Calendar)>,
297) -> LoadoutBuilder {
298    trader_loadout(
299        loadout_builder,
300        economy,
301        |good| matches!(good, Good::Tools | Good::Coin),
302        |quality| matches!(quality, Quality::Low | Quality::Common | Quality::Moderate),
303        200.0..300.0,
304    )
305}
306
307fn alchemist_loadout(
308    loadout_builder: LoadoutBuilder,
309    economy: Option<&SiteInformation>,
310    _time: Option<&(TimeOfDay, Calendar)>,
311) -> LoadoutBuilder {
312    trader_loadout(
313        loadout_builder,
314        economy,
315        |good| {
316            matches!(
317                good,
318                Good::Potions | Good::Stone | Good::Wood | Good::Ingredients | Good::Coin
319            )
320        },
321        |quality| matches!(quality, Quality::Low | Quality::Common | Quality::Moderate),
322        200.0..300.0,
323    )
324}
325
326fn profession_extra_loadout(
327    profession: Option<&Profession>,
328) -> fn(
329    LoadoutBuilder,
330    Option<&SiteInformation>,
331    time: Option<&(TimeOfDay, Calendar)>,
332) -> LoadoutBuilder {
333    match profession {
334        Some(Profession::Merchant) => merchant_loadout,
335        Some(Profession::Farmer) => farmer_loadout,
336        Some(Profession::Herbalist) => herbalist_loadout,
337        Some(Profession::Chef) => chef_loadout,
338        Some(Profession::Blacksmith) => blacksmith_loadout,
339        Some(Profession::Alchemist) => alchemist_loadout,
340        Some(Profession::Hunter) => hunter_loadout,
341        _ => loadout_default,
342    }
343}
344
345fn profession_agent_mark(profession: Option<&Profession>) -> Option<comp::agent::Mark> {
346    match profession {
347        Some(
348            Profession::Merchant
349            | Profession::Farmer
350            | Profession::Herbalist
351            | Profession::Chef
352            | Profession::Blacksmith
353            | Profession::Hunter
354            | Profession::Alchemist,
355        ) => Some(comp::agent::Mark::Merchant),
356        Some(Profession::Guard) => Some(comp::agent::Mark::Guard),
357        _ => None,
358    }
359}
360
361fn get_npc_entity_info(
362    npc: &Npc,
363    sites: &Sites,
364    index: IndexRef,
365    time: Option<&(TimeOfDay, Calendar)>,
366) -> EntityInfo {
367    let pos = comp::Pos(npc.wpos);
368
369    let mut rng = npc.rng(Npc::PERM_ENTITY_CONFIG);
370    if let Some(profession) = npc.profession() {
371        let economy = npc.home.and_then(|home| {
372            let site = sites.get(home)?.world_site?;
373            index.sites.get(site).trade_information(site)
374        });
375
376        let config_asset = humanoid_config(&profession);
377
378        let entity_config = EntityConfig::from_asset_expect_owned(config_asset)
379            .with_body(BodyBuilder::Exact(npc.body));
380        EntityInfo::at(pos.0)
381            .with_entity_config(entity_config, Some(config_asset), &mut rng, time)
382            .with_alignment(
383                if matches!(profession, Profession::Cultist | Profession::Pirate(_)) {
384                    comp::Alignment::Enemy
385                } else {
386                    comp::Alignment::Npc
387                },
388            )
389            .with_economy(economy.as_ref())
390            .with_lazy_loadout(profession_extra_loadout(Some(&profession)))
391            .with_alias(npc.get_name())
392            .with_agent_mark(profession_agent_mark(Some(&profession)))
393    } else {
394        let config_asset = match npc.body {
395            Body::BirdLarge(body) => match body.species {
396                comp::bird_large::Species::Phoenix => "common.entity.wild.aggressive.phoenix",
397                comp::bird_large::Species::Cockatrice => "common.entity.wild.aggressive.cockatrice",
398                comp::bird_large::Species::Roc => "common.entity.wild.aggressive.roc",
399                comp::bird_large::Species::CloudWyvern => {
400                    "common.entity.wild.aggressive.cloudwyvern"
401                },
402                comp::bird_large::Species::FlameWyvern => {
403                    "common.entity.wild.aggressive.flamewyvern"
404                },
405                comp::bird_large::Species::FrostWyvern => {
406                    "common.entity.wild.aggressive.frostwyvern"
407                },
408                comp::bird_large::Species::SeaWyvern => "common.entity.wild.aggressive.seawyvern",
409                comp::bird_large::Species::WealdWyvern => {
410                    "common.entity.wild.aggressive.wealdwyvern"
411                },
412            },
413            Body::BipedLarge(body) => match body.species {
414                comp::biped_large::Species::Ogre => "common.entity.wild.aggressive.ogre",
415                comp::biped_large::Species::Cyclops => "common.entity.wild.aggressive.cyclops",
416                comp::biped_large::Species::Wendigo => "common.entity.wild.aggressive.wendigo",
417                comp::biped_large::Species::Werewolf => "common.entity.wild.aggressive.werewolf",
418                comp::biped_large::Species::Cavetroll => "common.entity.wild.aggressive.cave_troll",
419                comp::biped_large::Species::Mountaintroll => {
420                    "common.entity.wild.aggressive.mountain_troll"
421                },
422                comp::biped_large::Species::Swamptroll => {
423                    "common.entity.wild.aggressive.swamp_troll"
424                },
425                comp::biped_large::Species::Blueoni => "common.entity.wild.aggressive.blue_oni",
426                comp::biped_large::Species::Redoni => "common.entity.wild.aggressive.red_oni",
427                comp::biped_large::Species::Tursus => "common.entity.wild.aggressive.tursus",
428                comp::biped_large::Species::Gigasfrost => {
429                    "common.entity.world.world_bosses.gigas_frost"
430                },
431                comp::biped_large::Species::Gigasfire => {
432                    "common.entity.world.world_bosses.gigas_fire"
433                },
434                species => unimplemented!("rtsim spawning for {:?}", species),
435            },
436            body => unimplemented!("rtsim spawning for {:?}", body),
437        };
438        let entity_config = EntityConfig::from_asset_expect_owned(config_asset)
439            .with_body(BodyBuilder::Exact(npc.body));
440
441        EntityInfo::at(pos.0).with_entity_config(entity_config, Some(config_asset), &mut rng, time)
442    }
443}
444
445#[derive(Default)]
446pub struct Sys;
447impl<'a> System<'a> for Sys {
448    type SystemData = (
449        Entities<'a>,
450        Read<'a, DeltaTime>,
451        Read<'a, Time>,
452        Read<'a, TimeOfDay>,
453        Read<'a, EventBus<CreateShipEvent>>,
454        Read<'a, EventBus<CreateNpcEvent>>,
455        Read<'a, EventBus<DeleteEvent>>,
456        WriteExpect<'a, RtSim>,
457        ReadExpect<'a, Arc<world::World>>,
458        ReadExpect<'a, world::IndexOwned>,
459        ReadExpect<'a, SlowJobPool>,
460        ReadStorage<'a, comp::Pos>,
461        ReadStorage<'a, RtSimEntity>,
462        WriteStorage<'a, comp::Agent>,
463        ReadStorage<'a, Presence>,
464        ReadExpect<'a, Calendar>,
465        Read<'a, IdMaps>,
466        ReadExpect<'a, ServerConstants>,
467        ReadExpect<'a, WeatherGrid>,
468        WriteStorage<'a, comp::Inventory>,
469        WriteExpect<'a, comp::gizmos::RtsimGizmos>,
470        ReadExpect<'a, comp::tool::AbilityMap>,
471        ReadExpect<'a, comp::item::MaterialStatManifest>,
472    );
473
474    const NAME: &'static str = "rtsim::tick";
475    const ORIGIN: Origin = Origin::Server;
476    const PHASE: Phase = Phase::Create;
477
478    fn run(
479        _job: &mut Job<Self>,
480        (
481            entities,
482            dt,
483            time,
484            time_of_day,
485            create_ship_events,
486            create_npc_events,
487            delete_events,
488            mut rtsim,
489            world,
490            index,
491            slow_jobs,
492            positions,
493            rtsim_entities,
494            mut agents,
495            presences,
496            calendar,
497            id_maps,
498            server_constants,
499            weather_grid,
500            inventories,
501            rtsim_gizmos,
502            ability_map,
503            msm,
504        ): Self::SystemData,
505    ) {
506        let mut create_ship_emitter = create_ship_events.emitter();
507        let mut create_npc_emitter = create_npc_events.emitter();
508        let mut delete_emitter = delete_events.emitter();
509        let rtsim = &mut *rtsim;
510        let calendar_data = (*time_of_day, (*calendar).clone());
511
512        // Set up rtsim inputs
513        {
514            let mut data = rtsim.state.data_mut();
515
516            // Update time of day
517            data.time_of_day = *time_of_day;
518
519            // Update character map (i.e: so that rtsim knows where players are)
520            // TODO: Other entities too like animals? Or do we now care about that?
521            data.npcs.character_map.clear();
522            for (presence, wpos) in (&presences, &positions).join() {
523                if let PresenceKind::Character(character) = &presence.kind {
524                    let chunk_pos = wpos.0.xy().as_().wpos_to_cpos();
525                    data.npcs
526                        .character_map
527                        .entry(chunk_pos)
528                        .or_default()
529                        .push((*character, wpos.0));
530                }
531            }
532        }
533
534        // Tick rtsim
535        rtsim.state.tick(
536            &mut NpcSystemData {
537                positions: positions.clone(),
538                id_maps,
539                server_constants,
540                weather_grid,
541                inventories: Mutex::new(inventories),
542                rtsim_gizmos,
543                ability_map,
544                msm,
545            },
546            &world,
547            index.as_index_ref(),
548            *time_of_day,
549            *time,
550            dt.0,
551        );
552
553        // Perform a save if required
554        if rtsim
555            .last_saved
556            .is_none_or(|ls| ls.elapsed() > Duration::from_secs(60))
557        {
558            // TODO: Use slow jobs
559            let _ = slow_jobs;
560            rtsim.save(/* &slow_jobs, */ false);
561        }
562
563        let chunk_states = rtsim.state.resource::<ChunkStates>();
564        let data = &mut *rtsim.state.data_mut();
565
566        let mut create_event = |id: NpcId, npc: &Npc, steering: Option<NpcBuilder>| match npc.body {
567            Body::Ship(body) => {
568                create_ship_emitter.emit(CreateShipEvent {
569                    pos: comp::Pos(npc.wpos),
570                    ori: comp::Ori::from(Dir::new(npc.dir.with_z(0.0))),
571                    ship: body,
572                    rtsim_entity: Some(id),
573                    driver: steering,
574                });
575            },
576            _ => {
577                let entity_info = get_npc_entity_info(
578                    npc,
579                    &data.sites,
580                    index.as_index_ref(),
581                    Some(&calendar_data),
582                );
583
584                let (mut npc_builder, pos) = SpawnEntityData::from_entity_info(entity_info)
585                    .into_npc_data_inner()
586                    .expect("Entity loaded from assets cannot be special")
587                    .to_npc_builder();
588
589                if let Some(agent) = &mut npc_builder.agent {
590                    agent.rtsim_outbox = Some(Default::default());
591                }
592
593                if let Some(health) = &mut npc_builder.health {
594                    health.set_fraction(npc.health_fraction);
595                }
596
597                create_npc_emitter.emit(CreateNpcEvent {
598                    pos,
599                    ori: comp::Ori::from(Dir::new(npc.dir.with_z(0.0))),
600                    npc: npc_builder.with_rtsim(id).with_rider(steering),
601                });
602            },
603        };
604
605        // Load in mounted npcs and their riders
606        for mount in data.npcs.mounts.iter_mounts() {
607            let mount_npc = data.npcs.npcs.get_mut(mount).expect("This should exist");
608            let chunk = mount_npc.wpos.xy().as_::<i32>().wpos_to_cpos();
609
610            if matches!(mount_npc.mode, SimulationMode::Simulated)
611                && chunk_states.0.get(chunk).is_some_and(|c| c.is_some())
612            {
613                mount_npc.mode = SimulationMode::Loaded;
614
615                let mut actor_info = |actor: Actor| {
616                    let npc_id = actor.npc()?;
617                    let npc = data.npcs.npcs.get_mut(npc_id)?;
618                    if matches!(npc.mode, SimulationMode::Simulated) {
619                        npc.mode = SimulationMode::Loaded;
620                        let entity_info = get_npc_entity_info(
621                            npc,
622                            &data.sites,
623                            index.as_index_ref(),
624                            Some(&calendar_data),
625                        );
626
627                        let mut npc_builder = SpawnEntityData::from_entity_info(entity_info)
628                            .into_npc_data_inner()
629                            // EntityConfig can't represent Waypoints at all
630                            // as of now, and if someone will try to spawn
631                            // rtsim waypoint it is definitely error.
632                            .expect("Entity loaded from assets cannot be special")
633                            .to_npc_builder()
634                            .0
635                            .with_rtsim(npc_id);
636
637                        if let Some(agent) = &mut npc_builder.agent {
638                            agent.rtsim_outbox = Some(Default::default());
639                        }
640
641                        Some(npc_builder)
642                    } else {
643                        error!("Npc is loaded but vehicle is unloaded");
644                        None
645                    }
646                };
647
648                let steerer = data
649                    .npcs
650                    .mounts
651                    .get_steerer_link(mount)
652                    .and_then(|link| actor_info(link.rider));
653
654                let mount_npc = data.npcs.npcs.get(mount).expect("This should exist");
655                create_event(mount, mount_npc, steerer);
656            }
657        }
658
659        // Load in NPCs
660        for (npc_id, npc) in data.npcs.npcs.iter_mut() {
661            let chunk = npc.wpos.xy().as_::<i32>().wpos_to_cpos();
662
663            // Load the NPC into the world if it's in a loaded chunk and is not already
664            // loaded
665            if matches!(npc.mode, SimulationMode::Simulated)
666                && chunk_states.0.get(chunk).is_some_and(|c| c.is_some())
667                // Riding npcs will be spawned by the vehicle.
668                && data.npcs.mounts.get_mount_link(npc_id).is_none()
669            {
670                npc.mode = SimulationMode::Loaded;
671                create_event(npc_id, npc, None);
672            }
673        }
674
675        // Synchronise rtsim NPC with entity data
676        for (entity, pos, rtsim_entity, agent) in (
677            &entities,
678            &positions,
679            &rtsim_entities,
680            (&mut agents).maybe(),
681        )
682            .join()
683        {
684            if let Some(npc) = data.npcs.get_mut(*rtsim_entity) {
685                match npc.mode {
686                    SimulationMode::Loaded => {
687                        // Update rtsim NPC state
688                        npc.wpos = pos.0;
689
690                        // Update entity state
691                        if let Some(agent) = agent {
692                            agent.rtsim_controller.personality = npc.personality;
693                            agent.rtsim_controller.look_dir = npc.controller.look_dir;
694                            agent.rtsim_controller.activity = npc.controller.activity;
695                            agent
696                                .rtsim_controller
697                                .actions
698                                .extend(std::mem::take(&mut npc.controller.actions));
699                            if let Some(rtsim_outbox) = &mut agent.rtsim_outbox {
700                                npc.inbox.append(rtsim_outbox);
701                            }
702                        }
703                    },
704                    SimulationMode::Simulated => {
705                        delete_emitter.emit(DeleteEvent(entity));
706                    },
707                }
708            }
709        }
710    }
711}