veloren_rtsim/rule/
architect.rs

1use common::{
2    comp::{self, Body},
3    resources::TimeOfDay,
4    rtsim::{Actor, Personality, Profession, Role},
5    terrain::CoordinateConversions,
6};
7use rand::{
8    Rng, rng,
9    seq::{IndexedRandom, IteratorRandom},
10};
11use world::{CONFIG, IndexRef, World, sim::SimChunk, site::SiteKind};
12
13use crate::{
14    Data, EventCtx, OnTick, RtState,
15    data::{
16        Npc,
17        architect::{Death, TrackedPopulation},
18    },
19    event::OnDeath,
20};
21
22use super::{Rule, RuleError};
23
24/// How many ticks the architect skips.
25///
26/// We don't need to run it every tick.
27const ARCHITECT_TICK_SKIP: u64 = 32;
28/// Min spawn delay, in ingame time.
29const MIN_SPAWN_DELAY: f64 = 60.0 * 60.0 * 24.0;
30/// For monsters that respawn in chunks, how many chunks should we try each
31/// respawn.
32const RESPAWN_ATTEMPTS: usize = 30;
33
34pub struct Architect;
35
36impl Rule for Architect {
37    fn start(rtstate: &mut RtState) -> Result<Self, RuleError> {
38        rtstate.bind(on_death);
39        rtstate.bind(architect_tick);
40
41        Ok(Self)
42    }
43}
44
45fn on_death(ctx: EventCtx<Architect, OnDeath>) {
46    let data = &mut *ctx.state.data_mut();
47
48    if let Actor::Npc(npc_id) = ctx.event.actor
49        && let Some(npc) = data.npcs.get(npc_id)
50    {
51        data.architect.on_death(npc, data.time_of_day);
52    }
53}
54
55fn architect_tick(ctx: EventCtx<Architect, OnTick>) {
56    if !ctx.event.tick.is_multiple_of(ARCHITECT_TICK_SKIP) {
57        return;
58    }
59
60    let tod = ctx.event.time_of_day;
61
62    let data = &mut *ctx.state.data_mut();
63
64    let mut rng = rng();
65    let mut count_to_spawn = rng.random_range(1..20);
66
67    let pop = data.architect.population.clone();
68    'outer: for (pop, count) in pop
69        .iter()
70        .zip(data.architect.wanted_population.iter())
71        .filter(|((_, current), (_, wanted))| current < wanted)
72        .map(|((pop, current), (_, wanted))| (pop, wanted - current))
73    {
74        for _ in 0..count {
75            let (body, role) = match pop {
76                TrackedPopulation::Adventurers => (
77                    Body::Humanoid(comp::humanoid::Body::random()),
78                    Role::Civilised(Some(Profession::Adventurer(rng.random_range(0..=3)))),
79                ),
80                TrackedPopulation::Merchants => (
81                    Body::Humanoid(comp::humanoid::Body::random()),
82                    Role::Civilised(Some(Profession::Merchant)),
83                ),
84                TrackedPopulation::Guards => (
85                    Body::Humanoid(comp::humanoid::Body::random()),
86                    Role::Civilised(Some(Profession::Guard)),
87                ),
88                TrackedPopulation::Captains => (
89                    Body::Humanoid(comp::humanoid::Body::random()),
90                    Role::Civilised(Some(Profession::Captain)),
91                ),
92                TrackedPopulation::OtherTownNpcs => (
93                    Body::Humanoid(comp::humanoid::Body::random()),
94                    Role::Civilised(Some(match rng.random_range(0..10) {
95                        0 => Profession::Hunter,
96                        1 => Profession::Blacksmith,
97                        2 => Profession::Chef,
98                        3 => Profession::Alchemist,
99                        4..=5 => Profession::Herbalist,
100                        _ => Profession::Farmer,
101                    })),
102                ),
103                TrackedPopulation::Pirates => (
104                    Body::Humanoid(comp::humanoid::Body::random()),
105                    Role::Civilised(Some(Profession::Pirate(false))),
106                ),
107                TrackedPopulation::PirateCaptains => (
108                    Body::Humanoid(comp::humanoid::Body::random()),
109                    Role::Civilised(Some(Profession::Pirate(true))),
110                ),
111                TrackedPopulation::Cultists => (
112                    Body::Humanoid(comp::humanoid::Body::random()),
113                    Role::Civilised(Some(Profession::Cultist)),
114                ),
115                TrackedPopulation::GigasFrost => (
116                    Body::BipedLarge(comp::biped_large::Body::random_with(
117                        &mut rng,
118                        &comp::biped_large::Species::Gigasfrost,
119                    )),
120                    Role::Monster,
121                ),
122                TrackedPopulation::GigasFire => (
123                    Body::BipedLarge(comp::biped_large::Body::random_with(
124                        &mut rng,
125                        &comp::biped_large::Species::Gigasfire,
126                    )),
127                    Role::Monster,
128                ),
129                TrackedPopulation::OtherMonsters => {
130                    let species = [
131                        comp::biped_large::Species::Ogre,
132                        comp::biped_large::Species::Cyclops,
133                        comp::biped_large::Species::Wendigo,
134                        comp::biped_large::Species::Cavetroll,
135                        comp::biped_large::Species::Mountaintroll,
136                        comp::biped_large::Species::Swamptroll,
137                        comp::biped_large::Species::Blueoni,
138                        comp::biped_large::Species::Redoni,
139                        comp::biped_large::Species::Tursus,
140                    ]
141                    .choose(&mut rng)
142                    .unwrap();
143
144                    (
145                        Body::BipedLarge(comp::biped_large::Body::random_with(&mut rng, species)),
146                        Role::Monster,
147                    )
148                },
149                TrackedPopulation::CloudWyvern => (
150                    Body::BirdLarge(comp::bird_large::Body::random_with(
151                        &mut rng,
152                        &comp::bird_large::Species::CloudWyvern,
153                    )),
154                    Role::Wild,
155                ),
156                TrackedPopulation::FrostWyvern => (
157                    Body::BirdLarge(comp::bird_large::Body::random_with(
158                        &mut rng,
159                        &comp::bird_large::Species::FrostWyvern,
160                    )),
161                    Role::Wild,
162                ),
163                TrackedPopulation::SeaWyvern => (
164                    Body::BirdLarge(comp::bird_large::Body::random_with(
165                        &mut rng,
166                        &comp::bird_large::Species::SeaWyvern,
167                    )),
168                    Role::Wild,
169                ),
170                TrackedPopulation::FlameWyvern => (
171                    Body::BirdLarge(comp::bird_large::Body::random_with(
172                        &mut rng,
173                        &comp::bird_large::Species::FlameWyvern,
174                    )),
175                    Role::Wild,
176                ),
177                TrackedPopulation::WealdWyvern => (
178                    Body::BirdLarge(comp::bird_large::Body::random_with(
179                        &mut rng,
180                        &comp::bird_large::Species::WealdWyvern,
181                    )),
182                    Role::Wild,
183                ),
184                TrackedPopulation::Phoenix => (
185                    Body::BirdLarge(comp::bird_large::Body::random_with(
186                        &mut rng,
187                        &comp::bird_large::Species::Phoenix,
188                    )),
189                    Role::Wild,
190                ),
191                TrackedPopulation::Roc => (
192                    Body::BirdLarge(comp::bird_large::Body::random_with(
193                        &mut rng,
194                        &comp::bird_large::Species::Roc,
195                    )),
196                    Role::Wild,
197                ),
198                TrackedPopulation::Cockatrice => (
199                    Body::BirdLarge(comp::bird_large::Body::random_with(
200                        &mut rng,
201                        &comp::bird_large::Species::Cockatrice,
202                    )),
203                    Role::Wild,
204                ),
205                TrackedPopulation::Other => continue 'outer,
206            };
207
208            let fake_death = Death {
209                time: TimeOfDay(tod.0 - MIN_SPAWN_DELAY),
210                body,
211                role,
212                faction: None,
213            };
214
215            data.architect.population.on_spawn(&fake_death);
216
217            data.architect.deaths.push_front(fake_death);
218        }
219
220        count_to_spawn += count;
221    }
222
223    // @perf: Could reuse previous allocation here.
224    let mut failed_spawn = Vec::new();
225
226    while count_to_spawn > 0
227        && let Some(death) = data.architect.deaths.pop_front()
228    {
229        if data.architect.population.of_death(&death)
230            > data.architect.wanted_population.of_death(&death)
231        {
232            data.architect.population.on_death(&death);
233            // If we have more than enough of this npc, we skip spawning a new one.
234            continue;
235        }
236
237        if death.time.0 + MIN_SPAWN_DELAY > tod.0 {
238            data.architect.deaths.push_front(death);
239            break;
240        }
241
242        if spawn_npc(data, ctx.world, ctx.index, &death) {
243            count_to_spawn -= 1;
244        } else {
245            failed_spawn.push(death);
246        }
247    }
248
249    for death in failed_spawn.into_iter().rev() {
250        data.architect.deaths.push_front(death);
251    }
252}
253
254fn randomize_body(body: Body, rng: &mut impl Rng) -> Body {
255    let mut random_humanoid = || {
256        let species = comp::humanoid::ALL_SPECIES.choose(rng).unwrap();
257        Body::Humanoid(comp::humanoid::Body::random_with(rng, species))
258    };
259    match body {
260        Body::Humanoid(_) => random_humanoid(),
261        body => body,
262    }
263}
264
265fn role_personality(rng: &mut impl Rng, role: &Role) -> Personality {
266    match role {
267        Role::Civilised(profession) => match profession {
268            Some(Profession::Guard | Profession::Merchant | Profession::Captain) => {
269                Personality::random_good(rng)
270            },
271            Some(Profession::Cultist | Profession::Pirate(_)) => Personality::random_evil(rng),
272            None
273            | Some(
274                Profession::Farmer
275                | Profession::Chef
276                | Profession::Hunter
277                | Profession::Blacksmith
278                | Profession::Alchemist
279                | Profession::Herbalist
280                | Profession::Adventurer(_),
281            ) => Personality::random(rng),
282        },
283        Role::Wild => Personality::random(rng),
284        Role::Monster => Personality::random_evil(rng),
285        Role::Vehicle => Personality::default(),
286    }
287}
288
289fn spawn_anywhere(
290    data: &mut Data,
291    world: &World,
292    death: &Death,
293    rng: &mut impl Rng,
294    body: Body,
295    personality: Personality,
296) {
297    let mut attempt = |check: bool| {
298        let cpos = world
299            .sim()
300            .map_size_lg()
301            .chunks()
302            .map(|s| rng.random_range(0..s as i32));
303
304        // TODO: If we had access to `ChunkStates` here we could make sure
305        // these aren't getting respawned in loaded chunks.
306        if let Some(chunk) = world.sim().get(cpos)
307            && (!check || !chunk.is_underwater())
308        {
309            let wpos = cpos.cpos_to_wpos_center();
310            let wpos = wpos.as_().with_z(world.sim().get_surface_alt_approx(wpos));
311
312            data.spawn_npc(
313                Npc::new(rng.random(), wpos, body, death.role.clone())
314                    .with_personality(personality),
315            );
316            return true;
317        }
318
319        false
320    };
321    for _ in 0..RESPAWN_ATTEMPTS {
322        if attempt(true) {
323            return;
324        }
325    }
326    attempt(false);
327}
328
329fn spawn_at_plot(
330    data: &mut Data,
331    world: &World,
332    index: IndexRef,
333    death: &Death,
334    rng: &mut impl Rng,
335    body: Body,
336    personality: Personality,
337    match_plot: impl Fn(&Data, common::rtsim::SiteId, &world::site::Plot) -> bool,
338) -> bool {
339    let sites = &index.sites;
340    let data_ref = &*data;
341    let match_plot = &match_plot;
342    if let Some((id, site, plot)) = data
343        .sites
344        .iter()
345        .filter(|(_, site)| !site.is_loaded())
346        .filter_map(|(id, site)| Some((id, site.world_site?)))
347        .flat_map(|(id, world_site)| {
348            let world_site = sites.get(world_site);
349            world_site
350                .filter_plots(move |plot| match_plot(data_ref, id, plot))
351                .map(move |plot| (id, world_site, plot))
352        })
353        .choose(rng)
354    {
355        let wpos = site.tile_center_wpos(plot.root_tile());
356        let wpos = wpos
357            .as_()
358            .with_z(world.sim().get_alt_approx(wpos).unwrap_or(0.0));
359        let mut npc = Npc::new(rng.random(), wpos, body, death.role.clone())
360            .with_personality(personality)
361            .with_home(id);
362        if let Some(faction) = data.sites[id].faction {
363            npc = npc.with_faction(faction);
364        }
365        data.spawn_npc(npc);
366
367        true
368    } else {
369        false
370    }
371}
372
373fn spawn_profession(
374    data: &mut Data,
375    world: &World,
376    index: IndexRef,
377    death: &Death,
378    rng: &mut impl Rng,
379    body: Body,
380    personality: Personality,
381    profession: Option<Profession>,
382) -> bool {
383    match profession {
384        Some(Profession::Pirate(captain)) => {
385            spawn_at_plot(
386                data,
387                world,
388                index,
389                death,
390                rng,
391                body,
392                personality,
393                |data, s, p| {
394                    // Don't spawn multiple captains at the same site.
395                    if captain
396                        && data.sites[s].population.iter().any(|npc| {
397                            data.npcs.get(*npc).is_some_and(|npc| {
398                                matches!(npc.profession(), Some(Profession::Pirate(true)))
399                            })
400                        })
401                    {
402                        return false;
403                    }
404                    matches!(p.kind(), world::site::PlotKind::PirateHideout(_))
405                },
406            )
407        },
408        _ => spawn_at_plot(
409            data,
410            world,
411            index,
412            death,
413            rng,
414            body,
415            personality,
416            |_, _, p| {
417                matches!(
418                    p.kind().meta(),
419                    Some(world::site::plot::PlotKindMeta::House { .. })
420                )
421            },
422        ),
423    }
424}
425
426fn spawn_npc(data: &mut Data, world: &World, index: IndexRef, death: &Death) -> bool {
427    let mut rng = rng();
428    let body = randomize_body(death.body, &mut rng);
429    let personality = role_personality(&mut rng, &death.role);
430    // First try and respawn in the same faction.
431    let did_spawn = if let Some(faction_id) = death.faction
432        && data.factions.get(faction_id).is_some()
433    {
434        if let Some((id, site)) = data
435            .sites
436            .iter()
437            .filter(|(_, site)| site.faction == Some(faction_id) && !site.is_loaded())
438            .choose(&mut rng)
439        {
440            let wpos = site.wpos;
441            let wpos = wpos
442                .as_()
443                .with_z(world.sim().get_alt_approx(wpos).unwrap_or(0.0));
444            data.spawn_npc(
445                Npc::new(rng.random(), wpos, body, death.role.clone())
446                    .with_personality(personality)
447                    .with_home(id)
448                    .with_faction(faction_id),
449            );
450
451            true
452        } else {
453            false
454        }
455    } else {
456        match &death.role {
457            Role::Civilised(profession) => spawn_profession(
458                data,
459                world,
460                index,
461                death,
462                &mut rng,
463                body,
464                personality,
465                *profession,
466            ),
467            Role::Wild => {
468                let site_filter: fn(&SiteKind) -> bool = match body {
469                    Body::BirdLarge(body) => match body.species {
470                        comp::bird_large::Species::Phoenix => {
471                            |site| matches!(site, SiteKind::DwarvenMine)
472                        },
473                        comp::bird_large::Species::Cockatrice => {
474                            |site| matches!(site, SiteKind::Myrmidon)
475                        },
476                        comp::bird_large::Species::Roc => |site| matches!(site, SiteKind::Haniwa),
477                        comp::bird_large::Species::FlameWyvern => {
478                            |site| matches!(site, SiteKind::Terracotta)
479                        },
480                        comp::bird_large::Species::CloudWyvern => {
481                            |site| matches!(site, SiteKind::Sahagin)
482                        },
483                        comp::bird_large::Species::FrostWyvern => {
484                            |site| matches!(site, SiteKind::Adlet)
485                        },
486                        comp::bird_large::Species::SeaWyvern => {
487                            |site| matches!(site, SiteKind::ChapelSite)
488                        },
489                        comp::bird_large::Species::WealdWyvern => {
490                            |site| matches!(site, SiteKind::GiantTree)
491                        },
492                    },
493                    _ => |_| true,
494                };
495
496                if let Some((id, site)) = data
497                    .sites
498                    .iter()
499                    .filter(|(_, site)| {
500                        !site.is_loaded()
501                            && site
502                                .world_site
503                                .and_then(|s| index.sites.get(s).kind)
504                                .is_some_and(|s| site_filter(&s))
505                    })
506                    .choose(&mut rng)
507                {
508                    let wpos = site.wpos;
509                    let wpos = wpos
510                        .as_()
511                        .with_z(world.sim().get_alt_approx(wpos).unwrap_or(0.0));
512                    data.spawn_npc(
513                        Npc::new(rng.random(), wpos, body, death.role.clone())
514                            .with_personality(personality)
515                            .with_home(id),
516                    );
517                    true
518                } else {
519                    false
520                }
521            },
522            Role::Monster => {
523                let chunk_filter: fn(&SimChunk) -> bool = match body {
524                    Body::BipedLarge(body) => match body.species {
525                        comp::biped_large::Species::Tursus
526                        | comp::biped_large::Species::Gigasfrost
527                        | comp::biped_large::Species::Wendigo => {
528                            |chunk| !chunk.is_underwater() && chunk.temp < CONFIG.snow_temp
529                        },
530                        comp::biped_large::Species::Gigasfire => |chunk| {
531                            !chunk.is_underwater()
532                                && chunk.temp > CONFIG.desert_temp
533                                && chunk.humidity < CONFIG.desert_hum
534                        },
535                        comp::biped_large::Species::Mountaintroll => {
536                            |chunk| !chunk.is_underwater() && chunk.alt > 500.0
537                        },
538                        comp::biped_large::Species::Swamptroll => {
539                            |chunk| !chunk.is_underwater() && chunk.humidity > CONFIG.jungle_hum
540                        },
541                        _ => |chunk| !chunk.is_underwater(),
542                    },
543                    Body::Arthropod(_)
544                    | Body::Humanoid(_)
545                    | Body::QuadrupedSmall(_)
546                    | Body::BipedSmall(_)
547                    | Body::QuadrupedMedium(_)
548                    | Body::Golem(_)
549                    | Body::Theropod(_)
550                    | Body::QuadrupedLow(_) => |chunk| !chunk.is_underwater(),
551                    Body::Dragon(_) | Body::BirdLarge(_) | Body::BirdMedium(_) => |_| true,
552                    Body::Crustacean(_) | Body::FishSmall(_) | Body::FishMedium(_) => {
553                        |chunk| chunk.is_underwater()
554                    },
555                    Body::Object(_) | Body::Ship(_) | Body::Item(_) | Body::Plugin(_) => |_| true,
556                };
557
558                for _ in 0..RESPAWN_ATTEMPTS {
559                    let cpos = world
560                        .sim()
561                        .map_size_lg()
562                        .chunks()
563                        .map(|s| rng.random_range(0..s as i32));
564
565                    // TODO: If we had access to `ChunkStates` here we could make sure
566                    // these aren't getting respawned in loaded chunks.
567                    if let Some(chunk) = world.sim().get(cpos)
568                        && chunk_filter(chunk)
569                    {
570                        let wpos = cpos.cpos_to_wpos_center();
571                        let wpos = wpos.as_().with_z(world.sim().get_surface_alt_approx(wpos));
572
573                        data.spawn_npc(
574                            Npc::new(rng.random(), wpos, body, death.role.clone())
575                                .with_personality(personality),
576                        );
577                        return true;
578                    }
579                }
580
581                false
582            },
583            Role::Vehicle => {
584                // Vehicles don't die as of now.
585                unimplemented!()
586            },
587        }
588    };
589
590    // If enough time has passed, try spawning anyway.
591    if !did_spawn && death.time.0 + MIN_SPAWN_DELAY * 5.0 < data.time_of_day.0 {
592        match death.role {
593            Role::Civilised(profession) => {
594                if !spawn_profession(
595                    data,
596                    world,
597                    index,
598                    death,
599                    &mut rng,
600                    body,
601                    personality,
602                    profession,
603                ) {
604                    spawn_anywhere(data, world, death, &mut rng, body, personality)
605                }
606            },
607            Role::Wild | Role::Monster => {
608                spawn_anywhere(data, world, death, &mut rng, body, personality)
609            },
610            Role::Vehicle => {
611                // Vehicles don't die as of now.
612                unimplemented!()
613            },
614        }
615
616        true
617    } else {
618        did_spawn
619    }
620}