Skip to main content

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,
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::ActorId,
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::ActorSystemData,
27    data::{
28        Sites,
29        actor::{Actor, Presence, 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        500.0..1400.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        500.0..1400.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        500.0..1400.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        500.0..1400.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        500.0..1400.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        500.0..1400.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_actor_entity_info(
362    actor: &Actor,
363    sites: &Sites,
364    index: IndexRef,
365    time: Option<&(TimeOfDay, Calendar)>,
366) -> EntityInfo {
367    let pos = comp::Pos(actor.wpos);
368
369    let mut rng = actor.rng(Actor::PERM_ENTITY_CONFIG);
370    if let Some(profession) = actor.profession() {
371        let economy = actor.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(actor.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(actor.get_name())
392            .with_agent_mark(profession_agent_mark(Some(&profession)))
393    } else {
394        let config_asset = match actor.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(actor.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, ActorId>,
462        WriteStorage<'a, comp::Agent>,
463        ReadStorage<'a, Body>,
464        ReadExpect<'a, Calendar>,
465        ReadExpect<'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_actors,
494            mut agents,
495            bodies,
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
520        // Tick rtsim
521        rtsim.state.tick(
522            &mut ActorSystemData {
523                positions: positions.clone(),
524                id_maps,
525                server_constants,
526                weather_grid,
527                inventories: Mutex::new(inventories),
528                rtsim_gizmos,
529                ability_map,
530                msm,
531            },
532            &world,
533            index.as_index_ref(),
534            *time_of_day,
535            *time,
536            dt.0,
537        );
538
539        // Perform a save if required
540        if rtsim
541            .last_saved
542            .is_none_or(|ls| ls.elapsed() > Duration::from_secs(60))
543        {
544            // TODO: Use slow jobs
545            let _ = slow_jobs;
546            rtsim.save(/* &slow_jobs, */ false);
547        }
548
549        let chunk_states = rtsim.state.resource::<ChunkStates>();
550        let data = &mut *rtsim.state.data_mut();
551
552        let mut create_event =
553            |actor_id: ActorId, actor: &Actor, steering: Option<NpcBuilder>| match actor.body {
554                Body::Ship(body) => {
555                    create_ship_emitter.emit(CreateShipEvent {
556                        pos: comp::Pos(actor.wpos),
557                        ori: comp::Ori::from(Dir::new(actor.dir.with_z(0.0))),
558                        ship: body,
559                        rtsim_actor: Some(actor_id),
560                        driver: steering,
561                    });
562                },
563                _ => {
564                    let entity_info = get_actor_entity_info(
565                        actor,
566                        &data.sites,
567                        index.as_index_ref(),
568                        Some(&calendar_data),
569                    );
570
571                    let (mut npc_builder, pos) = SpawnEntityData::from_entity_info(entity_info)
572                        .into_npc_data_inner()
573                        .expect("Entity loaded from assets cannot be special")
574                        .to_npc_builder();
575
576                    if let Some(agent) = &mut npc_builder.agent {
577                        agent.rtsim_outbox = Some(Default::default());
578                    }
579
580                    if let Some(health) = &mut npc_builder.health
581                        && let Some(presence) = &actor.presence
582                    {
583                        health.set_fraction(presence.health_fraction);
584                    }
585
586                    create_npc_emitter.emit(CreateNpcEvent {
587                        pos,
588                        ori: comp::Ori::from(Dir::new(actor.dir.with_z(0.0))),
589                        npc: npc_builder.with_rtsim(actor_id).with_rider(steering),
590                    });
591                },
592            };
593
594        // Load in mounted npcs and their riders
595        // Note that players *should* never be loaded because they are never
596        // `SimulationMode::Simulated`.
597        for mount in data.actors.mounts.iter_mounts() {
598            let mount_actor = data
599                .actors
600                .actors
601                .get_mut(mount)
602                .expect("This should exist");
603            let chunk = mount_actor.wpos.xy().as_::<i32>().wpos_to_cpos();
604
605            if matches!(mount_actor.mode, SimulationMode::Simulated)
606                && chunk_states.0.get(chunk).is_some_and(|c| c.is_some())
607            {
608                mount_actor.mode = SimulationMode::Loaded;
609
610                let mut actor_info = |actor_id: ActorId| {
611                    let actor = data.actors.actors.get_mut(actor_id)?;
612                    if matches!(actor.mode, SimulationMode::Simulated) {
613                        actor.mode = SimulationMode::Loaded;
614                        let entity_info = get_actor_entity_info(
615                            actor,
616                            &data.sites,
617                            index.as_index_ref(),
618                            Some(&calendar_data),
619                        );
620
621                        let mut npc_builder = SpawnEntityData::from_entity_info(entity_info)
622                            .into_npc_data_inner()
623                            // EntityConfig can't represent Waypoints at all
624                            // as of now, and if someone will try to spawn
625                            // rtsim waypoint it is definitely error.
626                            .expect("Entity loaded from assets cannot be special")
627                            .to_npc_builder()
628                            .0
629                            .with_rtsim(actor_id);
630
631                        if let Some(agent) = &mut npc_builder.agent {
632                            agent.rtsim_outbox = Some(Default::default());
633                        }
634
635                        Some(npc_builder)
636                    } else {
637                        error!("Npc is loaded but vehicle is unloaded");
638                        None
639                    }
640                };
641
642                let steerer = data
643                    .actors
644                    .mounts
645                    .get_steerer_link(mount)
646                    .and_then(|link| actor_info(link.rider));
647
648                let mount_actor = data.actors.actors.get(mount).expect("This should exist");
649                create_event(mount, mount_actor, steerer);
650            }
651        }
652
653        // Load in NPCs.
654        for (actor_id, actor) in data.actors.actors.iter_mut() {
655            let chunk = actor.wpos.xy().as_::<i32>().wpos_to_cpos();
656
657            // Load the NPC into the world if it's in a loaded chunk and is not already
658            // loaded
659            if matches!(actor.mode, SimulationMode::Simulated)
660                && chunk_states.0.get(chunk).is_some_and(|c| c.is_some())
661                // Riding npcs will be spawned by the vehicle.
662                && data.actors.mounts.get_mount_link(actor_id).is_none()
663            {
664                actor.mode = SimulationMode::Loaded;
665                // Don't 'spawn in' players, their entities are managed by the server
666                if actor.character().is_none() {
667                    create_event(actor_id, actor, None);
668                }
669            }
670        }
671
672        // Synchronise rtsim NPC with entity data
673        for (entity, pos, body, rtsim_actor, mut agent) in (
674            &entities,
675            &positions,
676            &bodies,
677            &rtsim_actors,
678            (&mut agents).maybe(),
679        )
680            .join()
681        {
682            if let Some(actor) = data.actors.get_mut(*rtsim_actor) {
683                // Update actor state
684                actor.wpos = pos.0;
685                actor.body = *body;
686
687                // For players, let rtsim know that the character is present in the world
688                if let Some(character) = actor.character_mut() {
689                    character.last_present_at = Some(data.tick);
690                    actor.presence.get_or_insert(Presence {
691                        // TODO: This isn't correct, use the actual health %
692                        health_fraction: 1.0,
693                    });
694                }
695
696                // TODO: Update other attributes here
697
698                match actor.mode {
699                    SimulationMode::Loaded => {
700                        // Update entity state
701                        if let Some(agent) = &mut agent
702                            && let Some(npc) = actor.npc_mut()
703                        {
704                            agent.rtsim_controller.personality = npc.personality;
705                            agent.rtsim_controller.look_dir = npc.controller.look_dir;
706                            agent.rtsim_controller.activity = npc.controller.activity;
707                            agent
708                                .rtsim_controller
709                                .actions
710                                .extend(std::mem::take(&mut npc.controller.actions));
711                            if let Some(rtsim_outbox) = &mut agent.rtsim_outbox {
712                                npc.inbox.append(rtsim_outbox);
713                            }
714                        }
715                    },
716                    SimulationMode::Simulated => {
717                        // Never try to 'dissolve' players back into the rtsim matrix
718                        if actor.character().is_none() {
719                            delete_emitter.emit(DeleteEvent(entity));
720                        }
721                    },
722                }
723            }
724        }
725    }
726}