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 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 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 stockmap
79 .entry(Good::Recipe)
80 .and_modify(|e| *e = e.max(10_000.0))
81 .or_insert(10_000.0);
82
83 stockmap
88 .entry(Good::Food)
89 .and_modify(|e| *e = e.max(10_000.0))
90 .or_insert(10_000.0);
91 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 stockmap
103 .iter_mut()
104 .filter(|(good, _amount)| **good != Good::Coin)
105 .for_each(|(_good, amount)| *amount *= 0.1);
106 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 .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 .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 {
514 let mut data = rtsim.state.data_mut();
515
516 data.time_of_day = *time_of_day;
518
519 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 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 if rtsim
555 .last_saved
556 .is_none_or(|ls| ls.elapsed() > Duration::from_secs(60))
557 {
558 let _ = slow_jobs;
560 rtsim.save(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 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 .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 for (npc_id, npc) in data.npcs.npcs.iter_mut() {
661 let chunk = npc.wpos.xy().as_::<i32>().wpos_to_cpos();
662
663 if matches!(npc.mode, SimulationMode::Simulated)
666 && chunk_states.0.get(chunk).is_some_and(|c| c.is_some())
667 && 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 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 npc.wpos = pos.0;
689
690 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}