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