veloren_server/rtsim/
tick.rs

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