diff --git a/CHANGELOG.md b/CHANGELOG.md index bc26f9f8bc..c03f670b7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added + - Command to toggle experimental shaders. - Faster Energy Regeneration while sitting. - Lantern glow for dropped lanterns. @@ -25,8 +26,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Setting in userdata/server/server_config/settings.ron that controls the length of each day/night cycle. - Starting site can now be chosen during character creation - Durability loss of equipped items on death +- Reputation system: crimes will be remembered and NPCs will tell each other about crimes they witness +- NPCs will now talk to players and to each other +- NPCs now have dedicated professions and will act accordingly +- NPCs other than merchants can be traded with +- NPCs will seek out a place to sleep when night comes +- Merchants now travel between towns +- Travellers and merchants will stay a while in each town they visit and converse with the locals +- Resource tracking: resources in the world can be temporarily exhausted, requiring time to replenish +- Airships now have pilot NPCs +- Simulated NPCs now have repopulation mechanics +- NPCs now have unique names +- A /scale command that can be used to change the in-game scale of players +- Merchants will flog their wares in towns, encouraging nearby character to buy goods from them ### Changed + - Bats move slower and use a simple proportional controller to maintain altitude - Bats now have less health - Climbing no longer requires having 10 energy @@ -34,11 +49,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Sword - Rescaling of images for the UI is now done when sampling from them on the GPU. Improvements are particularily noticeable when opening the map screen (which involves rescaling a few large - images) and also when using the voxel minimap view (where a medium size image is updated often). + images) and also when using the voxel minimap view (where a medium size image is updated often). ### Removed ### Fixed + - Doors - Debug hitboxes now scale with the `Scale` component - Potion quaffing no longer makes characters practically immortal. @@ -49,6 +65,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed various issues with showing the correct text hint for interactable blocks. - Intert entities like arrows no longer obstruct interacting with nearby entities/blocks. - Underwater fall damage +- The scale component now behaves properly ## [0.14.0] - 2023-01-07 diff --git a/Cargo.lock b/Cargo.lock index ae40df69e3..84bf7dc083 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,6 +145,12 @@ version = "1.0.65" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98161a4e3e2184da77bb14f02184cdd111e83bbbcc9979dfee3c44b9a85f5602" +[[package]] +name = "anymap2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" + [[package]] name = "app_dirs2" version = "2.5.4" @@ -1839,6 +1845,27 @@ dependencies = [ "syn 1.0.100", ] +[[package]] +name = "enum-map" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a56d54c8dd9b3ad34752ed197a4eb2a6601bc010808eb097a04a58ae4c43e1" +dependencies = [ + "enum-map-derive", + "serde", +] + +[[package]] +name = "enum-map-derive" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9045e2676cd5af83c3b167d917b0a5c90a4d8e266e2683d6631b235c457fc27" +dependencies = [ + "proc-macro2 1.0.43", + "quote 1.0.21", + "syn 1.0.100", +] + [[package]] name = "enumset" version = "1.0.11" @@ -4297,6 +4324,12 @@ dependencies = [ "regex", ] +[[package]] +name = "paste" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9423e2b32f7a043629287a536f21951e8c6a82482d0acb1eeebfc90bc2225b22" + [[package]] name = "peeking_take_while" version = "0.1.2" @@ -5075,6 +5108,28 @@ dependencies = [ "syn 1.0.100", ] +[[package]] +name = "rmp" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44519172358fd6d58656c86ab8e7fbc9e1490c3e8f14d35ed78ca0dd07403c9f" +dependencies = [ + "byteorder", + "num-traits", + "paste", +] + +[[package]] +name = "rmp-serde" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25786b0d276110195fa3d6f3f31299900cf71dfbd6c28450f3f58a0e7f7a347e" +dependencies = [ + "byteorder", + "rmp", + "serde", +] + [[package]] name = "rodio" version = "0.15.0" @@ -6637,6 +6692,7 @@ dependencies = [ "serde", "tracing", "unic-langid", + "veloren-common", "veloren-common-assets", ] @@ -6655,6 +6711,7 @@ dependencies = [ "crossbeam-utils 0.8.11", "csv", "dot_vox", + "enum-map", "fxhash", "hashbrown 0.12.3", "indexmap", @@ -6884,6 +6941,29 @@ dependencies = [ "veloren-plugin-derive", ] +[[package]] +name = "veloren-rtsim" +version = "0.10.0" +dependencies = [ + "anymap2", + "atomic_refcell", + "enum-map", + "fxhash", + "hashbrown 0.12.3", + "itertools", + "rand 0.8.5", + "rand_chacha 0.3.1", + "rayon", + "rmp-serde", + "ron 0.8.0", + "serde", + "slotmap 1.0.6", + "tracing", + "vek 0.15.8", + "veloren-common", + "veloren-world", +] + [[package]] name = "veloren-server" version = "0.14.0" @@ -6896,6 +6976,7 @@ dependencies = [ "chrono-tz", "crossbeam-channel", "drop_guard", + "enum-map", "enumset", "futures-util", "hashbrown 0.12.3", @@ -6933,6 +7014,7 @@ dependencies = [ "veloren-common-systems", "veloren-network", "veloren-plugin-api", + "veloren-rtsim", "veloren-server-agent", "veloren-world", ] @@ -6951,6 +7033,8 @@ dependencies = [ "veloren-common-base", "veloren-common-dynlib", "veloren-common-ecs", + "veloren-common-net", + "veloren-rtsim", ] [[package]] @@ -7108,6 +7192,7 @@ dependencies = [ "csv", "deflate", "enum-iterator 1.1.3", + "enum-map", "fallible-iterator", "flate2", "fxhash", diff --git a/Cargo.toml b/Cargo.toml index 5b61b4528a..e428f0d722 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "plugin/api", "plugin/derive", "plugin/rt", + "rtsim", "server", "server/agent", "server-cli", diff --git a/assets/common/entity/village/captain.ron b/assets/common/entity/village/captain.ron new file mode 100644 index 0000000000..f6b247e641 --- /dev/null +++ b/assets/common/entity/village/captain.ron @@ -0,0 +1,21 @@ +#![enable(implicit_some)] +( + name: Name("Captain"), + body: RandomWith("humanoid"), + alignment: Alignment(Npc), + loot: LootTable("common.loot_tables.creature.humanoid"), + inventory: ( + loadout: Inline(( + inherit: Asset("common.loadout.village.captain"), + active_hands: InHands((ModularWeapon(tool: Sword, material: Orichalcum, hands: Two), None)), + )), + items: [ + (10, "common.items.food.cheese"), + (10, "common.items.food.plainsalad"), + (10, "common.items.consumable.potion_med"), + ], + ), + meta: [ + SkillSetAsset("common.skillset.preset.rank5.fullskill"), + ], +) diff --git a/assets/common/entity/village/farmer.ron b/assets/common/entity/village/farmer.ron new file mode 100644 index 0000000000..77c8f1469c --- /dev/null +++ b/assets/common/entity/village/farmer.ron @@ -0,0 +1,23 @@ +#![enable(implicit_some)] +( + name: Name("Farmer"), + body: RandomWith("humanoid"), + alignment: Alignment(Npc), + loot: LootTable("common.loot_tables.creature.humanoid"), + inventory: ( + loadout: Inline(( + inherit: Asset("common.loadout.village.farmer"), + active_hands: InHands((Choice([ + (1, Item("common.items.weapons.tool.hoe")), + (1, Item("common.items.weapons.tool.rake")), + (1, Item("common.items.weapons.tool.shovel-0")), + (1, Item("common.items.weapons.tool.shovel-1")), + ]), None)), + )), + items: [ + (10, "common.items.food.cheese"), + (10, "common.items.food.plainsalad"), + ], + ), + meta: [], +) diff --git a/assets/common/entity/village/herbalist.ron b/assets/common/entity/village/herbalist.ron new file mode 100644 index 0000000000..db8bea4a03 --- /dev/null +++ b/assets/common/entity/village/herbalist.ron @@ -0,0 +1,21 @@ +#![enable(implicit_some)] +( + name: Name("Herbalist"), + body: RandomWith("humanoid"), + alignment: Alignment(Npc), + loot: LootTable("common.loot_tables.creature.humanoid"), + inventory: ( + loadout: Inline(( + inherit: Asset("common.loadout.village.herbalist"), + active_hands: InHands((Choice([ + (1, Item("common.items.weapons.tool.hoe")), + (1, Item("common.items.weapons.tool.rake")), + ]), None)), + )), + items: [ + (10, "common.items.food.cheese"), + (10, "common.items.food.plainsalad"), + ], + ), + meta: [], +) diff --git a/assets/common/entity/village/hunter.ron b/assets/common/entity/village/hunter.ron new file mode 100644 index 0000000000..d0304584c2 --- /dev/null +++ b/assets/common/entity/village/hunter.ron @@ -0,0 +1,23 @@ +#![enable(implicit_some)] +( + name: Name("Hunter"), + body: RandomWith("humanoid"), + alignment: Alignment(Npc), + loot: LootTable("common.loot_tables.creature.humanoid"), + inventory: ( + loadout: Inline(( + inherit: Asset("common.loadout.village.hunter"), + active_hands: InHands((Choice([ + (8, ModularWeapon(tool: Bow, material: Wood, hands: None)), + (4, ModularWeapon(tool: Bow, material: Bamboo, hands: None)), + (2, ModularWeapon(tool: Bow, material: Hardwood, hands: None)), + (2, ModularWeapon(tool: Bow, material: Ironwood, hands: None)), + (1, ModularWeapon(tool: Bow, material: Eldwood, hands: None)), + ]), None)), + )), + items: [ + (10, "common.items.consumable.potion_big"), + ], + ), + meta: [], +) diff --git a/assets/common/entity/village/merchant.ron b/assets/common/entity/village/merchant.ron index 13b9d6ccc1..64ae86cb98 100644 --- a/assets/common/entity/village/merchant.ron +++ b/assets/common/entity/village/merchant.ron @@ -6,6 +6,7 @@ loot: LootTable("common.loot_tables.creature.humanoid"), inventory: ( loadout: Inline(( + inherit: Asset("common.loadout.village.merchant"), active_hands: InHands((Choice([ (2, ModularWeapon(tool: Bow, material: Eldwood, hands: None)), (1, ModularWeapon(tool: Sword, material: Steel, hands: None)), diff --git a/assets/common/entity/wild/peaceful/dog.ron b/assets/common/entity/wild/peaceful/dog.ron new file mode 100644 index 0000000000..df7bad54a9 --- /dev/null +++ b/assets/common/entity/wild/peaceful/dog.ron @@ -0,0 +1,11 @@ +#![enable(implicit_some)] +( + name: Automatic, + body: RandomWith("dog"), + alignment: Alignment(Wild), + loot: LootTable("common.loot_tables.creature.quad_small.generic"), + inventory: ( + loadout: FromBody, + ), + meta: [], +) diff --git a/assets/common/loadout/village/captain.ron b/assets/common/loadout/village/captain.ron new file mode 100644 index 0000000000..c6617a5879 --- /dev/null +++ b/assets/common/loadout/village/captain.ron @@ -0,0 +1,26 @@ +// Christmas event +//(1.0, Some(Item("common.items.calendar.christmas.armor.misc.head.woolly_wintercap"))), +#![enable(implicit_some)] +( + head: Item("common.items.armor.pirate.hat"), + shoulders: Item("common.items.armor.mail.orichalcum.shoulder"), + chest: Item("common.items.armor.mail.orichalcum.chest"), + gloves: Item("common.items.armor.mail.orichalcum.hand"), + back: Choice([ + (1, Item("common.items.armor.misc.back.backpack")), + (1, Item("common.items.npc_armor.back.backpack_blue")), + (1, Item("common.items.armor.mail.orichalcum.back")), + (1, None), + ]), + belt: Item("common.items.armor.mail.orichalcum.belt"), + legs: Item("common.items.armor.mail.orichalcum.pants"), + feet: Item("common.items.armor.mail.orichalcum.foot"), + lantern: Choice([ + (1, Item("common.items.lantern.black_0")), + (1, Item("common.items.lantern.blue_0")), + (1, Item("common.items.lantern.green_0")), + (1, Item("common.items.lantern.red_0")), + (1, Item("common.items.lantern.geode_purp")), + (1, Item("common.items.boss_drops.lantern")), + ]), +) diff --git a/assets/common/loadout/village/farmer.ron b/assets/common/loadout/village/farmer.ron new file mode 100644 index 0000000000..14f646383a --- /dev/null +++ b/assets/common/loadout/village/farmer.ron @@ -0,0 +1,30 @@ +// Christmas event +//(1.0, Some(Item("common.items.calendar.christmas.armor.misc.head.woolly_wintercap"))), +#![enable(implicit_some)] +( + head: Choice([ + (3, Item("common.items.armor.misc.head.straw")), + (3, Item("common.items.armor.misc.head.bamboo_twig")), + (2, None), + ]), + chest: Choice([ + (1, Item("common.items.armor.misc.chest.worker_green_0")), + (1, Item("common.items.armor.misc.chest.worker_green_1")), + (1, Item("common.items.armor.misc.chest.worker_red_0")), + (1, Item("common.items.armor.misc.chest.worker_red_1")), + (1, Item("common.items.armor.misc.chest.worker_purple_0")), + (1, Item("common.items.armor.misc.chest.worker_purple_1")), + (1, Item("common.items.armor.misc.chest.worker_yellow_0")), + (1, Item("common.items.armor.misc.chest.worker_yellow_1")), + (1, Item("common.items.armor.misc.chest.worker_orange_0")), + (1, Item("common.items.armor.misc.chest.worker_orange_1")), + ]), + legs: Choice([ + (1, Item("common.items.armor.misc.pants.worker_blue")), + (1, Item("common.items.armor.misc.pants.worker_brown")), + ]), + feet: Choice([ + (1, Item("common.items.armor.misc.foot.sandals")), + (1, Item("common.items.armor.cloth_blue.foot")), + ]), +) diff --git a/assets/common/loadout/village/herbalist.ron b/assets/common/loadout/village/herbalist.ron new file mode 100644 index 0000000000..e41a4f4fdb --- /dev/null +++ b/assets/common/loadout/village/herbalist.ron @@ -0,0 +1,26 @@ +// Christmas event +//(1.0, Some(Item("common.items.calendar.christmas.armor.misc.head.woolly_wintercap"))), +#![enable(implicit_some)] +( + head: Choice([ + (3, Item("common.items.armor.misc.head.straw")), + (3, Item("common.items.armor.misc.head.hood")), + (2, None), + ]), + chest: Choice([ + (1, Item("common.items.armor.twigs.chest")), + (1, Item("common.items.armor.twigsflowers.chest")), + (1, Item("common.items.armor.twigsleaves.chest")), + ]), + legs: Choice([ + (1, Item("common.items.armor.twigs.pants")), + (1, Item("common.items.armor.twigsflowers.pants")), + (1, Item("common.items.armor.twigsleaves.pants")), + ]), + feet: Choice([ + (1, Item("common.items.armor.twigs.foot")), + (1, Item("common.items.armor.twigsflowers.foot")), + (1, Item("common.items.armor.twigsleaves.foot")), + (1, Item("common.items.armor.misc.foot.sandals")), + ]), +) diff --git a/assets/common/loadout/village/hunter.ron b/assets/common/loadout/village/hunter.ron new file mode 100644 index 0000000000..8a4933751d --- /dev/null +++ b/assets/common/loadout/village/hunter.ron @@ -0,0 +1,28 @@ +// Christmas event +//(1.0, Some(Item("common.items.calendar.christmas.armor.misc.head.woolly_wintercap"))), +#![enable(implicit_some)] +( + head: Choice([ + (6, None), + (2, Item("common.items.armor.misc.head.straw")), + (3, Item("common.items.armor.misc.head.hood")), + (3, Item("common.items.armor.misc.head.hood_dark")), + ]), + chest: Choice([ + (1, Item("common.items.armor.hide.leather.chest")), + (1, Item("common.items.armor.hide.rawhide.chest")), + (1, Item("common.items.armor.hide.primal.chest")), + ]), + legs: Choice([ + (1, Item("common.items.armor.hide.leather.pants")), + (1, Item("common.items.armor.hide.rawhide.pants")), + (1, Item("common.items.armor.hide.primal.pants")), + ]), + feet: Choice([ + (1, None), + (2, Item("common.items.armor.misc.foot.sandals")), + (4, Item("common.items.armor.hide.leather.foot")), + (4, Item("common.items.armor.hide.rawhide.foot")), + (4, Item("common.items.armor.hide.primal.foot")), + ]), +) diff --git a/assets/common/loadout/village/merchant.ron b/assets/common/loadout/village/merchant.ron index be52cd5da1..35c25e586a 100644 --- a/assets/common/loadout/village/merchant.ron +++ b/assets/common/loadout/village/merchant.ron @@ -11,5 +11,4 @@ legs: Item("common.items.armor.merchant.pants"), feet: Item("common.items.armor.merchant.foot"), lantern: Item("common.items.lantern.black_0"), - tabard: Item("common.items.debug.admin"), -) \ No newline at end of file +) diff --git a/assets/voxygen/i18n/en/npc.ftl b/assets/voxygen/i18n/en/npc.ftl index 097f9c4f18..5e53621f74 100644 --- a/assets/voxygen/i18n/en/npc.ftl +++ b/assets/voxygen/i18n/en/npc.ftl @@ -223,3 +223,35 @@ npc-speech-prisoner = .a2 = That Cardinal can't be trusted. .a3 = These Clerics are up to no good. .a4 = I wish i still had my pick! +npc-speech-moving_on = + .a0 = I've spent enough time here, onward to { $site }! +npc-speech-night_time = + .a0 = It's dark, time to head home. + .a1 = I'm tired. + .a2 = My bed beckons! +npc-speech-day_time = + .a0 = A new day begins! + .a1 = I never liked waking up... +npc-speech-start_hunting = + .a0 = Time to go hunting! +npc-speech-guard_thought = + .a0 = My brother's out fighting ogres. What do I get? Guard duty... + .a1 = Just one more patrol, then I can head home. + .a2 = No bandits are going to get past me. +npc-speech-merchant_sell_undirected = + .a0 = All my goods are of the highest quality! + .a1 = Does anybody want to buy my wares? + .a2 = I've got the best offers in town. + .a3 = Looking for supplies? I've got you covered. +npc-speech-merchant_sell_directed = + .a0 = You there! Are you in need of a new thingamabob? + .a1 = Are you hungry? I'm sure I've got some cheese you can buy. + .a2 = You look like you could do with some new armour! +npc-speech-witness_murder = + .a0 = Murderer! + .a1 = How could you do this? + .a2 = Aaargh! +npc-speech-witness_death = + .a0 = No! + .a1 = This is terrible! + .a2 = Oh my goodness! diff --git a/assets/voxygen/shaders/lod-object-frag.glsl b/assets/voxygen/shaders/lod-object-frag.glsl index 50660b6aa1..f846bd9503 100644 --- a/assets/voxygen/shaders/lod-object-frag.glsl +++ b/assets/voxygen/shaders/lod-object-frag.glsl @@ -22,7 +22,10 @@ layout(location = 0) in vec3 f_pos; layout(location = 1) in vec3 f_norm; layout(location = 2) in vec4 f_col; layout(location = 3) in vec3 model_pos; -layout(location = 4) in float snow_cover; +layout(location = 4) flat in uint f_flags; + +const uint FLAG_SNOW_COVERED = 1; +const uint FLAG_IS_BUILDING = 2; layout(location = 0) out vec4 tgt_color; layout(location = 1) out uvec4 tgt_mat; @@ -117,11 +120,25 @@ void main() { emitted_light *= f_ao; reflected_light *= f_ao; - vec3 side_color = mix(surf_color, vec3(0.5, 0.6, 1.0), snow_cover); - vec3 top_color = mix(surf_color, surf_color * 0.3, 0.5 + snow_cover * 0.5); + vec3 glow = vec3(0); + if ((f_flags & FLAG_IS_BUILDING) > 0u && abs(f_norm.z) < 0.1) { + ivec3 wpos = ivec3((f_pos.xyz + focus_off.xyz) * 0.2); + if (((wpos.x & wpos.y & wpos.z) & 1) == 1) { + glow += vec3(1, 0.7, 0.3) * 2; + } else { + reflected_light += vec3(1, 0.7, 0.3) * 0.9; + } + } + + vec3 side_color = surf_color; + vec3 top_color = surf_color; + if ((f_flags & FLAG_SNOW_COVERED) > 0u && f_norm.z > 0.0) { + side_color = mix(side_color, vec3(0.5, 0.6, 1.0), f_norm.z); + top_color = mix(top_color, surf_color * 0.3, 0.5 + f_norm.z * 0.5); + } surf_color = mix(side_color, top_color, pow(fract(model_pos.z * 0.1), 2.0)); - surf_color = illuminate(max_light, view_dir, surf_color * emitted_light, surf_color * reflected_light); + surf_color = illuminate(max_light, view_dir, surf_color * emitted_light + glow, surf_color * reflected_light); tgt_color = vec4(surf_color, 1.0); tgt_mat = uvec4(uvec3((f_norm + 1.0) * 127.0), MAT_LOD); diff --git a/assets/voxygen/shaders/lod-object-vert.glsl b/assets/voxygen/shaders/lod-object-vert.glsl index b5da3c3e49..3de44d1527 100644 --- a/assets/voxygen/shaders/lod-object-vert.glsl +++ b/assets/voxygen/shaders/lod-object-vert.glsl @@ -24,13 +24,11 @@ layout(location = 3) in vec3 inst_pos; layout(location = 4) in uvec3 inst_col; layout(location = 5) in uint inst_flags; -const uint FLAG_SNOW_COVERED = 1; - layout(location = 0) out vec3 f_pos; layout(location = 1) out vec3 f_norm; layout(location = 2) out vec4 f_col; layout(location = 3) out vec3 model_pos; -layout(location = 4) out float snow_cover; +layout(location = 4) flat out uint f_flags; void main() { vec3 obj_pos = inst_pos - focus_off.xyz; @@ -50,12 +48,7 @@ void main() { f_norm = v_norm; f_col = vec4(vec3(inst_col) * (1.0 / 255.0) * v_col * (hash(inst_pos.xyxy) * 0.35 + 0.65), 1.0); - - if ((inst_flags & FLAG_SNOW_COVERED) > 0u && f_norm.z > 0.0) { - snow_cover = 1.0; - } else { - snow_cover = 0.0; - } + f_flags = inst_flags; gl_Position = all_mat * diff --git a/client/examples/chat-cli/main.rs b/client/examples/chat-cli/main.rs index 4f0a525098..40d7d3614e 100644 --- a/client/examples/chat-cli/main.rs +++ b/client/examples/chat-cli/main.rs @@ -103,7 +103,7 @@ fn main() { &localisation.read(), SHOW_NAME, ) - .message + .1 ), Event::Disconnect => {}, // TODO Event::DisconnectionNotification(time) => { diff --git a/client/i18n/Cargo.toml b/client/i18n/Cargo.toml index 329daed3fc..593fcd7adb 100644 --- a/client/i18n/Cargo.toml +++ b/client/i18n/Cargo.toml @@ -7,6 +7,7 @@ version = "0.13.0" [dependencies] # Assets +common = {package = "veloren-common", path = "../../common"} common-assets = {package = "veloren-common-assets", path = "../../common/assets"} ron = "0.8" serde = { version = "1.0", features = ["derive"] } diff --git a/client/i18n/src/lib.rs b/client/i18n/src/lib.rs index 74da0e45bb..9a7964216f 100644 --- a/client/i18n/src/lib.rs +++ b/client/i18n/src/lib.rs @@ -19,10 +19,11 @@ use std::{borrow::Cow, io}; use assets::{source::DirEntry, AssetExt, AssetGuard, AssetHandle, ReloadWatcher, SharedString}; use tracing::warn; // Re-export because I don't like prefix +use common::comp::{Content, LocalizationArg}; use common_assets as assets; // Re-export for argument creation -pub use fluent::fluent_args; +pub use fluent::{fluent_args, FluentValue}; pub use fluent_bundle::FluentArgs; /// The reference language, aka the more up-to-date localization data. @@ -116,7 +117,9 @@ impl Language { let msg = bundle.get_message(key)?; let mut attrs = msg.attributes(); - if attrs.len() != 0 { + let mut errs = Vec::new(); + + let msg = if attrs.len() != 0 { let idx = usize::from(seed) % attrs.len(); // unwrap is ok here, because idx is bound to attrs.len() // by using modulo operator. @@ -134,16 +137,17 @@ impl Language { // * len = 0 // * no matter what seed is, we return None in code above let variation = attrs.nth(idx).unwrap(); - let mut errs = Vec::new(); - let msg = bundle.format_pattern(variation.value(), args, &mut errs); - for err in errs { - tracing::error!("err: {err} for {key}"); - } - - Some(msg) + bundle.format_pattern(variation.value(), args, &mut errs) } else { - None + // Fall back to single message if there are no attributes + bundle.format_pattern(msg.value()?, args, &mut errs) + }; + + for err in errs { + tracing::error!("err: {err} for {key}"); } + + Some(msg) } } @@ -328,6 +332,47 @@ impl LocalizationGuard { }) } + /// Localize the given content. + pub fn get_content(&self, content: &Content) -> String { + // On error, produces the localisation but with the missing key inline + fn get_content_inner(lang: &Language, content: &Content) -> Result { + match content { + Content::Plain(text) => Ok(text.clone()), + Content::Localized { key, seed, args } => { + let mut is_err = false; + let mut fargs = FluentArgs::new(); + for (k, arg) in args { + fargs.set(k, match arg { + LocalizationArg::Content(content) => FluentValue::String( + get_content_inner(lang, content) + .unwrap_or_else(|broken_text| { + is_err = true; + broken_text + }) + .into(), + ), + LocalizationArg::Nat(n) => FluentValue::from(n), + }); + } + + lang.try_variation(key, *seed, Some(&fargs)) + .map(Cow::into_owned) + .ok_or_else(|| key.clone()) + .and_then(|text| if is_err { Err(text) } else { Ok(text) }) + }, + } + } + + match get_content_inner(&self.active, content) { + Ok(text) => text, + // If part of the localisation failed, use the fallback language + Err(broken_text) => self.fallback.as_ref() + .and_then(|fb| get_content_inner(fb, content).ok()) + // If all else fails, localise with the active language, but with the missing key included inline + .unwrap_or(broken_text), + } + } + /// Get a localized text from the variation of given key with given /// arguments /// diff --git a/client/src/lib.rs b/client/src/lib.rs index f7c527bb36..e891570bc2 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -30,7 +30,7 @@ use common::{ slot::{EquipSlot, InvSlotId, Slot}, CharacterState, ChatMode, ControlAction, ControlEvent, Controller, ControllerInputs, GroupManip, InputKind, InventoryAction, InventoryEvent, InventoryUpdateEvent, - MapMarkerChange, UtteranceKind, + MapMarkerChange, PresenceKind, UtteranceKind, }, event::{EventBus, LocalEvent, UpdateCharacterMetadata}, grid::Grid, @@ -59,8 +59,8 @@ use common_net::{ self, world_msg::{EconomyInfo, PoiInfo, SiteId, SiteInfo}, ChatTypeContext, ClientGeneral, ClientMsg, ClientRegister, ClientType, DisconnectReason, - InviteAnswer, Notification, PingMsg, PlayerInfo, PlayerListUpdate, PresenceKind, - RegisterError, ServerGeneral, ServerInit, ServerRegisterAnswer, + InviteAnswer, Notification, PingMsg, PlayerInfo, PlayerListUpdate, RegisterError, + ServerGeneral, ServerInit, ServerRegisterAnswer, }, sync::WorldSyncExt, }; @@ -1855,6 +1855,7 @@ impl Client { true, None, &self.connected_server_constants, + |_, _| {}, ); // TODO: avoid emitting these in the first place let _ = self @@ -2279,13 +2280,15 @@ impl Client { .any(|r| !matches!(r, group::Role::Pet)) { frontend_events - .push(Event::Chat(comp::ChatType::Meta.chat_msg( + // TODO: localise + .push(Event::Chat(comp::ChatType::Meta.into_plain_msg( "Type /g or /group to chat with your group members", ))); } if let Some(player_info) = self.player_list.get(&uid) { frontend_events.push(Event::Chat( - comp::ChatType::GroupMeta("Group".into()).chat_msg(format!( + // TODO: localise + comp::ChatType::GroupMeta("Group".into()).into_plain_msg(format!( "[{}] joined group", self.personalize_alias(uid, player_info.player_alias.clone()) )), @@ -2302,7 +2305,8 @@ impl Client { Removed(uid) => { if let Some(player_info) = self.player_list.get(&uid) { frontend_events.push(Event::Chat( - comp::ChatType::GroupMeta("Group".into()).chat_msg(format!( + // TODO: localise + comp::ChatType::GroupMeta("Group".into()).into_plain_msg(format!( "[{}] left group", self.personalize_alias(uid, player_info.player_alias.clone()) )), @@ -2789,20 +2793,20 @@ impl Client { KillSource::Other => (), }; }, - comp::ChatType::Tell(from, to) | comp::ChatType::NpcTell(from, to, _) => { + comp::ChatType::Tell(from, to) | comp::ChatType::NpcTell(from, to) => { alias_of_uid(from); alias_of_uid(to); }, comp::ChatType::Say(uid) | comp::ChatType::Region(uid) | comp::ChatType::World(uid) - | comp::ChatType::NpcSay(uid, _) => { + | comp::ChatType::NpcSay(uid) => { alias_of_uid(uid); }, comp::ChatType::Group(uid, _) | comp::ChatType::Faction(uid, _) => { alias_of_uid(uid); }, - comp::ChatType::Npc(uid, _) => alias_of_uid(uid), + comp::ChatType::Npc(uid) => alias_of_uid(uid), comp::ChatType::Meta => (), }; result @@ -2963,7 +2967,7 @@ mod tests { &localisation.read(), true, ) - .message; + .1; }, Event::Disconnect => {}, Event::DisconnectionNotification(_) => { diff --git a/common/Cargo.toml b/common/Cargo.toml index 7288871985..8aff837d6b 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -26,6 +26,7 @@ common-base = { package = "veloren-common-base", path = "base" } serde = { version = "1.0.110", features = ["derive", "rc"] } # Util +enum-map = "2.4" vek = { version = "0.15.8", features = ["serde"] } cfg-if = "1.0.0" chrono = "0.4.22" diff --git a/common/net/src/msg/client.rs b/common/net/src/msg/client.rs index a599b9117a..f752db223d 100644 --- a/common/net/src/msg/client.rs +++ b/common/net/src/msg/client.rs @@ -101,7 +101,7 @@ impl ClientMsg { &self, c_type: ClientType, registered: bool, - presence: Option, + presence: Option, ) -> bool { match self { ClientMsg::Type(t) => c_type == *t, diff --git a/common/net/src/msg/mod.rs b/common/net/src/msg/mod.rs index 429ef7f0d1..316f7429ba 100644 --- a/common/net/src/msg/mod.rs +++ b/common/net/src/msg/mod.rs @@ -19,23 +19,8 @@ pub use self::{ }, world_msg::WorldMapMsg, }; -use common::character::CharacterId; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum PresenceKind { - Spectator, - Character(CharacterId), - Possessor, -} - -impl PresenceKind { - /// Check if the presence represents a control of a character, and thus - /// certain in-game messages from the client such as control inputs - /// should be handled. - pub fn controlling_char(&self) -> bool { matches!(self, Self::Character(_) | Self::Possessor) } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum PingMsg { Ping, diff --git a/common/net/src/msg/server.rs b/common/net/src/msg/server.rs index 4d158d5345..a40a932bab 100644 --- a/common/net/src/msg/server.rs +++ b/common/net/src/msg/server.rs @@ -6,7 +6,7 @@ use crate::sync; use common::{ calendar::Calendar, character::{self, CharacterItem}, - comp::{self, invite::InviteKind, item::MaterialStatManifest}, + comp::{self, invite::InviteKind, item::MaterialStatManifest, Content}, event::UpdateCharacterMetadata, lod, outcome::Outcome, @@ -215,11 +215,10 @@ pub enum ServerGeneral { } impl ServerGeneral { - pub fn server_msg(chat_type: comp::ChatType, msg: S) -> Self - where - S: Into, - { - ServerGeneral::ChatMsg(chat_type.chat_msg(msg)) + // TODO: Don't use `Into` since this treats all strings as plaintext, + // properly localise server messages + pub fn server_msg(chat_type: comp::ChatType, content: impl Into) -> Self { + ServerGeneral::ChatMsg(chat_type.into_msg(content.into())) } } @@ -296,7 +295,7 @@ impl ServerMsg { &self, c_type: ClientType, registered: bool, - presence: Option, + presence: Option, ) -> bool { match self { ServerMsg::Info(_) | ServerMsg::Init(_) | ServerMsg::RegisterAnswer(_) => { diff --git a/common/net/src/synced_components.rs b/common/net/src/synced_components.rs index 299a496ee6..71bcf20610 100644 --- a/common/net/src/synced_components.rs +++ b/common/net/src/synced_components.rs @@ -40,6 +40,7 @@ macro_rules! synced_components { sticky: Sticky, immovable: Immovable, character_state: CharacterState, + character_activity: CharacterActivity, shockwave: Shockwave, beam_segment: BeamSegment, alignment: Alignment, @@ -201,6 +202,10 @@ impl NetSync for CharacterState { const SYNC_FROM: SyncFrom = SyncFrom::AnyEntity; } +impl NetSync for CharacterActivity { + const SYNC_FROM: SyncFrom = SyncFrom::AnyEntity; +} + impl NetSync for Shockwave { const SYNC_FROM: SyncFrom = SyncFrom::AnyEntity; } diff --git a/common/src/astar.rs b/common/src/astar.rs index 4f39310cd3..83a263fa76 100644 --- a/common/src/astar.rs +++ b/common/src/astar.rs @@ -45,6 +45,15 @@ impl PathResult { _ => None, } } + + pub fn map(self, f: impl FnOnce(Path) -> Path) -> PathResult { + match self { + PathResult::None(p) => PathResult::None(f(p)), + PathResult::Exhausted(p) => PathResult::Exhausted(f(p)), + PathResult::Path(p) => PathResult::Path(f(p)), + PathResult::Pending => PathResult::Pending, + } + } } #[derive(Clone)] @@ -78,7 +87,7 @@ impl fmt::Debug for Astar Astar { - pub fn new(max_iters: usize, start: S, heuristic: impl FnOnce(&S) -> f32, hasher: H) -> Self { + pub fn new(max_iters: usize, start: S, hasher: H) -> Self { Self { max_iters, iter: 0, @@ -95,7 +104,7 @@ impl Astar { }, final_scores: { let mut h = HashMap::with_capacity_and_hasher(1, hasher.clone()); - h.extend(core::iter::once((start.clone(), heuristic(&start)))); + h.extend(core::iter::once((start.clone(), 0.0))); h }, visited: { @@ -111,7 +120,7 @@ impl Astar { pub fn poll( &mut self, iters: usize, - mut heuristic: impl FnMut(&S) -> f32, + mut heuristic: impl FnMut(&S, &S) -> f32, mut neighbors: impl FnMut(&S) -> I, mut transition: impl FnMut(&S, &S) -> f32, mut satisfied: impl FnMut(&S) -> bool, @@ -134,7 +143,7 @@ impl Astar { if cost < *neighbor_cheapest { self.came_from.insert(neighbor.clone(), node.clone()); self.cheapest_scores.insert(neighbor.clone(), cost); - let h = heuristic(&neighbor); + let h = heuristic(&neighbor, &node); let neighbor_cost = cost + h; self.final_scores.insert(neighbor.clone(), neighbor_cost); diff --git a/common/src/character.rs b/common/src/character.rs index 92401c3fe1..5b8bdd9501 100644 --- a/common/src/character.rs +++ b/common/src/character.rs @@ -5,7 +5,9 @@ use serde::{Deserialize, Serialize}; /// The limit on how many characters that a player can have pub const MAX_CHARACTERS_PER_PLAYER: usize = 8; -pub type CharacterId = i64; +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[serde(transparent)] +pub struct CharacterId(pub i64); pub const MAX_NAME_LENGTH: usize = 20; diff --git a/common/src/cmd.rs b/common/src/cmd.rs index ddc319baf3..4fb54e7434 100644 --- a/common/src/cmd.rs +++ b/common/src/cmd.rs @@ -296,8 +296,14 @@ pub enum ServerChatCommand { Respawn, RevokeBuild, RevokeBuildAll, + RtsimChunk, + RtsimInfo, + RtsimNpc, + RtsimPurge, + RtsimTp, Safezone, Say, + Scale, ServerPhysics, SetMotd, Ship, @@ -653,6 +659,7 @@ impl ServerChatCommand { Enum("entity", ENTITIES.clone(), Required), Integer("amount", 1, Optional), Boolean("ai", "true".to_string(), Optional), + Float("scale", 1.0, Optional), ], "Spawn a test entity", Some(Admin), @@ -677,6 +684,36 @@ impl ServerChatCommand { "Teleport to another player", Some(Moderator), ), + ServerChatCommand::RtsimTp => cmd( + vec![Integer("npc index", 0, Required)], + "Teleport to an rtsim npc", + Some(Moderator), + ), + ServerChatCommand::RtsimInfo => cmd( + vec![Integer("npc index", 0, Required)], + "Display information about an rtsim NPC", + Some(Moderator), + ), + ServerChatCommand::RtsimNpc => cmd( + vec![Any("query", Required), Integer("max number", 20, Optional)], + "List rtsim NPCs that fit a given query (e.g: simulated,merchant) in order of \ + distance", + Some(Moderator), + ), + ServerChatCommand::RtsimPurge => cmd( + vec![Boolean( + "whether purging of rtsim data should occur on next startup", + true.to_string(), + Required, + )], + "Purge rtsim data on next startup", + Some(Admin), + ), + ServerChatCommand::RtsimChunk => cmd( + vec![], + "Display information about the current chunk from rtsim", + Some(Moderator), + ), ServerChatCommand::Unban => cmd( vec![PlayerName(Required)], "Remove the ban for the given username", @@ -727,6 +764,14 @@ impl ServerChatCommand { ServerChatCommand::Lightning => { cmd(vec![], "Lightning strike at current position", Some(Admin)) }, + ServerChatCommand::Scale => cmd( + vec![ + Float("factor", 1.0, Required), + Boolean("reset_mass", true.to_string(), Optional), + ], + "Scale your character", + Some(Admin), + ), } } @@ -796,6 +841,11 @@ impl ServerChatCommand { ServerChatCommand::Tell => "tell", ServerChatCommand::Time => "time", ServerChatCommand::Tp => "tp", + ServerChatCommand::RtsimTp => "rtsim_tp", + ServerChatCommand::RtsimInfo => "rtsim_info", + ServerChatCommand::RtsimNpc => "rtsim_npc", + ServerChatCommand::RtsimPurge => "rtsim_purge", + ServerChatCommand::RtsimChunk => "rtsim_chunk", ServerChatCommand::Unban => "unban", ServerChatCommand::Version => "version", ServerChatCommand::Waypoint => "waypoint", @@ -808,6 +858,7 @@ impl ServerChatCommand { ServerChatCommand::DeleteLocation => "delete_location", ServerChatCommand::WeatherZone => "weather_zone", ServerChatCommand::Lightning => "lightning", + ServerChatCommand::Scale => "scale", } } diff --git a/common/src/comp/agent.rs b/common/src/comp/agent.rs index ab02458c9c..cb34b91852 100644 --- a/common/src/comp/agent.rs +++ b/common/src/comp/agent.rs @@ -4,7 +4,7 @@ use crate::{ quadruped_small, ship, Body, UtteranceKind, }, path::Chaser, - rtsim::{Memory, MemoryItem, RtSimController, RtSimEvent}, + rtsim::RtSimController, trade::{PendingTrade, ReducedInventory, SiteId, SitePrices, TradeId, TradeResult}, uid::Uid, }; @@ -83,7 +83,7 @@ impl Alignment { } } - // Never attacks + // Usually never attacks pub fn passive_towards(self, other: Alignment) -> bool { match (self, other) { (Alignment::Enemy, Alignment::Enemy) => true, @@ -98,6 +98,20 @@ impl Alignment { _ => false, } } + + // Never attacks + pub fn friendly_towards(self, other: Alignment) -> bool { + match (self, other) { + (Alignment::Enemy, Alignment::Enemy) => true, + (Alignment::Owned(a), Alignment::Owned(b)) if a == b => true, + (Alignment::Npc, Alignment::Npc) => true, + (Alignment::Npc, Alignment::Tame) => true, + (Alignment::Tame, Alignment::Npc) => true, + (Alignment::Tame, Alignment::Tame) => true, + (_, Alignment::Passive) => true, + _ => false, + } + } } impl Component for Alignment { @@ -611,6 +625,11 @@ impl Awareness { self.reached = false; } } + + pub fn set_maximally_aware(&mut self) { + self.reached = true; + self.level = Self::ALERT; + } } #[derive(Clone, Debug, PartialOrd, PartialEq, Eq)] @@ -713,23 +732,6 @@ impl Agent { } pub fn allowed_to_speak(&self) -> bool { self.behavior.can(BehaviorCapability::SPEAK) } - - pub fn forget_enemy(&mut self, target_name: &str) { - self.rtsim_controller - .events - .push(RtSimEvent::ForgetEnemy(target_name.to_owned())); - } - - pub fn add_fight_to_memory(&mut self, target_name: &str, time: f64) { - self.rtsim_controller - .events - .push(RtSimEvent::AddMemory(Memory { - item: MemoryItem::CharacterFight { - name: target_name.to_owned(), - }, - time_to_forget: time + 300.0, - })); - } } impl Component for Agent { @@ -904,21 +906,21 @@ impl, Vec3) -> f32, const NUM_SAMPLES: usize> PidController /// Get the PID coefficients associated with some Body, since it will likely /// need to be tuned differently for each body type -pub fn pid_coefficients(body: &Body) -> (f32, f32, f32) { +pub fn pid_coefficients(body: &Body) -> Option<(f32, f32, f32)> { + // A pure-proportional controller is { kp: 1.0, ki: 0.0, kd: 0.0 } match body { Body::Ship(ship::Body::DefaultAirship) => { let kp = 1.0; let ki = 0.1; let kd = 1.2; - (kp, ki, kd) + Some((kp, ki, kd)) }, Body::Ship(ship::Body::AirBalloon) => { let kp = 1.0; let ki = 0.1; let kd = 0.8; - (kp, ki, kd) + Some((kp, ki, kd)) }, - // default to a pure-proportional controller, which is the first step when tuning - _ => (1.0, 0.0, 0.0), + _ => None, } } diff --git a/common/src/comp/body.rs b/common/src/comp/body.rs index b259123d0d..de07e6e590 100644 --- a/common/src/comp/body.rs +++ b/common/src/comp/body.rs @@ -983,7 +983,7 @@ impl Body { } /// Returns the eye height for this creature. - pub fn eye_height(&self) -> f32 { self.height() * 0.9 } + pub fn eye_height(&self, scale: f32) -> f32 { self.height() * 0.9 * scale } pub fn default_light_offset(&self) -> Vec3 { // TODO: Make this a manifest diff --git a/common/src/comp/character_state.rs b/common/src/comp/character_state.rs index 9e2ea21973..e914d4b32b 100644 --- a/common/src/comp/character_state.rs +++ b/common/src/comp/character_state.rs @@ -12,6 +12,7 @@ use crate::{ utils::{AbilityInfo, StageSection}, *, }, + util::Dir, }; use serde::{Deserialize, Serialize}; use specs::{Component, DerefFlaggedStorage}; @@ -30,6 +31,7 @@ pub struct StateUpdate { pub should_strafe: bool, pub queued_inputs: BTreeMap, pub removed_inputs: Vec, + pub character_activity: CharacterActivity, } pub struct OutputEvents<'a> { @@ -60,6 +62,7 @@ impl From<&JoinData<'_>> for StateUpdate { character: data.character.clone(), queued_inputs: BTreeMap::new(), removed_inputs: Vec::new(), + character_activity: *data.character_activity, } } } @@ -257,7 +260,6 @@ impl CharacterState { | CharacterState::Shockwave(_) | CharacterState::BasicBeam(_) | CharacterState::Stunned(_) - | CharacterState::UseItem(_) | CharacterState::Wielding(_) | CharacterState::Talk | CharacterState::FinisherMelee(_) @@ -980,3 +982,20 @@ impl Default for CharacterState { impl Component for CharacterState { type Storage = DerefFlaggedStorage>; } + +/// Contains information about the visual activity of a character. +/// +/// For now this only includes the direction they're looking in, but later it +/// might include markers indicating that they're available for +/// trade/interaction, more details about their stance or appearance, facial +/// expression, etc. +#[derive(Copy, Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct CharacterActivity { + /// `None` means that the look direction should be derived from the + /// orientation + pub look_dir: Option, +} + +impl Component for CharacterActivity { + type Storage = DerefFlaggedStorage>; +} diff --git a/common/src/comp/chat.rs b/common/src/comp/chat.rs index 228f12cbab..ad5e55daee 100644 --- a/common/src/comp/chat.rs +++ b/common/src/comp/chat.rs @@ -2,6 +2,7 @@ use crate::{ comp::{group::Group, BuffKind}, uid::Uid, }; +use hashbrown::HashMap; use serde::{Deserialize, Serialize}; use specs::{Component, DenseVecStorage}; use std::time::{Duration, Instant}; @@ -29,8 +30,8 @@ impl Component for ChatMode { } impl ChatMode { - /// Create a message from your current chat mode and uuid. - pub fn new_message(&self, from: Uid, message: String) -> UnresolvedChatMsg { + /// Create a plain message from your current chat mode and uuid. + pub fn to_plain_msg(&self, from: Uid, text: impl ToString) -> UnresolvedChatMsg { let chat_type = match self { ChatMode::Tell(to) => ChatType::Tell(from, *to), ChatMode::Say => ChatType::Say(from), @@ -39,7 +40,10 @@ impl ChatMode { ChatMode::Faction(faction) => ChatType::Faction(from, faction.clone()), ChatMode::World => ChatType::World(from), }; - UnresolvedChatMsg { chat_type, message } + UnresolvedChatMsg { + chat_type, + content: Content::Plain(text.to_string()), + } } } @@ -102,26 +106,28 @@ pub enum ChatType { /// World chat World(Uid), /// Messages sent from NPCs (Not shown in chat but as speech bubbles) - /// - /// The u16 field is a random number for selecting localization variants. - Npc(Uid, u16), + Npc(Uid), /// From NPCs but in the chat for clients in the near vicinity - NpcSay(Uid, u16), + NpcSay(Uid), /// From NPCs but in the chat for a specific client. Shows a chat bubble. /// (from, to, localization variant) - NpcTell(Uid, Uid, u16), + NpcTell(Uid, Uid), /// Anything else Meta, } impl ChatType { - pub fn chat_msg(self, msg: S) -> GenericChatMsg - where - S: Into, - { + pub fn into_plain_msg(self, text: impl ToString) -> GenericChatMsg { GenericChatMsg { chat_type: self, - message: msg.into(), + content: Content::Plain(text.to_string()), + } + } + + pub fn into_msg(self, content: Content) -> GenericChatMsg { + GenericChatMsg { + chat_type: self, + content, } } @@ -140,9 +146,9 @@ impl ChatType { ChatType::Faction(u, _s) => Some(*u), ChatType::Region(u) => Some(*u), ChatType::World(u) => Some(*u), - ChatType::Npc(u, _r) => Some(*u), - ChatType::NpcSay(u, _r) => Some(*u), - ChatType::NpcTell(u, _t, _r) => Some(*u), + ChatType::Npc(u) => Some(*u), + ChatType::NpcSay(u) => Some(*u), + ChatType::NpcTell(u, _t) => Some(*u), ChatType::Meta => None, } } @@ -156,9 +162,9 @@ impl ChatType { | ChatType::CommandError | ChatType::FactionMeta(_) | ChatType::GroupMeta(_) - | ChatType::Npc(_, _) - | ChatType::NpcSay(_, _) - | ChatType::NpcTell(_, _, _) + | ChatType::Npc(_) + | ChatType::NpcSay(_) + | ChatType::NpcTell(_, _) | ChatType::Meta | ChatType::Kill(_, _) => None, ChatType::Tell(_, _) | ChatType::Group(_, _) | ChatType::Faction(_, _) => Some(true), @@ -167,11 +173,118 @@ impl ChatType { } } +/// The content of a chat message. +// TODO: This could be generalised to *any* in-game text, not just chat messages (hence it not being +// called `ChatContent`). A few examples: +// +// - Signposts, both those appearing as overhead messages and those displayed 'in-world' on a shop +// sign +// - UI elements +// - In-game notes/books (we could add a variant that allows structuring complex, novel textual +// information as a syntax tree or some other intermediate format that can be localised by the +// client) +// TODO: We probably want to have this type be able to represent similar things to +// `fluent::FluentValue`, such as numeric values, so that they can be properly localised in whatever +// manner is required. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Content { + /// The content is a plaintext string that should be shown to the user + /// verbatim. + Plain(String), + /// The content is a localizable message with the given arguments. + Localized { + /// i18n key + key: String, + /// Pseudorandom seed value that allows frontends to select a + /// deterministic (but pseudorandom) localised output + seed: u16, + /// i18n arguments + args: HashMap, + }, +} + +// TODO: Remove impl and make use of `Plain(...)` explicit (to discourage it) +impl From for Content { + fn from(text: String) -> Self { Self::Plain(text) } +} + +// TODO: Remove impl and make use of `Plain(...)` explicit (to discourage it) +impl<'a> From<&'a str> for Content { + fn from(text: &'a str) -> Self { Self::Plain(text.to_string()) } +} + +/// A localisation argument for localised content (see [`Content::Localized`]). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum LocalizationArg { + /// The localisation argument is itself a section of content. + /// + /// Note that this allows [`Content`] to recursively refer to itself. It may + /// be tempting to decide to parameterise everything, having dialogue + /// generated with a compact tree. "It's simpler!", you might say. False. + /// Over-parameterisation is an anti-pattern that hurts translators. Where + /// possible, prefer fewer levels of nesting unless doing so would + /// result in an intractably larger number of combinations. See [here](https://github.com/projectfluent/fluent/wiki/Good-Practices-for-Developers#prefer-wet-over-dry) for the + /// guidance provided by the docs for `fluent`, the localisation library + /// used by clients. + Content(Content), + /// The localisation argument is a natural number + Nat(u64), +} + +// TODO: Remove impl and make use of `Content(Plain(...))` explicit (to +// discourage it) +impl From for LocalizationArg { + fn from(text: String) -> Self { Self::Content(Content::Plain(text)) } +} + +// TODO: Remove impl and make use of `Content(Plain(...))` explicit (to +// discourage it) +impl<'a> From<&'a str> for LocalizationArg { + fn from(text: &'a str) -> Self { Self::Content(Content::Plain(text.to_string())) } +} + +// TODO: Remove impl and make use of `Content(Plain(...))` explicit (to +// discourage it) +impl From for LocalizationArg { + fn from(n: u64) -> Self { Self::Nat(n) } +} + +impl Content { + pub fn localized(key: impl ToString) -> Self { + Self::Localized { + key: key.to_string(), + seed: rand::random(), + args: HashMap::default(), + } + } + + pub fn localized_with_args<'a, A: Into>( + key: impl ToString, + args: impl IntoIterator, + ) -> Self { + Self::Localized { + key: key.to_string(), + seed: rand::random(), + args: args + .into_iter() + .map(|(k, v)| (k.to_string(), v.into())) + .collect(), + } + } + + pub fn as_plain(&self) -> Option<&str> { + match self { + Self::Plain(text) => Some(text.as_str()), + Self::Localized { .. } => None, + } + } +} + // Stores chat text, type #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GenericChatMsg { pub chat_type: ChatType, - pub message: String, + content: Content, } pub type ChatMsg = GenericChatMsg; @@ -183,19 +296,19 @@ impl GenericChatMsg { pub const REGION_DISTANCE: f32 = 1000.0; pub const SAY_DISTANCE: f32 = 100.0; - pub fn npc(uid: Uid, message: String) -> Self { - let chat_type = ChatType::Npc(uid, rand::random()); - Self { chat_type, message } + pub fn npc(uid: Uid, content: Content) -> Self { + let chat_type = ChatType::Npc(uid); + Self { chat_type, content } } - pub fn npc_say(uid: Uid, message: String) -> Self { - let chat_type = ChatType::NpcSay(uid, rand::random()); - Self { chat_type, message } + pub fn npc_say(uid: Uid, content: Content) -> Self { + let chat_type = ChatType::NpcSay(uid); + Self { chat_type, content } } - pub fn npc_tell(from: Uid, to: Uid, message: String) -> Self { - let chat_type = ChatType::NpcTell(from, to, rand::random()); - Self { chat_type, message } + pub fn npc_tell(from: Uid, to: Uid, content: Content) -> Self { + let chat_type = ChatType::NpcTell(from, to); + Self { chat_type, content } } pub fn map_group(self, mut f: impl FnMut(G) -> T) -> GenericChatMsg { @@ -213,15 +326,15 @@ impl GenericChatMsg { ChatType::Faction(a, b) => ChatType::Faction(a, b), ChatType::Region(a) => ChatType::Region(a), ChatType::World(a) => ChatType::World(a), - ChatType::Npc(a, b) => ChatType::Npc(a, b), - ChatType::NpcSay(a, b) => ChatType::NpcSay(a, b), - ChatType::NpcTell(a, b, c) => ChatType::NpcTell(a, b, c), + ChatType::Npc(a) => ChatType::Npc(a), + ChatType::NpcSay(a) => ChatType::NpcSay(a), + ChatType::NpcTell(a, b) => ChatType::NpcTell(a, b), ChatType::Meta => ChatType::Meta, }; GenericChatMsg { chat_type, - message: self.message, + content: self.content, } } @@ -234,15 +347,8 @@ impl GenericChatMsg { } pub fn to_bubble(&self) -> Option<(SpeechBubble, Uid)> { - let icon = self.icon(); - if let ChatType::Npc(from, r) | ChatType::NpcSay(from, r) | ChatType::NpcTell(from, _, r) = - self.chat_type - { - Some((SpeechBubble::npc_new(&self.message, r, icon), from)) - } else { - self.uid() - .map(|from| (SpeechBubble::player_new(&self.message, icon), from)) - } + self.uid() + .map(|from| (SpeechBubble::new(self.content.clone(), self.icon()), from)) } pub fn icon(&self) -> SpeechBubbleType { @@ -260,14 +366,20 @@ impl GenericChatMsg { ChatType::Faction(_u, _s) => SpeechBubbleType::Faction, ChatType::Region(_u) => SpeechBubbleType::Region, ChatType::World(_u) => SpeechBubbleType::World, - ChatType::Npc(_u, _r) => SpeechBubbleType::None, - ChatType::NpcSay(_u, _r) => SpeechBubbleType::Say, - ChatType::NpcTell(_f, _t, _) => SpeechBubbleType::Say, + ChatType::Npc(_u) => SpeechBubbleType::None, + ChatType::NpcSay(_u) => SpeechBubbleType::Say, + ChatType::NpcTell(_f, _t) => SpeechBubbleType::Say, ChatType::Meta => SpeechBubbleType::None, } } pub fn uid(&self) -> Option { self.chat_type.uid() } + + pub fn content(&self) -> &Content { &self.content } + + pub fn into_content(self) -> Content { self.content } + + pub fn set_content(&mut self, content: Content) { self.content = content; } } /// Player factions are used to coordinate pvp vs hostile factions or segment @@ -283,15 +395,6 @@ impl From for Faction { fn from(s: String) -> Self { Faction(s) } } -/// The contents of a speech bubble -pub enum SpeechBubbleMessage { - /// This message was said by a player and needs no translation - Plain(String), - /// This message was said by an NPC. The fields are a i18n key and a random - /// u16 index - Localized(String, u16), -} - /// List of chat types for players and NPCs. Each one has its own icon. /// /// This is a subset of `ChatType`, and a superset of `ChatMode` @@ -311,7 +414,7 @@ pub enum SpeechBubbleType { /// Adds a speech bubble above the character pub struct SpeechBubble { - pub message: SpeechBubbleMessage, + pub content: Content, pub icon: SpeechBubbleType, pub timeout: Instant, } @@ -320,33 +423,14 @@ impl SpeechBubble { /// Default duration in seconds of speech bubbles pub const DEFAULT_DURATION: f64 = 5.0; - pub fn npc_new(i18n_key: &str, r: u16, icon: SpeechBubbleType) -> Self { - let message = SpeechBubbleMessage::Localized(i18n_key.to_string(), r); + pub fn new(content: Content, icon: SpeechBubbleType) -> Self { let timeout = Instant::now() + Duration::from_secs_f64(SpeechBubble::DEFAULT_DURATION); Self { - message, + content, icon, timeout, } } - pub fn player_new(message: &str, icon: SpeechBubbleType) -> Self { - let message = SpeechBubbleMessage::Plain(message.to_string()); - let timeout = Instant::now() + Duration::from_secs_f64(SpeechBubble::DEFAULT_DURATION); - Self { - message, - icon, - timeout, - } - } - - pub fn message(&self, i18n_variation: F) -> String - where - F: Fn(&str, u16) -> String, - { - match &self.message { - SpeechBubbleMessage::Plain(m) => m.to_string(), - SpeechBubbleMessage::Localized(k, i) => i18n_variation(k, *i), - } - } + pub fn content(&self) -> &Content { &self.content } } diff --git a/common/src/comp/compass.rs b/common/src/comp/compass.rs index 389c4fb67f..4937e87d1c 100644 --- a/common/src/comp/compass.rs +++ b/common/src/comp/compass.rs @@ -1,4 +1,5 @@ use vek::Vec2; +// TODO: Move this to common/src/, it's not a component /// Cardinal directions pub enum Direction { diff --git a/common/src/comp/fluid_dynamics.rs b/common/src/comp/fluid_dynamics.rs index 9124ae501a..c7b3558497 100644 --- a/common/src/comp/fluid_dynamics.rs +++ b/common/src/comp/fluid_dynamics.rs @@ -135,6 +135,7 @@ impl Body { rel_flow: &Vel, fluid_density: f32, wings: Option<&Wings>, + scale: f32, ) -> Vec3 { let v_sq = rel_flow.0.magnitude_squared(); if v_sq < 0.25 { @@ -201,11 +202,11 @@ impl Body { debug_assert!(c_d.is_sign_positive()); debug_assert!(c_l.is_sign_positive() || aoa.is_sign_negative()); - planform_area * (c_l * *lift_dir + c_d * *rel_flow_dir) - + self.parasite_drag() * *rel_flow_dir + planform_area * scale.powf(2.0) * (c_l * *lift_dir + c_d * *rel_flow_dir) + + self.parasite_drag(scale) * *rel_flow_dir }, - _ => self.parasite_drag() * *rel_flow_dir, + _ => self.parasite_drag(scale) * *rel_flow_dir, } } } @@ -214,13 +215,13 @@ impl Body { /// Skin friction is the drag arising from the shear forces between a fluid /// and a surface, while pressure drag is due to flow separation. Both are /// viscous effects. - fn parasite_drag(&self) -> f32 { + fn parasite_drag(&self, scale: f32) -> f32 { // Reference area and drag coefficient assumes best-case scenario of the // orientation producing least amount of drag match self { // Cross-section, head/feet first Body::BipedLarge(_) | Body::BipedSmall(_) | Body::Golem(_) | Body::Humanoid(_) => { - let dim = self.dimensions().xy().map(|a| a * 0.5); + let dim = self.dimensions().xy().map(|a| a * 0.5 * scale); const CD: f32 = 0.7; CD * PI * dim.x * dim.y }, @@ -231,7 +232,7 @@ impl Body { | Body::QuadrupedSmall(_) | Body::QuadrupedLow(_) | Body::Arthropod(_) => { - let dim = self.dimensions().map(|a| a * 0.5); + let dim = self.dimensions().map(|a| a * 0.5 * scale); let cd: f32 = if matches!(self, Body::QuadrupedLow(_)) { 0.7 } else { @@ -242,7 +243,7 @@ impl Body { // Cross-section, zero-lift angle; exclude the wings (width * 0.2) Body::BirdMedium(_) | Body::BirdLarge(_) | Body::Dragon(_) => { - let dim = self.dimensions().map(|a| a * 0.5); + let dim = self.dimensions().map(|a| a * 0.5 * scale); let cd: f32 = match self { // "Field Estimates of Body Drag Coefficient // on the Basis of Dives in Passerine Birds", @@ -256,7 +257,7 @@ impl Body { // Cross-section, zero-lift angle; exclude the fins (width * 0.2) Body::FishMedium(_) | Body::FishSmall(_) => { - let dim = self.dimensions().map(|a| a * 0.5); + let dim = self.dimensions().map(|a| a * 0.5 * scale); // "A Simple Method to Determine Drag Coefficients in Aquatic Animals", // D. Bilo and W. Nachtigall, 1980 const CD: f32 = 0.031; @@ -276,7 +277,7 @@ impl Body { | object::Body::FireworkYellow | object::Body::MultiArrow | object::Body::Dart => { - let dim = self.dimensions().map(|a| a * 0.5); + let dim = self.dimensions().map(|a| a * 0.5 * scale); const CD: f32 = 0.02; CD * PI * dim.x * dim.z }, @@ -295,20 +296,20 @@ impl Body { | object::Body::Pumpkin3 | object::Body::Pumpkin4 | object::Body::Pumpkin5 => { - let dim = self.dimensions().map(|a| a * 0.5); + let dim = self.dimensions().map(|a| a * 0.5 * scale); const CD: f32 = 0.5; CD * PI * dim.x * dim.z }, _ => { - let dim = self.dimensions(); + let dim = self.dimensions().map(|a| a * scale); const CD: f32 = 2.0; CD * (PI / 6.0 * dim.x * dim.y * dim.z).powf(2.0 / 3.0) }, }, Body::ItemDrop(_) => { - let dim = self.dimensions(); + let dim = self.dimensions().map(|a| a * scale); const CD: f32 = 2.0; CD * (PI / 6.0 * dim.x * dim.y * dim.z).powf(2.0 / 3.0) }, @@ -316,7 +317,7 @@ impl Body { Body::Ship(_) => { // Airships tend to use the square of the cube root of its volume for // reference area - let dim = self.dimensions(); + let dim = self.dimensions().map(|a| a * scale); (PI / 6.0 * dim.x * dim.y * dim.z).powf(2.0 / 3.0) }, } diff --git a/common/src/comp/mod.rs b/common/src/comp/mod.rs index 82a7b1e314..12a8de0a06 100644 --- a/common/src/comp/mod.rs +++ b/common/src/comp/mod.rs @@ -38,6 +38,8 @@ pub mod loot_owner; #[cfg(not(target_arch = "wasm32"))] mod player; #[cfg(not(target_arch = "wasm32"))] pub mod poise; #[cfg(not(target_arch = "wasm32"))] +pub mod presence; +#[cfg(not(target_arch = "wasm32"))] pub mod projectile; #[cfg(not(target_arch = "wasm32"))] pub mod shockwave; @@ -71,9 +73,10 @@ pub use self::{ Buff, BuffCategory, BuffChange, BuffData, BuffEffect, BuffId, BuffKind, BuffSource, Buffs, ModifierKind, }, - character_state::{CharacterState, StateUpdate}, + character_state::{CharacterActivity, CharacterState, StateUpdate}, chat::{ - ChatMode, ChatMsg, ChatType, Faction, SpeechBubble, SpeechBubbleType, UnresolvedChatMsg, + ChatMode, ChatMsg, ChatType, Content, Faction, LocalizationArg, SpeechBubble, + SpeechBubbleType, UnresolvedChatMsg, }, combo::Combo, controller::{ @@ -107,6 +110,7 @@ pub use self::{ player::DisconnectReason, player::{AliasError, Player, MAX_ALIAS_LEN}, poise::{Poise, PoiseChange, PoiseState}, + presence::{Presence, PresenceKind}, projectile::{Projectile, ProjectileConstructor}, shockwave::{Shockwave, ShockwaveHitEntities}, skillset::{ diff --git a/common/src/comp/pet.rs b/common/src/comp/pet.rs index 8f8a308513..ac617be380 100644 --- a/common/src/comp/pet.rs +++ b/common/src/comp/pet.rs @@ -94,6 +94,7 @@ pub fn is_mountable(mount: &Body, rider: Option<&Body>) -> bool { | quadruped_low::Species::Elbst | quadruped_low::Species::Tortoise ), + Body::Ship(_) => true, _ => false, } } diff --git a/common/src/comp/presence.rs b/common/src/comp/presence.rs new file mode 100644 index 0000000000..fcb7588993 --- /dev/null +++ b/common/src/comp/presence.rs @@ -0,0 +1,128 @@ +use crate::{character::CharacterId, ViewDistances}; +use serde::{Deserialize, Serialize}; +use specs::Component; +use std::time::{Duration, Instant}; +use vek::*; + +#[derive(Debug)] +pub struct Presence { + pub terrain_view_distance: ViewDistance, + pub entity_view_distance: ViewDistance, + pub kind: PresenceKind, + pub lossy_terrain_compression: bool, +} + +impl Presence { + pub fn new(view_distances: ViewDistances, kind: PresenceKind) -> Self { + let now = Instant::now(); + Self { + terrain_view_distance: ViewDistance::new(view_distances.terrain, now), + entity_view_distance: ViewDistance::new(view_distances.entity, now), + kind, + lossy_terrain_compression: false, + } + } +} + +impl Component for Presence { + type Storage = specs::DenseVecStorage; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PresenceKind { + Spectator, + Character(CharacterId), + Possessor, +} + +impl PresenceKind { + /// Check if the presence represents a control of a character, and thus + /// certain in-game messages from the client such as control inputs + /// should be handled. + pub fn controlling_char(&self) -> bool { matches!(self, Self::Character(_) | Self::Possessor) } +} + +#[derive(PartialEq, Debug, Clone, Copy)] +enum Direction { + Up, + Down, +} + +/// Distance from the [Presence] from which the world is loaded and information +/// is synced to clients. +/// +/// We limit the frequency that changes in the view distance change direction +/// (e.g. shifting from increasing the value to decreasing it). This is useful +/// since we want to avoid rapid cycles of shrinking and expanding of the view +/// distance. +#[derive(Debug)] +pub struct ViewDistance { + direction: Direction, + last_direction_change_time: Instant, + target: Option, + current: u32, +} + +impl ViewDistance { + /// Minimum time allowed between changes in direction of value adjustments. + const TIME_PER_DIR_CHANGE: Duration = Duration::from_millis(300); + + pub fn new(start_value: u32, now: Instant) -> Self { + Self { + direction: Direction::Up, + last_direction_change_time: now.checked_sub(Self::TIME_PER_DIR_CHANGE).unwrap_or(now), + target: None, + current: start_value, + } + } + + /// Returns the current value. + pub fn current(&self) -> u32 { self.current } + + /// Applies deferred change based on the whether the time to apply it has + /// been reached. + pub fn update(&mut self, now: Instant) { + if let Some(target_val) = self.target { + if now.saturating_duration_since(self.last_direction_change_time) + > Self::TIME_PER_DIR_CHANGE + { + self.last_direction_change_time = now; + self.current = target_val; + self.target = None; + } + } + } + + /// Sets the target value. + /// + /// If this hasn't been changed recently or it is in the same direction as + /// the previous change it will be applied immediately. Otherwise, it + /// will be deferred to a later time (limiting the frequency of changes + /// in the change direction). + pub fn set_target(&mut self, new_target: u32, now: Instant) { + use core::cmp::Ordering; + let new_direction = match new_target.cmp(&self.current) { + Ordering::Equal => return, // No change needed. + Ordering::Less => Direction::Down, + Ordering::Greater => Direction::Up, + }; + + // Change is in the same direction as before so we can just apply it. + if new_direction == self.direction { + self.current = new_target; + self.target = None; + // If it has already been a while since the last direction change we can + // directly apply the request and switch the direction. + } else if now.saturating_duration_since(self.last_direction_change_time) + > Self::TIME_PER_DIR_CHANGE + { + self.direction = new_direction; + self.last_direction_change_time = now; + self.current = new_target; + self.target = None; + // Otherwise, we need to defer the request. + } else { + self.target = Some(new_target); + } + } +} diff --git a/common/src/event.rs b/common/src/event.rs index 0eab390bbf..89c708b5e0 100644 --- a/common/src/event.rs +++ b/common/src/event.rs @@ -8,7 +8,7 @@ use crate::{ }, lottery::LootSpec, outcome::Outcome, - rtsim::RtSimEntity, + rtsim::{RtSimEntity, RtSimVehicle}, terrain::SpriteKind, trade::{TradeAction, TradeId}, uid::Uid, @@ -42,6 +42,92 @@ pub struct UpdateCharacterMetadata { pub skill_set_persistence_load_error: Option, } +pub struct NpcBuilder { + pub stats: comp::Stats, + pub skill_set: comp::SkillSet, + pub health: Option, + pub poise: comp::Poise, + pub inventory: comp::inventory::Inventory, + pub body: comp::Body, + pub agent: Option, + pub alignment: comp::Alignment, + pub scale: comp::Scale, + pub anchor: Option, + pub loot: LootSpec, + pub rtsim_entity: Option, + pub projectile: Option, +} + +impl NpcBuilder { + pub fn new(stats: comp::Stats, body: comp::Body, alignment: comp::Alignment) -> Self { + Self { + stats, + skill_set: comp::SkillSet::default(), + health: None, + poise: comp::Poise::new(body), + inventory: comp::Inventory::with_empty(), + body, + agent: None, + alignment, + scale: comp::Scale(1.0), + anchor: None, + loot: LootSpec::Nothing, + rtsim_entity: None, + projectile: None, + } + } + + pub fn with_health(mut self, health: impl Into>) -> Self { + self.health = health.into(); + self + } + + pub fn with_poise(mut self, poise: comp::Poise) -> Self { + self.poise = poise; + self + } + + pub fn with_agent(mut self, agent: impl Into>) -> Self { + self.agent = agent.into(); + self + } + + pub fn with_anchor(mut self, anchor: comp::Anchor) -> Self { + self.anchor = Some(anchor); + self + } + + pub fn with_rtsim(mut self, rtsim: RtSimEntity) -> Self { + self.rtsim_entity = Some(rtsim); + self + } + + pub fn with_projectile(mut self, projectile: impl Into>) -> Self { + self.projectile = projectile.into(); + self + } + + pub fn with_scale(mut self, scale: comp::Scale) -> Self { + self.scale = scale; + self + } + + pub fn with_inventory(mut self, inventory: comp::Inventory) -> Self { + self.inventory = inventory; + self + } + + pub fn with_skill_set(mut self, skill_set: comp::SkillSet) -> Self { + self.skill_set = skill_set; + self + } + + pub fn with_loot(mut self, loot: LootSpec) -> Self { + self.loot = loot; + self + } +} + #[allow(clippy::large_enum_variant)] // TODO: Pending review in #587 #[derive(strum::EnumDiscriminants)] #[strum_discriminants(repr(usize))] @@ -137,26 +223,13 @@ pub enum ServerEvent { // TODO: to avoid breakage when adding new fields, perhaps have an `NpcBuilder` type? CreateNpc { pos: Pos, - stats: comp::Stats, - skill_set: comp::SkillSet, - health: Option, - poise: comp::Poise, - inventory: comp::inventory::Inventory, - body: comp::Body, - agent: Option, - alignment: comp::Alignment, - scale: comp::Scale, - anchor: Option, - loot: LootSpec, - rtsim_entity: Option, - projectile: Option, + npc: NpcBuilder, }, CreateShip { pos: Pos, ship: comp::ship::Body, - mountable: bool, - agent: Option, - rtsim_entity: Option, + rtsim_entity: Option, + driver: Option, }, CreateWaypoint(Vec3), ClientDisconnect(EcsEntity, DisconnectReason), diff --git a/common/src/generation.rs b/common/src/generation.rs index 764165c793..091421aa1f 100644 --- a/common/src/generation.rs +++ b/common/src/generation.rs @@ -7,8 +7,10 @@ use crate::{ }, lottery::LootSpec, npc::{self, NPC_NAMES}, + rtsim, trade::SiteInformation, }; +use enum_map::EnumMap; use serde::Deserialize; use vek::*; @@ -254,7 +256,7 @@ impl EntityInfo { self = self.with_name(name); }, NameKind::Automatic => { - self = self.with_automatic_name(); + self = self.with_automatic_name(None); }, NameKind::Uninit => {}, } @@ -373,8 +375,8 @@ impl EntityInfo { } #[must_use] - pub fn with_agent_mark(mut self, agent_mark: agent::Mark) -> Self { - self.agent_mark = Some(agent_mark); + pub fn with_agent_mark(mut self, agent_mark: impl Into>) -> Self { + self.agent_mark = agent_mark.into(); self } @@ -406,7 +408,7 @@ impl EntityInfo { } #[must_use] - pub fn with_automatic_name(mut self) -> Self { + pub fn with_automatic_name(mut self, alias: Option) -> Self { let npc_names = NPC_NAMES.read(); let name = match &self.body { Body::Humanoid(body) => Some(get_npc_name(&npc_names.humanoid, body.species)), @@ -428,14 +430,30 @@ impl EntityInfo { Body::Arthropod(body) => Some(get_npc_name(&npc_names.arthropod, body.species)), _ => None, }; - self.name = name.map(str::to_owned); + self.name = name.map(|name| { + if let Some(alias) = alias { + format!("{alias} ({name})") + } else { + name.to_string() + } + }); + self + } + + #[must_use] + pub fn with_alias(mut self, alias: String) -> Self { + self.name = Some(if let Some(name) = self.name { + format!("{alias} ({name})") + } else { + alias + }); self } /// map contains price+amount #[must_use] - pub fn with_economy(mut self, e: &SiteInformation) -> Self { - self.trading_information = Some(e.clone()); + pub fn with_economy<'a>(mut self, e: impl Into>) -> Self { + self.trading_information = e.into().cloned(); self } @@ -444,11 +462,18 @@ impl EntityInfo { self.no_flee = true; self } + + #[must_use] + pub fn with_loadout(mut self, loadout: LoadoutBuilder) -> Self { + self.loadout = loadout; + self + } } #[derive(Default)] pub struct ChunkSupplement { pub entities: Vec, + pub rtsim_max_resources: EnumMap, } impl ChunkSupplement { diff --git a/common/src/lod.rs b/common/src/lod.rs index 01d7742c3d..b665d1c082 100644 --- a/common/src/lod.rs +++ b/common/src/lod.rs @@ -10,6 +10,7 @@ bitflags::bitflags! { #[derive(Serialize, Deserialize)] pub struct Flags: u8 { const SNOW_COVERED = 0b00000001; + const IS_BUILDING = 0b00000010; } } diff --git a/common/src/mounting.rs b/common/src/mounting.rs index 6d95da5fd0..cd45231c6a 100644 --- a/common/src/mounting.rs +++ b/common/src/mounting.rs @@ -1,6 +1,5 @@ use crate::{ comp, - comp::{pet::is_mountable, Body}, link::{Is, Link, LinkHandle, Role}, terrain::TerrainGrid, uid::{Uid, UidAllocator}, @@ -29,6 +28,7 @@ pub struct Mounting { pub rider: Uid, } +#[derive(Debug)] pub enum MountingError { NoSuchEntity, NotMountable, @@ -39,7 +39,6 @@ impl Link for Mounting { Read<'a, UidAllocator>, WriteStorage<'a, Is>, WriteStorage<'a, Is>, - WriteStorage<'a, Body>, ); type DeleteData<'a> = ( Read<'a, UidAllocator>, @@ -60,7 +59,7 @@ impl Link for Mounting { fn create( this: &LinkHandle, - (uid_allocator, mut is_mounts, mut is_riders, body): Self::CreateData<'_>, + (uid_allocator, mut is_mounts, mut is_riders): Self::CreateData<'_>, ) -> Result<(), Self::Error> { let entity = |uid: Uid| uid_allocator.retrieve_entity_internal(uid.into()); @@ -68,23 +67,15 @@ impl Link for Mounting { // Forbid self-mounting Err(MountingError::NotMountable) } else if let Some((mount, rider)) = entity(this.mount).zip(entity(this.rider)) { - if let Some(mount_body) = body.get(mount) { - if is_mountable(mount_body, body.get(rider)) { - let can_mount_with = - |entity| is_mounts.get(entity).is_none() && is_riders.get(entity).is_none(); + let can_mount_with = + |entity| is_mounts.get(entity).is_none() && is_riders.get(entity).is_none(); - // Ensure that neither mount or rider are already part of a mounting - // relationship - if can_mount_with(mount) && can_mount_with(rider) { - let _ = is_mounts.insert(mount, this.make_role()); - let _ = is_riders.insert(rider, this.make_role()); - Ok(()) - } else { - Err(MountingError::NotMountable) - } - } else { - Err(MountingError::NotMountable) - } + // Ensure that neither mount or rider are already part of a mounting + // relationship + if can_mount_with(mount) && can_mount_with(rider) { + let _ = is_mounts.insert(mount, this.make_role()); + let _ = is_riders.insert(rider, this.make_role()); + Ok(()) } else { Err(MountingError::NotMountable) } @@ -146,7 +137,7 @@ impl Link for Mounting { let old_pos = pos.0.map(|e| e.floor() as i32); pos.0 = safe_pos .map(|p| p.0.map(|e| e.floor())) - .unwrap_or_else(|| terrain.find_space(old_pos).map(|e| e as f32)) + .unwrap_or_else(|| terrain.find_ground(old_pos).map(|e| e as f32)) + Vec3::new(0.5, 0.5, 0.0); if let Some(force_update) = force_update.get_mut(rider) { force_update.update(); diff --git a/common/src/path.rs b/common/src/path.rs index 439875276b..a6dc55a5c4 100644 --- a/common/src/path.rs +++ b/common/src/path.rs @@ -19,7 +19,7 @@ use vek::*; #[derive(Clone, Debug)] pub struct Path { - nodes: Vec, + pub nodes: Vec, } impl Default for Path { @@ -534,7 +534,7 @@ where _ => return (None, false), }; - let heuristic = |pos: &Vec3| (pos.distance_squared(end) as f32).sqrt(); + let heuristic = |pos: &Vec3, _: &Vec3| (pos.distance_squared(end) as f32).sqrt(); let neighbors = |pos: &Vec3| { let pos = *pos; const DIRS: [Vec3; 17] = [ @@ -639,7 +639,7 @@ where let satisfied = |pos: &Vec3| pos == &end; let mut new_astar = match astar.take() { - None => Astar::new(25_000, start, heuristic, DefaultHashBuilder::default()), + None => Astar::new(25_000, start, DefaultHashBuilder::default()), Some(astar) => astar, }; diff --git a/common/src/rtsim.rs b/common/src/rtsim.rs index d8ff8f0423..9dc0dfa728 100644 --- a/common/src/rtsim.rs +++ b/common/src/rtsim.rs @@ -3,41 +3,211 @@ // `Agent`). When possible, this should be moved to the `rtsim` // module in `server`. +use crate::{character::CharacterId, comp::Content}; +use rand::{seq::IteratorRandom, Rng}; +use serde::{Deserialize, Serialize}; use specs::Component; +use std::collections::VecDeque; +use strum::{EnumIter, IntoEnumIterator}; use vek::*; -use crate::comp::dialogue::MoodState; +slotmap::new_key_type! { pub struct NpcId; } -pub type RtSimId = usize; +slotmap::new_key_type! { pub struct VehicleId; } + +slotmap::new_key_type! { pub struct SiteId; } + +slotmap::new_key_type! { pub struct FactionId; } #[derive(Copy, Clone, Debug)] -pub struct RtSimEntity(pub RtSimId); +pub struct RtSimEntity(pub NpcId); impl Component for RtSimEntity { type Storage = specs::VecStorage; } -#[derive(Clone, Debug)] -pub enum RtSimEvent { - AddMemory(Memory), - SetMood(Memory), - ForgetEnemy(String), - PrintMemories, +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum Actor { + Npc(NpcId), + Character(CharacterId), } -#[derive(Clone, Debug)] -pub struct Memory { - pub item: MemoryItem, - pub time_to_forget: f64, +impl Actor { + pub fn npc(&self) -> Option { + match self { + Actor::Npc(id) => Some(*id), + Actor::Character(_) => None, + } + } } -#[derive(Clone, Debug)] -pub enum MemoryItem { - // These are structs to allow more data beyond name to be stored - // such as clothing worn, weapon used, etc. - CharacterInteraction { name: String }, - CharacterFight { name: String }, - Mood { state: MoodState }, +#[derive(Copy, Clone, Debug)] +pub struct RtSimVehicle(pub VehicleId); + +impl Component for RtSimVehicle { + type Storage = specs::VecStorage; +} + +#[derive(EnumIter, Clone, Copy)] +pub enum PersonalityTrait { + Open, + Adventurous, + Closed, + Conscientious, + Busybody, + Unconscientious, + Extroverted, + Introverted, + Agreeable, + Sociable, + Disagreeable, + Neurotic, + Seeker, + Worried, + SadLoner, + Stable, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +pub struct Personality { + openness: u8, + conscientiousness: u8, + extraversion: u8, + agreeableness: u8, + neuroticism: u8, +} + +fn distributed(min: u8, max: u8, rng: &mut impl Rng) -> u8 { + let l = max - min; + min + rng.gen_range(0..=l / 3) + + rng.gen_range(0..=l / 3 + l % 3 % 2) + + rng.gen_range(0..=l / 3 + l % 3 / 2) +} + +impl Personality { + pub const HIGH_THRESHOLD: u8 = Self::MAX - Self::LOW_THRESHOLD; + pub const LITTLE_HIGH: u8 = Self::MID + (Self::MAX - Self::MIN) / 20; + pub const LITTLE_LOW: u8 = Self::MID - (Self::MAX - Self::MIN) / 20; + pub const LOW_THRESHOLD: u8 = (Self::MAX - Self::MIN) / 5 * 2 + Self::MIN; + const MAX: u8 = 255; + pub const MID: u8 = (Self::MAX - Self::MIN) / 2; + const MIN: u8 = 0; + + fn distributed_value(rng: &mut impl Rng) -> u8 { distributed(Self::MIN, Self::MAX, rng) } + + pub fn random(rng: &mut impl Rng) -> Self { + Self { + openness: Self::distributed_value(rng), + conscientiousness: Self::distributed_value(rng), + extraversion: Self::distributed_value(rng), + agreeableness: Self::distributed_value(rng), + neuroticism: Self::distributed_value(rng), + } + } + + pub fn random_evil(rng: &mut impl Rng) -> Self { + Self { + openness: Self::distributed_value(rng), + extraversion: Self::distributed_value(rng), + neuroticism: Self::distributed_value(rng), + agreeableness: distributed(0, Self::LOW_THRESHOLD - 1, rng), + conscientiousness: distributed(0, Self::LOW_THRESHOLD - 1, rng), + } + } + + pub fn random_good(rng: &mut impl Rng) -> Self { + Self { + openness: Self::distributed_value(rng), + extraversion: Self::distributed_value(rng), + neuroticism: Self::distributed_value(rng), + agreeableness: Self::distributed_value(rng), + conscientiousness: distributed(Self::LOW_THRESHOLD, Self::MAX, rng), + } + } + + pub fn is(&self, trait_: PersonalityTrait) -> bool { + match trait_ { + PersonalityTrait::Open => self.openness > Personality::HIGH_THRESHOLD, + PersonalityTrait::Adventurous => { + self.openness > Personality::HIGH_THRESHOLD && self.neuroticism < Personality::MID + }, + PersonalityTrait::Closed => self.openness < Personality::LOW_THRESHOLD, + PersonalityTrait::Conscientious => self.conscientiousness > Personality::HIGH_THRESHOLD, + PersonalityTrait::Busybody => self.agreeableness < Personality::LOW_THRESHOLD, + PersonalityTrait::Unconscientious => { + self.conscientiousness < Personality::LOW_THRESHOLD + }, + PersonalityTrait::Extroverted => self.extraversion > Personality::HIGH_THRESHOLD, + PersonalityTrait::Introverted => self.extraversion < Personality::LOW_THRESHOLD, + PersonalityTrait::Agreeable => self.agreeableness > Personality::HIGH_THRESHOLD, + PersonalityTrait::Sociable => { + self.agreeableness > Personality::HIGH_THRESHOLD + && self.extraversion > Personality::MID + }, + PersonalityTrait::Disagreeable => self.agreeableness < Personality::LOW_THRESHOLD, + PersonalityTrait::Neurotic => self.neuroticism > Personality::HIGH_THRESHOLD, + PersonalityTrait::Seeker => { + self.neuroticism > Personality::HIGH_THRESHOLD + && self.openness > Personality::LITTLE_HIGH + }, + PersonalityTrait::Worried => { + self.neuroticism > Personality::HIGH_THRESHOLD + && self.agreeableness > Personality::LITTLE_HIGH + }, + PersonalityTrait::SadLoner => { + self.neuroticism > Personality::HIGH_THRESHOLD + && self.extraversion < Personality::LITTLE_LOW + }, + PersonalityTrait::Stable => self.neuroticism < Personality::LOW_THRESHOLD, + } + } + + pub fn chat_trait(&self, rng: &mut impl Rng) -> Option { + PersonalityTrait::iter().filter(|t| self.is(*t)).choose(rng) + } + + pub fn will_ambush(&self) -> bool { + self.agreeableness < Self::LOW_THRESHOLD && self.conscientiousness < Self::LOW_THRESHOLD + } + + pub fn get_generic_comment(&self, rng: &mut impl Rng) -> Content { + let i18n_key = if let Some(extreme_trait) = self.chat_trait(rng) { + match extreme_trait { + PersonalityTrait::Open => "npc-speech-villager_open", + PersonalityTrait::Adventurous => "npc-speech-villager_adventurous", + PersonalityTrait::Closed => "npc-speech-villager_closed", + PersonalityTrait::Conscientious => "npc-speech-villager_conscientious", + PersonalityTrait::Busybody => "npc-speech-villager_busybody", + PersonalityTrait::Unconscientious => "npc-speech-villager_unconscientious", + PersonalityTrait::Extroverted => "npc-speech-villager_extroverted", + PersonalityTrait::Introverted => "npc-speech-villager_introverted", + PersonalityTrait::Agreeable => "npc-speech-villager_agreeable", + PersonalityTrait::Sociable => "npc-speech-villager_sociable", + PersonalityTrait::Disagreeable => "npc-speech-villager_disagreeable", + PersonalityTrait::Neurotic => "npc-speech-villager_neurotic", + PersonalityTrait::Seeker => "npc-speech-villager_seeker", + PersonalityTrait::SadLoner => "npc-speech-villager_sad_loner", + PersonalityTrait::Worried => "npc-speech-villager_worried", + PersonalityTrait::Stable => "npc-speech-villager_stable", + } + } else { + "npc-speech-villager" + }; + + Content::localized(i18n_key) + } +} + +impl Default for Personality { + fn default() -> Self { + Self { + openness: Personality::MID, + conscientiousness: Personality::MID, + extraversion: Personality::MID, + agreeableness: Personality::MID, + neuroticism: Personality::MID, + } + } } /// This type is the map route through which the rtsim (real-time simulation) @@ -49,36 +219,112 @@ pub enum MemoryItem { /// into the game as a physical entity or not). Agent code should attempt to act /// upon its instructions where reasonable although deviations for various /// reasons (obstacle avoidance, counter-attacking, etc.) are expected. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub struct RtSimController { - /// When this field is `Some(..)`, the agent should attempt to make progress - /// toward the given location, accounting for obstacles and other - /// high-priority situations like being attacked. - pub travel_to: Option<(Vec3, String)>, - /// Proportion of full speed to move - pub speed_factor: f32, - /// Events - pub events: Vec, -} - -impl Default for RtSimController { - fn default() -> Self { - Self { - travel_to: None, - speed_factor: 1.0, - events: Vec::new(), - } - } + pub activity: Option, + pub actions: VecDeque, + pub personality: Personality, + pub heading_to: Option, } impl RtSimController { - pub fn reset(&mut self) { *self = Self::default(); } - pub fn with_destination(pos: Vec3) -> Self { Self { - travel_to: Some((pos, format!("{:0.1?}", pos))), - speed_factor: 0.25, - events: Vec::new(), + activity: Some(NpcActivity::Goto(pos, 0.5)), + ..Default::default() + } + } +} + +#[derive(Clone, Copy, Debug)] +pub enum NpcActivity { + /// (travel_to, speed_factor) + Goto(Vec3, f32), + Gather(&'static [ChunkResource]), + // TODO: Generalise to other entities? What kinds of animals? + HuntAnimals, + Dance, +} + +/// Represents event-like actions that rtsim NPCs can perform to interact with +/// the world +#[derive(Clone, Debug)] +pub enum NpcAction { + /// Speak the given message, with an optional target for that speech. + // TODO: Use some sort of structured, language-independent value that frontends can translate + // instead + Say(Option, Content), + /// Attack the given target + Attack(Actor), +} + +// Note: the `serde(name = "...")` is to minimise the length of field +// identifiers for the sake of rtsim persistence +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, enum_map::Enum)] +pub enum ChunkResource { + #[serde(rename = "0")] + Grass, + #[serde(rename = "1")] + Flower, + #[serde(rename = "2")] + Fruit, + #[serde(rename = "3")] + Vegetable, + #[serde(rename = "4")] + Mushroom, + #[serde(rename = "5")] + Loot, // Chests, boxes, potions, etc. + #[serde(rename = "6")] + Plant, // Flax, cotton, wheat, corn, etc. + #[serde(rename = "7")] + Stone, + #[serde(rename = "8")] + Wood, // Twigs, logs, bamboo, etc. + #[serde(rename = "9")] + Gem, // Amethyst, diamond, etc. + #[serde(rename = "a")] + Ore, // Iron, copper, etc. +} + +// Note: the `serde(name = "...")` is to minimise the length of field +// identifiers for the sake of rtsim persistence +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum Profession { + #[serde(rename = "0")] + Farmer, + #[serde(rename = "1")] + Hunter, + #[serde(rename = "2")] + Merchant, + #[serde(rename = "3")] + Guard, + #[serde(rename = "4")] + Adventurer(u32), + #[serde(rename = "5")] + Blacksmith, + #[serde(rename = "6")] + Chef, + #[serde(rename = "7")] + Alchemist, + #[serde(rename = "8")] + Pirate, + #[serde(rename = "9")] + Cultist, + #[serde(rename = "10")] + Herbalist, + #[serde(rename = "11")] + Captain, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WorldSettings { + pub start_time: f64, +} + +impl Default for WorldSettings { + fn default() -> Self { + Self { + start_time: 9.0 * 3600.0, // 9am } } } diff --git a/common/src/states/basic_ranged.rs b/common/src/states/basic_ranged.rs index 84fbd82165..f03e7810f2 100644 --- a/common/src/states/basic_ranged.rs +++ b/common/src/states/basic_ranged.rs @@ -91,7 +91,10 @@ impl CharacterBehavior for Data { // Shoots all projectiles simultaneously for i in 0..self.static_data.num_projectiles { // Gets offsets - let body_offsets = data.body.projectile_offsets(update.ori.look_vec()); + let body_offsets = data.body.projectile_offsets( + update.ori.look_vec(), + data.scale.map_or(1.0, |s| s.0), + ); let pos = Pos(data.pos.0 + body_offsets); // Adds a slight spread to the projectiles. First projectile has no spread, // and spread increases linearly with number of projectiles created. diff --git a/common/src/states/basic_summon.rs b/common/src/states/basic_summon.rs index 810644dea2..fdda7ed54d 100644 --- a/common/src/states/basic_summon.rs +++ b/common/src/states/basic_summon.rs @@ -6,7 +6,7 @@ use crate::{ skillset::skills, Behavior, BehaviorCapability, CharacterState, Projectile, StateUpdate, }, - event::{LocalEvent, ServerEvent}, + event::{LocalEvent, NpcBuilder, ServerEvent}, outcome::Outcome, skillset_builder::{self, SkillSetBuilder}, states::{ @@ -149,7 +149,7 @@ impl CharacterBehavior for Data { let collision_vector = Vec3::new( data.pos.0.x + (summon_frac * 2.0 * PI).sin() * obstacle_xy, data.pos.0.y + (summon_frac * 2.0 * PI).cos() * obstacle_xy, - data.pos.0.z + data.body.eye_height(), + data.pos.0.z + data.body.eye_height(data.scale.map_or(1.0, |s| s.0)), ); // Check for collision in z up to 50 blocks @@ -174,27 +174,22 @@ impl CharacterBehavior for Data { // Send server event to create npc output_events.emit_server(ServerEvent::CreateNpc { pos: comp::Pos(collision_vector - Vec3::unit_z() * obstacle_z), - stats, - skill_set, - health, - poise: comp::Poise::new(body), - inventory: comp::Inventory::with_loadout(loadout, body), - body, - agent: Some( - comp::Agent::from_body(&body) - .with_behavior(Behavior::from(BehaviorCapability::SPEAK)) - .with_no_flee_if(true), - ), - alignment: comp::Alignment::Owned(*data.uid), - scale: self - .static_data - .summon_info - .scale - .unwrap_or(comp::Scale(1.0)), - anchor: None, - loot: crate::lottery::LootSpec::Nothing, - rtsim_entity: None, - projectile, + npc: NpcBuilder::new(stats, body, comp::Alignment::Owned(*data.uid)) + .with_skill_set(skill_set) + .with_health(health) + .with_inventory(comp::Inventory::with_loadout(loadout, body)) + .with_agent( + comp::Agent::from_body(&body) + .with_behavior(Behavior::from(BehaviorCapability::SPEAK)) + .with_no_flee_if(true), + ) + .with_scale( + self.static_data + .summon_info + .scale + .unwrap_or(comp::Scale(1.0)), + ) + .with_projectile(projectile), }); // Send local event used for frontend shenanigans diff --git a/common/src/states/behavior.rs b/common/src/states/behavior.rs index 766a1738bc..0746a94b84 100644 --- a/common/src/states/behavior.rs +++ b/common/src/states/behavior.rs @@ -3,10 +3,10 @@ use crate::{ self, character_state::OutputEvents, item::{tool::AbilityMap, MaterialStatManifest}, - ActiveAbilities, Beam, Body, CharacterState, Combo, ControlAction, Controller, - ControllerInputs, Density, Energy, Health, InputAttr, InputKind, Inventory, - InventoryAction, Mass, Melee, Ori, PhysicsState, Pos, SkillSet, Stance, StateUpdate, Stats, - Vel, + ActiveAbilities, Beam, Body, CharacterActivity, CharacterState, Combo, ControlAction, + Controller, ControllerInputs, Density, Energy, Health, InputAttr, InputKind, Inventory, + InventoryAction, Mass, Melee, Ori, PhysicsState, Pos, Scale, SkillSet, Stance, StateUpdate, + Stats, Vel, }, link::Is, mounting::Rider, @@ -120,9 +120,11 @@ pub struct JoinData<'a> { pub entity: Entity, pub uid: &'a Uid, pub character: &'a CharacterState, + pub character_activity: &'a CharacterActivity, pub pos: &'a Pos, pub vel: &'a Vel, pub ori: &'a Ori, + pub scale: Option<&'a Scale>, pub mass: &'a Mass, pub density: &'a Density, pub dt: &'a DeltaTime, @@ -152,9 +154,11 @@ pub struct JoinStruct<'a> { pub entity: Entity, pub uid: &'a Uid, pub char_state: FlaggedAccessMut<'a, &'a mut CharacterState, CharacterState>, + pub character_activity: FlaggedAccessMut<'a, &'a mut CharacterActivity, CharacterActivity>, pub pos: &'a mut Pos, pub vel: &'a mut Vel, pub ori: &'a mut Ori, + pub scale: Option<&'a Scale>, pub mass: &'a Mass, pub density: FlaggedAccessMut<'a, &'a mut Density, Density>, pub energy: FlaggedAccessMut<'a, &'a mut Energy, Energy>, @@ -188,9 +192,11 @@ impl<'a> JoinData<'a> { entity: j.entity, uid: j.uid, character: &j.char_state, + character_activity: &j.character_activity, pos: j.pos, vel: j.vel, ori: j.ori, + scale: j.scale, mass: j.mass, density: &j.density, energy: &j.energy, diff --git a/common/src/states/charged_ranged.rs b/common/src/states/charged_ranged.rs index 0277c42312..fcc03f9b2b 100644 --- a/common/src/states/charged_ranged.rs +++ b/common/src/states/charged_ranged.rs @@ -114,7 +114,9 @@ impl CharacterBehavior for Data { get_crit_data(data, self.static_data.ability_info); let tool_stats = get_tool_stats(data, self.static_data.ability_info); // Gets offsets - let body_offsets = data.body.projectile_offsets(update.ori.look_vec()); + let body_offsets = data + .body + .projectile_offsets(update.ori.look_vec(), data.scale.map_or(1.0, |s| s.0)); let pos = Pos(data.pos.0 + body_offsets); let projectile = arrow.create_projectile( Some(*data.uid), diff --git a/common/src/states/climb.rs b/common/src/states/climb.rs index 68c2dd0be0..6ca51d0337 100644 --- a/common/src/states/climb.rs +++ b/common/src/states/climb.rs @@ -76,7 +76,8 @@ impl CharacterBehavior for Data { // They've climbed atop something, give them a boost output_events.emit_local(LocalEvent::Jump( data.entity, - CLIMB_BOOST_JUMP_FACTOR * impulse / data.mass.0, + CLIMB_BOOST_JUMP_FACTOR * impulse / data.mass.0 + * data.scale.map_or(1.0, |s| s.0.powf(13.0).powf(0.25)), )); }; update.character = CharacterState::Idle(idle::Data::default()); @@ -122,10 +123,14 @@ impl CharacterBehavior for Data { // Apply Vertical Climbing Movement match climb { Climb::Down => { - update.vel.0.z += data.dt.0 * (GRAVITY - self.static_data.movement_speed.powi(2)) + update.vel.0.z += data.dt.0 + * (GRAVITY + - self.static_data.movement_speed.powi(2) * data.scale.map_or(1.0, |s| s.0)) }, Climb::Up => { - update.vel.0.z += data.dt.0 * (GRAVITY + self.static_data.movement_speed.powi(2)) + update.vel.0.z += data.dt.0 + * (GRAVITY + + self.static_data.movement_speed.powi(2) * data.scale.map_or(1.0, |s| s.0)) }, Climb::Hold => update.vel.0.z += data.dt.0 * GRAVITY, } diff --git a/common/src/states/repeater_ranged.rs b/common/src/states/repeater_ranged.rs index 639c47f57b..f1bcce0972 100644 --- a/common/src/states/repeater_ranged.rs +++ b/common/src/states/repeater_ranged.rs @@ -95,7 +95,9 @@ impl CharacterBehavior for Data { get_crit_data(data, self.static_data.ability_info); let tool_stats = get_tool_stats(data, self.static_data.ability_info); // Gets offsets - let body_offsets = data.body.projectile_offsets(update.ori.look_vec()); + let body_offsets = data + .body + .projectile_offsets(update.ori.look_vec(), data.scale.map_or(1.0, |s| s.0)); let pos = Pos(data.pos.0 + body_offsets); let projectile = self.static_data.projectile.create_projectile( Some(*data.uid), diff --git a/common/src/states/utils.rs b/common/src/states/utils.rs index 4457f0df51..ade2c10ed0 100644 --- a/common/src/states/utils.rs +++ b/common/src/states/utils.rs @@ -220,7 +220,7 @@ impl Body { _ => 2.0, }, Body::Ship(ship) if ship.has_water_thrust() => 0.1, - Body::Ship(_) => 0.035, + Body::Ship(_) => 0.12, Body::Arthropod(_) => 3.5, } } @@ -298,10 +298,10 @@ impl Body { /// Returns the position where a projectile should be fired relative to this /// body - pub fn projectile_offsets(&self, ori: Vec3) -> Vec3 { + pub fn projectile_offsets(&self, ori: Vec3, scale: f32) -> Vec3 { let body_offsets_z = match self { Body::Golem(_) => self.height() * 0.4, - _ => self.eye_height(), + _ => self.eye_height(scale), }; let dim = self.dimensions(); @@ -385,7 +385,11 @@ fn basic_move(data: &JoinData<'_>, update: &mut StateUpdate, efficiency: f32) { let accel = if let Some(block) = data.physics.on_ground { // FRIC_GROUND temporarily used to normalize things around expected values - data.body.base_accel() * block.get_traction() * block.get_friction() / FRIC_GROUND + data.body.base_accel() + * data.scale.map_or(1.0, |s| s.0.sqrt()) + * block.get_traction() + * block.get_friction() + / FRIC_GROUND } else { data.body.air_accel() } * efficiency; @@ -434,8 +438,11 @@ pub fn handle_forced_movement( // FRIC_GROUND temporarily used to normalize things around expected values data.body.base_accel() * block.get_traction() * block.get_friction() / FRIC_GROUND }) { - update.vel.0 += - Vec2::broadcast(data.dt.0) * accel * Vec2::from(*data.ori) * strength; + update.vel.0 += Vec2::broadcast(data.dt.0) + * accel + * data.scale.map_or(1.0, |s| s.0.sqrt()) + * Vec2::from(*data.ori) + * strength; } }, ForcedMovement::Reverse(strength) => { @@ -444,8 +451,11 @@ pub fn handle_forced_movement( // FRIC_GROUND temporarily used to normalize things around expected values data.body.base_accel() * block.get_traction() * block.get_friction() / FRIC_GROUND }) { - update.vel.0 += - Vec2::broadcast(data.dt.0) * accel * -Vec2::from(*data.ori) * strength; + update.vel.0 += Vec2::broadcast(data.dt.0) + * accel + * data.scale.map_or(1.0, |s| s.0.sqrt()) + * -Vec2::from(*data.ori) + * strength; } }, ForcedMovement::Sideways(strength) => { @@ -467,7 +477,11 @@ pub fn handle_forced_movement( } }; - update.vel.0 += Vec2::broadcast(data.dt.0) * accel * direction * strength; + update.vel.0 += Vec2::broadcast(data.dt.0) + * accel + * data.scale.map_or(1.0, |s| s.0.sqrt()) + * direction + * strength; } }, ForcedMovement::DirectedReverse(strength) => { @@ -516,6 +530,7 @@ pub fn handle_forced_movement( dir.y, vertical, ) + * data.scale.map_or(1.0, |s| s.0.sqrt()) // Multiply decreasing amount linearly over time (with average of 1) * 2.0 * progress // Apply direction @@ -529,7 +544,9 @@ pub fn handle_forced_movement( }, ForcedMovement::Hover { move_input } => { update.vel.0 = Vec3::new(data.vel.0.x, data.vel.0.y, 0.0) - + move_input * data.inputs.move_dir.try_normalized().unwrap_or_default(); + + move_input + * data.scale.map_or(1.0, |s| s.0.sqrt()) + * data.inputs.move_dir.try_normalized().unwrap_or_default(); }, } } @@ -569,7 +586,7 @@ pub fn handle_orientation( .map_or_else(|| to_horizontal_fast(data.ori), |dir| dir.into()) }; // unit is multiples of 180° - let half_turns_per_tick = data.body.base_ori_rate() + let half_turns_per_tick = data.body.base_ori_rate() / data.scale.map_or(1.0, |s| s.0.sqrt()) * efficiency * if data.physics.on_ground.is_some() { 1.0 @@ -594,6 +611,9 @@ pub fn handle_orientation( .ori .slerped_towards(target_ori, target_fraction.min(1.0)) }; + + // Look at things + update.character_activity.look_dir = Some(data.controller.inputs.look_dir); } /// Updates components to move player as if theyre swimming @@ -605,7 +625,7 @@ fn swim_move( ) -> bool { let efficiency = efficiency * data.stats.move_speed_modifier * data.stats.friction_modifier; if let Some(force) = data.body.swim_thrust() { - let force = efficiency * force; + let force = efficiency * force * data.scale.map_or(1.0, |s| s.0); let mut water_accel = force / data.mass.0; if let Ok(level) = data.skill_set.skill_level(Skill::Swim(SwimSkill::Speed)) { @@ -912,13 +932,13 @@ pub fn handle_manipulate_loadout( let iters = (3.0 * (sprite_pos_f32 - data.pos.0).map(|x| x.abs()).sum()) as usize; // Heuristic compares manhattan distance of start and end pos - let heuristic = - move |pos: &Vec3| (sprite_pos - pos).map(|x| x.abs()).sum() as f32; + let heuristic = move |pos: &Vec3, _: &Vec3| { + (sprite_pos - pos).map(|x| x.abs()).sum() as f32 + }; let mut astar = Astar::new( iters, data.pos.0.map(|x| x.floor() as i32), - heuristic, BuildHasherDefault::::default(), ); @@ -1068,7 +1088,9 @@ pub fn handle_jump( .map(|impulse| { output_events.emit_local(LocalEvent::Jump( data.entity, - strength * impulse / data.mass.0 * data.stats.move_speed_modifier, + strength * impulse / data.mass.0 + * data.scale.map_or(1.0, |s| s.0.powf(13.0).powf(0.25)) + * data.stats.move_speed_modifier, )); }) .is_some() diff --git a/common/src/terrain/block.rs b/common/src/terrain/block.rs index 6e8669586b..9ca94ee0e6 100644 --- a/common/src/terrain/block.rs +++ b/common/src/terrain/block.rs @@ -3,7 +3,7 @@ use crate::{ comp::{fluid_dynamics::LiquidKind, tool::ToolKind}, consts::FRIC_GROUND, lottery::LootSpec, - make_case_elim, + make_case_elim, rtsim, }; use num_derive::FromPrimitive; use num_traits::FromPrimitive; @@ -195,6 +195,98 @@ impl Block { } } + /// Returns the rtsim resource, if any, that this block corresponds to. If + /// you want the scarcity of a block to change with rtsim's resource + /// depletion tracking, you can do so by editing this function. + #[inline] + pub fn get_rtsim_resource(&self) -> Option { + match self.get_sprite()? { + SpriteKind::Stones => Some(rtsim::ChunkResource::Stone), + SpriteKind::Twigs + | SpriteKind::Wood + | SpriteKind::Bamboo + | SpriteKind::Hardwood + | SpriteKind::Ironwood + | SpriteKind::Frostwood + | SpriteKind::Eldwood => Some(rtsim::ChunkResource::Wood), + SpriteKind::Amethyst + | SpriteKind::Ruby + | SpriteKind::Sapphire + | SpriteKind::Emerald + | SpriteKind::Topaz + | SpriteKind::Diamond + | SpriteKind::AmethystSmall + | SpriteKind::TopazSmall + | SpriteKind::DiamondSmall + | SpriteKind::RubySmall + | SpriteKind::EmeraldSmall + | SpriteKind::SapphireSmall + | SpriteKind::CrystalHigh + | SpriteKind::CrystalLow => Some(rtsim::ChunkResource::Gem), + SpriteKind::Bloodstone + | SpriteKind::Coal + | SpriteKind::Cobalt + | SpriteKind::Copper + | SpriteKind::Iron + | SpriteKind::Tin + | SpriteKind::Silver + | SpriteKind::Gold => Some(rtsim::ChunkResource::Ore), + + SpriteKind::LongGrass + | SpriteKind::MediumGrass + | SpriteKind::ShortGrass + | SpriteKind::LargeGrass + | SpriteKind::GrassSnow + | SpriteKind::GrassBlue + | SpriteKind::SavannaGrass + | SpriteKind::TallSavannaGrass + | SpriteKind::RedSavannaGrass + | SpriteKind::JungleRedGrass + | SpriteKind::Fern => Some(rtsim::ChunkResource::Grass), + SpriteKind::BlueFlower + | SpriteKind::PinkFlower + | SpriteKind::PurpleFlower + | SpriteKind::RedFlower + | SpriteKind::WhiteFlower + | SpriteKind::YellowFlower + | SpriteKind::Sunflower + | SpriteKind::Moonbell + | SpriteKind::Pyrebloom => Some(rtsim::ChunkResource::Flower), + SpriteKind::Reed + | SpriteKind::Flax + | SpriteKind::WildFlax + | SpriteKind::Cotton + | SpriteKind::Corn + | SpriteKind::WheatYellow + | SpriteKind::WheatGreen => Some(rtsim::ChunkResource::Plant), + SpriteKind::Apple + | SpriteKind::Pumpkin + | SpriteKind::Beehive // TODO: Not a fruit, but kind of acts like one + | SpriteKind::Coconut => Some(rtsim::ChunkResource::Fruit), + SpriteKind::Cabbage + | SpriteKind::Carrot + | SpriteKind::Tomato + | SpriteKind::Radish + | SpriteKind::Turnip => Some(rtsim::ChunkResource::Vegetable), + SpriteKind::Mushroom + | SpriteKind::CaveMushroom + | SpriteKind::CeilingMushroom => Some(rtsim::ChunkResource::Mushroom), + + SpriteKind::Chest + | SpriteKind::ChestBuried + | SpriteKind::PotionMinor + | SpriteKind::DungeonChest0 + | SpriteKind::DungeonChest1 + | SpriteKind::DungeonChest2 + | SpriteKind::DungeonChest3 + | SpriteKind::DungeonChest4 + | SpriteKind::DungeonChest5 + | SpriteKind::CoralChest + | SpriteKind::Crate => Some(rtsim::ChunkResource::Loot), + _ => None, + } + } + #[inline] pub fn get_glow(&self) -> Option { match self.kind() { diff --git a/common/src/terrain/mod.rs b/common/src/terrain/mod.rs index 7f05512f40..a2fb964cf8 100644 --- a/common/src/terrain/mod.rs +++ b/common/src/terrain/mod.rs @@ -84,7 +84,9 @@ pub trait CoordinateConversions { impl CoordinateConversions for Vec2 { #[inline] - fn wpos_to_cpos(&self) -> Self { self.map2(TerrainChunkSize::RECT_SIZE, |e, sz| e / sz as i32) } + fn wpos_to_cpos(&self) -> Self { + self.map2(TerrainChunkSize::RECT_SIZE, |e, sz| e.div_euclid(sz as i32)) + } #[inline] fn cpos_to_wpos(&self) -> Self { self.map2(TerrainChunkSize::RECT_SIZE, |e, sz| e * sz as i32) } @@ -222,11 +224,13 @@ impl TerrainChunkMeta { pub type TerrainChunk = chonk::Chonk; pub type TerrainGrid = VolGrid2d; +const TERRAIN_GRID_SEARCH_DIST: i32 = 63; + impl TerrainGrid { /// Find a location suitable for spawning an entity near the given /// position (but in the same chunk). - pub fn find_space(&self, pos: Vec3) -> Vec3 { - self.try_find_space(pos).unwrap_or(pos) + pub fn find_ground(&self, pos: Vec3) -> Vec3 { + self.try_find_ground(pos).unwrap_or(pos) } pub fn is_space(&self, pos: Vec3) -> bool { @@ -237,8 +241,14 @@ impl TerrainGrid { } pub fn try_find_space(&self, pos: Vec3) -> Option> { - const SEARCH_DIST: i32 = 63; - (0..SEARCH_DIST * 2 + 1) + (0..TERRAIN_GRID_SEARCH_DIST * 2 + 1) + .map(|i| if i % 2 == 0 { i } else { -i } / 2) + .map(|z_diff| pos + Vec3::unit_z() * z_diff) + .find(|pos| self.is_space(*pos)) + } + + pub fn try_find_ground(&self, pos: Vec3) -> Option> { + (0..TERRAIN_GRID_SEARCH_DIST * 2 + 1) .map(|i| if i % 2 == 0 { i } else { -i } / 2) .map(|z_diff| pos + Vec3::unit_z() * z_diff) .find(|pos| { diff --git a/common/src/trade.rs b/common/src/trade.rs index 496c05358e..3b3ca0f7eb 100644 --- a/common/src/trade.rs +++ b/common/src/trade.rs @@ -381,31 +381,29 @@ impl SitePrices { inventories: &[Option; 2], who: usize, reduce: bool, - ) -> f32 { + ) -> Option { offers[who] .iter() .map(|(slot, amount)| { inventories[who] .as_ref() - .and_then(|ri| { - ri.inventory.get(slot).map(|item| { - if let Some(vec) = TradePricing::get_materials(&item.name.as_ref()) { - vec.iter() - .map(|(amount2, material)| { - self.values.get(material).copied().unwrap_or_default() - * *amount2 - * (if reduce { material.trade_margin() } else { 1.0 }) - }) - .sum::() - * (*amount as f32) - } else { - 0.0 - } - }) + .map(|ri| { + let item = ri.inventory.get(slot)?; + let vec = TradePricing::get_materials(&item.name.as_ref())?; + Some( + vec.iter() + .map(|(amount2, material)| { + self.values.get(material).copied().unwrap_or_default() + * *amount2 + * (if reduce { material.trade_margin() } else { 1.0 }) + }) + .sum::() + * (*amount as f32), + ) }) - .unwrap_or_default() + .unwrap_or(Some(0.0)) }) - .sum() + .try_fold(0.0, |a, p| Some(a + p?)) } } diff --git a/common/state/src/lib.rs b/common/state/src/lib.rs index d0047fe6de..34025380bf 100644 --- a/common/state/src/lib.rs +++ b/common/state/src/lib.rs @@ -6,4 +6,4 @@ mod build_areas; mod state; // TODO: breakup state module and remove glob pub use build_areas::{BuildAreaError, BuildAreas}; -pub use state::{BlockChange, State, TerrainChanges}; +pub use state::{BlockChange, BlockDiff, State, TerrainChanges}; diff --git a/common/state/src/state.rs b/common/state/src/state.rs index 6bc741ee84..20224a5748 100644 --- a/common/state/src/state.rs +++ b/common/state/src/state.rs @@ -113,6 +113,13 @@ impl TerrainChanges { } } +#[derive(Clone)] +pub struct BlockDiff { + pub wpos: Vec3, + pub old: Block, + pub new: Block, +} + /// A type used to represent game state stored on both the client and the /// server. This includes things like entity components, terrain data, and /// global states like weather, time of day, etc. @@ -199,6 +206,7 @@ impl State { ecs.register::(); ecs.register::(); ecs.register::(); + ecs.register::(); ecs.register::(); ecs.register::(); ecs.register::(); @@ -524,7 +532,9 @@ impl State { } // Apply terrain changes - pub fn apply_terrain_changes(&self) { self.apply_terrain_changes_internal(false); } + pub fn apply_terrain_changes(&self, block_update: impl FnMut(&specs::World, Vec)) { + self.apply_terrain_changes_internal(false, block_update); + } /// `during_tick` is true if and only if this is called from within /// [State::tick]. @@ -534,7 +544,11 @@ impl State { /// from within both the client and the server ticks, right after /// handling terrain messages; currently, client sets it to true and /// server to false. - fn apply_terrain_changes_internal(&self, during_tick: bool) { + fn apply_terrain_changes_internal( + &self, + during_tick: bool, + mut block_update: impl FnMut(&specs::World, Vec), + ) { span!( _guard, "apply_terrain_changes", @@ -575,17 +589,30 @@ impl State { } // Apply block modifications // Only include in `TerrainChanges` if successful - modified_blocks.retain(|pos, block| { - let res = terrain.set(*pos, *block); - if let (&Ok(old_block), true) = (&res, during_tick) { + let mut updated_blocks = Vec::with_capacity(modified_blocks.len()); + modified_blocks.retain(|wpos, new| { + let res = terrain.map(*wpos, |old| { + updated_blocks.push(BlockDiff { + wpos: *wpos, + old, + new: *new, + }); + *new + }); + if let (&Ok(old), true) = (&res, during_tick) { // NOTE: If the changes are applied during the tick, we push the *old* value as // the modified block (since it otherwise can't be recovered after the tick). // Otherwise, the changes will be applied after the tick, so we push the *new* // value. - *block = old_block; + *new = old; } res.is_ok() }); + + if !updated_blocks.is_empty() { + block_update(&self.ecs, updated_blocks); + } + self.ecs.write_resource::().modified_blocks = modified_blocks; } @@ -597,6 +624,7 @@ impl State { update_terrain_and_regions: bool, mut metrics: Option<&mut StateTickMetrics>, server_constants: &ServerConstants, + block_update: impl FnMut(&specs::World, Vec), ) { span!(_guard, "tick", "State::tick"); @@ -643,7 +671,7 @@ impl State { drop(guard); if update_terrain_and_regions { - self.apply_terrain_changes_internal(true); + self.apply_terrain_changes_internal(true, block_update); } // Process local events diff --git a/common/systems/src/character_behavior.rs b/common/systems/src/character_behavior.rs index 133c66d869..39ffd3e13a 100644 --- a/common/systems/src/character_behavior.rs +++ b/common/systems/src/character_behavior.rs @@ -8,9 +8,9 @@ use common::{ self, character_state::OutputEvents, inventory::item::{tool::AbilityMap, MaterialStatManifest}, - ActiveAbilities, Beam, Body, CharacterState, Combo, Controller, Density, Energy, Health, - Inventory, InventoryManip, Mass, Melee, Ori, PhysicsState, Poise, Pos, SkillSet, Stance, - StateUpdate, Stats, Vel, + ActiveAbilities, Beam, Body, CharacterActivity, CharacterState, Combo, Controller, Density, + Energy, Health, Inventory, InventoryManip, Mass, Melee, Ori, PhysicsState, Poise, Pos, + Scale, SkillSet, Stance, StateUpdate, Stats, Vel, }, event::{EventBus, LocalEvent, ServerEvent}, link::Is, @@ -37,6 +37,7 @@ pub struct ReadData<'a> { healths: ReadStorage<'a, Health>, bodies: ReadStorage<'a, Body>, masses: ReadStorage<'a, Mass>, + scales: ReadStorage<'a, Scale>, physics_states: ReadStorage<'a, PhysicsState>, melee_attacks: ReadStorage<'a, Melee>, beams: ReadStorage<'a, Beam>, @@ -64,6 +65,7 @@ impl<'a> System<'a> for Sys { type SystemData = ( ReadData<'a>, WriteStorage<'a, CharacterState>, + WriteStorage<'a, CharacterActivity>, WriteStorage<'a, Pos>, WriteStorage<'a, Vel>, WriteStorage<'a, Ori>, @@ -83,6 +85,7 @@ impl<'a> System<'a> for Sys { ( read_data, mut character_states, + mut character_activities, mut positions, mut velocities, mut orientations, @@ -105,6 +108,7 @@ impl<'a> System<'a> for Sys { entity, uid, mut char_state, + character_activity, pos, vel, ori, @@ -115,13 +119,13 @@ impl<'a> System<'a> for Sys { controller, health, body, - physics, - (stat, skill_set, active_abilities, is_rider), + (physics, scale, stat, skill_set, active_abilities, is_rider), combo, ) in ( &read_data.entities, &read_data.uids, &mut character_states, + &mut character_activities, &mut positions, &mut velocities, &mut orientations, @@ -132,8 +136,9 @@ impl<'a> System<'a> for Sys { &mut controllers, read_data.healths.maybe(), &read_data.bodies, - &read_data.physics_states, ( + &read_data.physics_states, + read_data.scales.maybe(), &read_data.stats, &read_data.skill_sets, read_data.active_abilities.maybe(), @@ -180,9 +185,11 @@ impl<'a> System<'a> for Sys { entity, uid, char_state, + character_activity, pos, vel, ori, + scale, mass, density, energy, @@ -258,6 +265,9 @@ impl Sys { if *join.char_state != state_update.character { *join.char_state = state_update.character } + if *join.character_activity != state_update.character_activity { + *join.character_activity = state_update.character_activity + } if *join.density != state_update.density { *join.density = state_update.density } diff --git a/common/systems/src/controller.rs b/common/systems/src/controller.rs index 2386709f06..0102b5ee11 100644 --- a/common/systems/src/controller.rs +++ b/common/systems/src/controller.rs @@ -2,7 +2,7 @@ use common::{ comp::{ ability::Stance, agent::{Sound, SoundKind}, - Body, BuffChange, ControlEvent, Controller, Pos, + Body, BuffChange, ControlEvent, Controller, Pos, Scale, }, event::{EventBus, ServerEvent}, uid::UidAllocator, @@ -22,6 +22,7 @@ pub struct ReadData<'a> { server_bus: Read<'a, EventBus>, positions: ReadStorage<'a, Pos>, bodies: ReadStorage<'a, Body>, + scales: ReadStorage<'a, Scale>, } #[derive(Default)] @@ -91,13 +92,15 @@ impl<'a> System<'a> for Sys { }, ControlEvent::Respawn => server_emitter.emit(ServerEvent::Respawn(entity)), ControlEvent::Utterance(kind) => { - if let (Some(pos), Some(body)) = ( + if let (Some(pos), Some(body), scale) = ( read_data.positions.get(entity), read_data.bodies.get(entity), + read_data.scales.get(entity), ) { let sound = Sound::new( SoundKind::Utterance(kind, *body), - pos.0 + Vec3::unit_z() * body.eye_height(), + pos.0 + + Vec3::unit_z() * body.eye_height(scale.map_or(1.0, |s| s.0)), 8.0, // TODO: Come up with a better way of determining this 1.0, ); diff --git a/common/systems/src/lib.rs b/common/systems/src/lib.rs index a0e18fcf5f..1c4e108177 100644 --- a/common/systems/src/lib.rs +++ b/common/systems/src/lib.rs @@ -1,4 +1,4 @@ -#![feature(btree_drain_filter)] +#![feature(drain_filter)] #![allow(clippy::option_map_unit_fn)] mod aura; diff --git a/common/systems/src/melee.rs b/common/systems/src/melee.rs index 077aa59d79..bbe32ddaf9 100644 --- a/common/systems/src/melee.rs +++ b/common/systems/src/melee.rs @@ -69,13 +69,14 @@ impl<'a> System<'a> for Sys { let mut rng = rand::thread_rng(); // Attacks - for (attacker, uid, pos, ori, melee_attack, body) in ( + for (attacker, uid, pos, ori, melee_attack, body, scale) in ( &read_data.entities, &read_data.uids, &read_data.positions, &read_data.orientations, &mut melee_attacks, &read_data.bodies, + read_data.scales.maybe(), ) .join() { @@ -88,7 +89,7 @@ impl<'a> System<'a> for Sys { melee_attack.applied = true; // Scales - let eye_pos = pos.0 + Vec3::unit_z() * body.eye_height(); + let eye_pos = pos.0 + Vec3::unit_z() * body.eye_height(scale.map_or(1.0, |s| s.0)); let scale = read_data.scales.get(attacker).map_or(1.0, |s| s.0); let height = body.height() * scale; // TODO: use Capsule Prisms instead of Cylinders diff --git a/common/systems/src/mount.rs b/common/systems/src/mount.rs index 1cfb25e68d..b8d70b37b9 100644 --- a/common/systems/src/mount.rs +++ b/common/systems/src/mount.rs @@ -1,5 +1,5 @@ use common::{ - comp::{Body, Controller, InputKind, Ori, Pos, Vel}, + comp::{Body, ControlAction, Controller, InputKind, Ori, Pos, Scale, Vel}, link::Is, mounting::Mount, uid::UidAllocator, @@ -24,6 +24,7 @@ impl<'a> System<'a> for Sys { WriteStorage<'a, Vel>, WriteStorage<'a, Ori>, ReadStorage<'a, Body>, + ReadStorage<'a, Scale>, ); const NAME: &'static str = "mount"; @@ -41,22 +42,24 @@ impl<'a> System<'a> for Sys { mut velocities, mut orientations, bodies, + scales, ): Self::SystemData, ) { // For each mount... for (entity, is_mount, body) in (&entities, &is_mounts, bodies.maybe()).join() { // ...find the rider... - let Some((inputs, queued_inputs, rider)) = uid_allocator + let Some((inputs, actions, rider)) = uid_allocator .retrieve_entity_internal(is_mount.rider.id()) .and_then(|rider| { controllers .get_mut(rider) .map(|c| { - let queued_inputs = c.queued_inputs - // TODO: Formalise ways to pass inputs to mounts - .drain_filter(|i, _| matches!(i, InputKind::Jump | InputKind::Fly | InputKind::Roll)) - .collect(); - (c.inputs.clone(), queued_inputs, rider) + let actions = c.actions.drain_filter(|action| match action { + ControlAction::StartInput { input: i, .. } + | ControlAction::CancelInput(i) => matches!(i, InputKind::Jump | InputKind::Fly | InputKind::Roll), + _ => false + }).collect(); + (c.inputs.clone(), actions, rider) }) }) else { continue }; @@ -68,18 +71,17 @@ impl<'a> System<'a> for Sys { if let (Some(pos), Some(ori), Some(vel)) = (pos, ori, vel) { let mounter_body = bodies.get(rider); let mounting_offset = body.map_or(Vec3::unit_z(), Body::mount_offset) - + mounter_body.map_or(Vec3::zero(), Body::rider_offset); + * scales.get(entity).map_or(1.0, |s| s.0) + + mounter_body.map_or(Vec3::zero(), Body::rider_offset) + * scales.get(rider).map_or(1.0, |s| s.0); let _ = positions.insert(rider, Pos(pos.0 + ori.to_quat() * mounting_offset)); let _ = orientations.insert(rider, ori); let _ = velocities.insert(rider, vel); } // ...and apply the rider's inputs to the mount's controller. if let Some(controller) = controllers.get_mut(entity) { - *controller = Controller { - inputs, - queued_inputs, - ..Default::default() - } + controller.inputs = inputs; + controller.actions = actions; } } } diff --git a/common/systems/src/phys.rs b/common/systems/src/phys.rs index a11bbcc053..6bf2cf9707 100644 --- a/common/systems/src/phys.rs +++ b/common/systems/src/phys.rs @@ -50,6 +50,7 @@ fn integrate_forces( mass: &Mass, fluid: &Fluid, gravity: f32, + scale: Option, ) -> Vel { let dim = body.dimensions(); let height = dim.z; @@ -61,7 +62,13 @@ fn integrate_forces( // Aerodynamic/hydrodynamic forces if !rel_flow.0.is_approx_zero() { debug_assert!(!rel_flow.0.map(|a| a.is_nan()).reduce_or()); - let impulse = dt.0 * body.aerodynamic_forces(&rel_flow, fluid_density.0, wings); + let impulse = dt.0 + * body.aerodynamic_forces( + &rel_flow, + fluid_density.0, + wings, + scale.map_or(1.0, |s| s.0), + ); debug_assert!(!impulse.map(|a| a.is_nan()).reduce_or()); if !impulse.is_approx_zero() { let new_v = vel.0 + impulse / mass.0; @@ -610,6 +617,7 @@ impl<'a> PhysicsData<'a> { &write.physics_states, &read.masses, &read.densities, + read.scales.maybe(), !&read.is_ridings, ) .par_join() @@ -628,6 +636,7 @@ impl<'a> PhysicsData<'a> { physics_state, mass, density, + scale, _, )| { let in_loaded_chunk = read @@ -672,6 +681,7 @@ impl<'a> PhysicsData<'a> { mass, &fluid, GRAVITY, + scale.copied(), ) .0 }, @@ -1092,19 +1102,21 @@ impl<'a> PhysicsData<'a> { // TODO: Cache the matrices here to avoid recomputing - let transform_last_from = Mat4::::translation_3d( - previous_cache_other.pos.unwrap_or(*pos_other).0 - - previous_cache.pos.unwrap_or(Pos(wpos)).0, - ) * Mat4::from( - previous_cache_other.ori, - ) * Mat4::::translation_3d( - voxel_collider.translation, - ); + let transform_last_from = + Mat4::::translation_3d( + previous_cache_other.pos.unwrap_or(*pos_other).0 + - previous_cache.pos.unwrap_or(Pos(wpos)).0, + ) * Mat4::from(previous_cache_other.ori) + * Mat4::::scaling_3d(previous_cache_other.scale) + * Mat4::::translation_3d( + voxel_collider.translation, + ); let transform_last_to = transform_last_from.inverted(); let transform_from = Mat4::::translation_3d(pos_other.0 - wpos) * Mat4::from(ori_other.to_quat()) + * Mat4::::scaling_3d(previous_cache_other.scale) * Mat4::::translation_3d( voxel_collider.translation, ); @@ -1350,12 +1362,9 @@ fn box_voxel_collision + ReadVol>( read: &PhysicsRead, ori: &Ori, ) { - // FIXME: Review these - #![allow( - clippy::cast_precision_loss, - clippy::cast_possible_truncation, - clippy::cast_sign_loss - )] + // We cap out scale at 10.0 to prevent an enormous amount of lag + let scale = read.scales.get(entity).map_or(1.0, |s| s.0.min(10.0)); + //prof_span!("box_voxel_collision"); // Convience function to compute the player aabb @@ -1410,7 +1419,7 @@ fn box_voxel_collision + ReadVol>( #[allow(clippy::trivially_copy_pass_by_ref)] fn always_hits(_: &Block) -> bool { true } - let (radius, z_min, z_max) = cylinder; + let (radius, z_min, z_max) = (Vec3::from(cylinder) * scale).into_tuple(); // Probe distances let hdist = radius.ceil() as i32; @@ -1440,7 +1449,8 @@ fn box_voxel_collision + ReadVol>( // Don't jump too far at once const MAX_INCREMENTS: usize = 100; // The maximum number of collision tests per tick - let increments = ((pos_delta.map(|e| e.abs()).reduce_partial_max() / 0.3).ceil() as usize) + let min_step = (radius / 2.0).min(z_max - z_min).clamped(0.01, 0.3); + let increments = ((pos_delta.map(|e| e.abs()).reduce_partial_max() / min_step).ceil() as usize) .clamped(1, MAX_INCREMENTS); let old_pos = pos.0; for _ in 0..increments { diff --git a/common/systems/tests/character_state.rs b/common/systems/tests/character_state.rs index 425a2ab157..d7a9a70558 100644 --- a/common/systems/tests/character_state.rs +++ b/common/systems/tests/character_state.rs @@ -2,8 +2,8 @@ mod tests { use common::{ comp::{ - item::MaterialStatManifest, skills::GeneralSkill, tool::AbilityMap, CharacterState, - Controller, Energy, Ori, PhysicsState, Poise, Pos, Skill, Stats, Vel, + item::MaterialStatManifest, skills::GeneralSkill, tool::AbilityMap, CharacterActivity, + CharacterState, Controller, Energy, Ori, PhysicsState, Poise, Pos, Skill, Stats, Vel, }, resources::{DeltaTime, GameMode, Time}, shared_server_config::ServerConstants, @@ -53,6 +53,7 @@ mod tests { .ecs_mut() .create_entity() .with(CharacterState::Idle(common::states::idle::Data::default())) + .with(CharacterActivity::default()) .with(Pos(Vec3::zero())) .with(Vel::default()) .with(ori) @@ -84,6 +85,7 @@ mod tests { None, // Dummy ServerConstants &ServerConstants::default(), + |_, _| {}, ); } @@ -115,8 +117,12 @@ mod tests { for i in 0..TESTCASES { if let Some(e) = entities[i] { let result = Dir::from(*results.get(e).expect("Ori missing")); - assert!(result.abs_diff_eq(&testcases[i].1, 0.0005)); - // println!("{:?}", result); + assert!( + result.abs_diff_eq(&testcases[i].1, 0.0005), + "{:?} != {:?}", + result, + testcases[i].1 + ); } } } diff --git a/common/systems/tests/phys/basic.rs b/common/systems/tests/phys/basic.rs index e7eabcbfd0..dba3fee9d0 100644 --- a/common/systems/tests/phys/basic.rs +++ b/common/systems/tests/phys/basic.rs @@ -19,6 +19,7 @@ fn simple_run() { false, None, &ServerConstants::default(), + |_, _| {}, ); } @@ -127,11 +128,11 @@ fn fall_dt_speed_diff() -> Result<(), Box> { assert_relative_eq!(svel.0.z, -4.9847627, epsilon = EPSILON); assert_relative_eq!(fpos.0.x, 16.0); assert_relative_eq!(fpos.0.y, 16.0); - assert_relative_eq!(fpos.0.z, 264.25073, epsilon = EPSILON); + assert_relative_eq!(fpos.0.z, 264.25067, epsilon = EPSILON); assert_relative_eq!(fvel.0.z, -4.9930925, epsilon = EPSILON); // Diff after 200ms - assert_relative_eq!((spos.0.z - fpos.0.z).abs(), 0.2253418, epsilon = EPSILON); + assert_relative_eq!((spos.0.z - fpos.0.z).abs(), 0.22540283, epsilon = EPSILON); assert_relative_eq!((svel.0.z - fvel.0.z).abs(), 0.008329868, epsilon = EPSILON); Ok(()) diff --git a/common/systems/tests/phys/utils.rs b/common/systems/tests/phys/utils.rs index 3d6ac32cd8..bd2b2504c1 100644 --- a/common/systems/tests/phys/utils.rs +++ b/common/systems/tests/phys/utils.rs @@ -3,8 +3,8 @@ use common::{ inventory::item::MaterialStatManifest, skills::{GeneralSkill, Skill}, tool::AbilityMap, - Auras, Buffs, CharacterState, Collider, Combo, Controller, Energy, Health, Ori, Pos, Stats, - Vel, + Auras, Buffs, CharacterActivity, CharacterState, Collider, Combo, Controller, Energy, + Health, Ori, Pos, Stats, Vel, }, resources::{DeltaTime, GameMode, Time}, shared_server_config::ServerConstants, @@ -66,6 +66,7 @@ pub fn tick(state: &mut State, dt: Duration) { false, None, &ServerConstants::default(), + |_, _| {}, ); } @@ -122,6 +123,7 @@ pub fn create_player(state: &mut State) -> Entity { .with(body) .with(Controller::default()) .with(CharacterState::default()) + .with(CharacterActivity::default()) .with(Buffs::default()) .with(Combo::default()) .with(Auras::default()) diff --git a/rtsim/Cargo.toml b/rtsim/Cargo.toml new file mode 100644 index 0000000000..639680d709 --- /dev/null +++ b/rtsim/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "veloren-rtsim" +version = "0.10.0" +edition = "2021" + +[dependencies] +common = { package = "veloren-common", path = "../common" } +world = { package = "veloren-world", path = "../world" } +ron = "0.8" +serde = { version = "1.0.110", features = ["derive"] } +hashbrown = { version = "0.12", features = ["rayon", "serde", "nightly"] } +enum-map = { version = "2.4", features = ["serde"] } +vek = { version = "0.15.8", features = ["serde"] } +rmp-serde = "1.1.0" +anymap2 = "0.13" +tracing = "0.1" +atomic_refcell = "0.1" +slotmap = { version = "1.0.6", features = ["serde"] } +rand = { version = "0.8", features = ["small_rng"] } +rand_chacha = "0.3" +fxhash = "0.2.1" +itertools = "0.10.3" +rayon = "1.5" diff --git a/rtsim/src/ai/mod.rs b/rtsim/src/ai/mod.rs new file mode 100644 index 0000000000..2fe6222151 --- /dev/null +++ b/rtsim/src/ai/mod.rs @@ -0,0 +1,878 @@ +use crate::{ + data::{ + npc::{Controller, Npc, NpcId}, + ReportId, Sentiments, + }, + RtState, +}; +use common::resources::{Time, TimeOfDay}; +use hashbrown::HashSet; +use rand_chacha::ChaChaRng; +use std::{any::Any, collections::VecDeque, marker::PhantomData, ops::ControlFlow}; +use world::{IndexRef, World}; + +/// The context provided to an [`Action`] while it is being performed. It should +/// be possible to access any and all important information about the game world +/// through this struct. +pub struct NpcCtx<'a> { + pub state: &'a RtState, + pub world: &'a World, + pub index: IndexRef<'a>, + + pub time_of_day: TimeOfDay, + pub time: Time, + + pub npc_id: NpcId, + pub npc: &'a Npc, + pub controller: &'a mut Controller, + pub inbox: &'a mut VecDeque, // TODO: Allow more inbox items + pub sentiments: &'a mut Sentiments, + pub known_reports: &'a mut HashSet, + + pub rng: ChaChaRng, +} + +/// A trait that describes 'actions': long-running tasks performed by rtsim +/// NPCs. These can be as simple as walking in a straight line between two +/// locations or as complex as taking part in an adventure with players or +/// performing an entire daily work schedule. +/// +/// Actions are built up from smaller sub-actions via the combinator methods +/// defined on this trait, and with the standalone functions in this module. +/// Using these combinators, in a similar manner to using the [`Iterator`] API, +/// it is possible to construct arbitrarily complex actions including behaviour +/// trees (see [`choose`] and [`watch`]) and other forms of moment-by-moment +/// decision-making. +/// +/// On completion, actions may produce a value, denoted by the type parameter +/// `R`. For example, an action may communicate whether it was successful or +/// unsuccessful through this completion value. +/// +/// You should not need to implement this trait yourself when writing AI code. +/// If you find yourself wanting to implement it, please discuss with the core +/// dev team first. +pub trait Action: Any + Send + Sync { + /// Returns `true` if the action should be considered the 'same' (i.e: + /// achieving the same objective) as another. In general, the AI system + /// will try to avoid switching (and therefore restarting) tasks when the + /// new task is the 'same' as the old one. + // TODO: Figure out a way to compare actions based on their 'intention': i.e: + // two pathing actions should be considered equivalent if their destination + // is the same regardless of the progress they've each made. + fn is_same(&self, other: &Self) -> bool + where + Self: Sized; + + /// Like [`Action::is_same`], but allows for dynamic dispatch. + fn dyn_is_same_sized(&self, other: &dyn Action) -> bool + where + Self: Sized, + { + match (other as &dyn Any).downcast_ref::() { + Some(other) => self.is_same(other), + None => false, + } + } + + /// Like [`Action::is_same`], but allows for dynamic dispatch. + fn dyn_is_same(&self, other: &dyn Action) -> bool; + + /// Generate a backtrace for the action. The action should recursively push + /// all of the tasks it is currently performing. + fn backtrace(&self, bt: &mut Vec); + + /// Reset the action to its initial state such that it can be repeated. + fn reset(&mut self); + + /// Perform the action for the current tick. + fn tick(&mut self, ctx: &mut NpcCtx) -> ControlFlow; + + /// Create an action that chains together two sub-actions, one after the + /// other. + /// + /// # Example + /// + /// ```ignore + /// // Walk toward an enemy NPC and, once done, attack the enemy NPC + /// goto(enemy_npc).then(attack(enemy_npc)) + /// ``` + #[must_use] + fn then, R1>(self, other: A1) -> Then + where + Self: Sized, + { + Then { + a0: self, + a0_finished: false, + a1: other, + phantom: PhantomData, + } + } + + /// Create an action that repeats a sub-action indefinitely. + /// + /// # Example + /// + /// ```ignore + /// // Endlessly collect flax from the environment + /// find_and_collect(ChunkResource::Flax).repeat() + /// ``` + #[must_use] + fn repeat(self) -> Repeat + where + Self: Sized, + { + Repeat(self, PhantomData) + } + + /// Stop the sub-action suddenly if a condition is reached. + /// + /// # Example + /// + /// ```ignore + /// // Keep going on adventures until your 111th birthday + /// go_on_an_adventure().repeat().stop_if(|ctx| ctx.npc.age > 111.0) + /// ``` + #[must_use] + fn stop_if bool + Clone>(self, f: F) -> StopIf + where + Self: Sized, + { + StopIf(self, f.clone(), f) + } + + /// Pause an action to possibly perform another action. + /// + /// # Example + /// + /// ```ignore + /// // Keep going on adventures until your 111th birthday + /// walk_to_the_shops() + /// .interrupt_with(|ctx| if ctx.npc.is_hungry() { + /// Some(eat_food()) + /// } else { + /// None + /// }) + /// ``` + #[must_use] + fn interrupt_with, R1, F: FnMut(&mut NpcCtx) -> Option + Clone>( + self, + f: F, + ) -> InterruptWith + where + Self: Sized, + { + InterruptWith { + a0: self, + f: f.clone(), + f2: f, + a1: None, + phantom: PhantomData, + } + } + + /// Map the completion value of this action to something else. + #[must_use] + fn map R1, R1>(self, f: F) -> Map + where + Self: Sized, + { + Map(self, f, PhantomData) + } + + /// Box the action. Often used to perform type erasure, such as when you + /// want to return one of many actions (each with different types) from + /// the same function. + /// + /// Note that [`Either`] can often be used to unify mismatched types without + /// the need for boxing. + /// + /// # Example + /// + /// ```ignore + /// // Error! Type mismatch between branches + /// if npc.is_too_tired() { + /// goto(npc.home) + /// } else { + /// go_on_an_adventure() + /// } + /// + /// // All fine + /// if npc.is_too_tired() { + /// goto(npc.home).boxed() + /// } else { + /// go_on_an_adventure().boxed() + /// } + /// ``` + #[must_use] + fn boxed(self) -> Box> + where + Self: Sized, + { + Box::new(self) + } + + /// Add debugging information to the action that will be visible when using + /// the `/npc_info` command. + /// + /// # Example + /// + /// ```ignore + /// goto(npc.home).debug(|| "Going home") + /// ``` + #[must_use] + fn debug(self, mk_info: F) -> Debug + where + Self: Sized, + { + Debug(self, mk_info, PhantomData) + } +} + +impl Action for Box> { + fn is_same(&self, other: &Self) -> bool { (**self).dyn_is_same(other) } + + fn dyn_is_same(&self, other: &dyn Action) -> bool { + match (other as &dyn Any).downcast_ref::() { + Some(other) => self.is_same(other), + None => false, + } + } + + fn backtrace(&self, bt: &mut Vec) { (**self).backtrace(bt) } + + fn reset(&mut self) { (**self).reset(); } + + fn tick(&mut self, ctx: &mut NpcCtx) -> ControlFlow { (**self).tick(ctx) } +} + +impl, B: Action> Action for itertools::Either { + fn is_same(&self, other: &Self) -> bool { + match (self, other) { + (itertools::Either::Left(x), itertools::Either::Left(y)) => x.is_same(y), + (itertools::Either::Right(x), itertools::Either::Right(y)) => x.is_same(y), + _ => false, + } + } + + fn dyn_is_same(&self, other: &dyn Action) -> bool { self.dyn_is_same_sized(other) } + + fn backtrace(&self, bt: &mut Vec) { + match self { + itertools::Either::Left(x) => x.backtrace(bt), + itertools::Either::Right(x) => x.backtrace(bt), + } + } + + fn reset(&mut self) { + match self { + itertools::Either::Left(x) => x.reset(), + itertools::Either::Right(x) => x.reset(), + } + } + + fn tick(&mut self, ctx: &mut NpcCtx) -> ControlFlow { + match self { + itertools::Either::Left(x) => x.tick(ctx), + itertools::Either::Right(x) => x.tick(ctx), + } + } +} + +// Now + +/// See [`now`]. +#[derive(Copy, Clone)] +pub struct Now(F, Option); + +impl A + Send + Sync + 'static, A: Action> + Action for Now +{ + // TODO: This doesn't compare?! + fn is_same(&self, _other: &Self) -> bool { true } + + fn dyn_is_same(&self, other: &dyn Action) -> bool { self.dyn_is_same_sized(other) } + + fn backtrace(&self, bt: &mut Vec) { + if let Some(action) = &self.1 { + action.backtrace(bt); + } else { + bt.push("".to_string()); + } + } + + // TODO: Reset closure? + fn reset(&mut self) { self.1 = None; } + + // TODO: Reset closure state? + fn tick(&mut self, ctx: &mut NpcCtx) -> ControlFlow { + (self.1.get_or_insert_with(|| (self.0)(ctx))).tick(ctx) + } +} + +/// Start a new action based on the state of the world (`ctx`) at the moment the +/// action is started. +/// +/// If you're in a situation where you suddenly find yourself needing `ctx`, you +/// probably want to use this. +/// +/// # Example +/// +/// ```ignore +/// // An action that makes an NPC immediately travel to its *current* home +/// now(|ctx| goto(ctx.npc.home)) +/// ``` +pub fn now(f: F) -> Now +where + F: FnMut(&mut NpcCtx) -> A, +{ + Now(f, None) +} + +// Until + +/// See [`now`]. +#[derive(Copy, Clone)] +pub struct Until(F, Option, PhantomData); + +impl< + R: Send + Sync + 'static, + F: FnMut(&mut NpcCtx) -> Option + Send + Sync + 'static, + A: Action, +> Action<()> for Until +{ + // TODO: This doesn't compare?! + fn is_same(&self, _other: &Self) -> bool { true } + + fn dyn_is_same(&self, other: &dyn Action<()>) -> bool { self.dyn_is_same_sized(other) } + + fn backtrace(&self, bt: &mut Vec) { + if let Some(action) = &self.1 { + action.backtrace(bt); + } else { + bt.push("".to_string()); + } + } + + // TODO: Reset closure? + fn reset(&mut self) { self.1 = None; } + + // TODO: Reset closure state? + fn tick(&mut self, ctx: &mut NpcCtx) -> ControlFlow<()> { + match &mut self.1 { + Some(x) => match x.tick(ctx) { + ControlFlow::Continue(()) => ControlFlow::Continue(()), + ControlFlow::Break(_) => { + self.1 = None; + ControlFlow::Continue(()) + }, + }, + None => match (self.0)(ctx) { + Some(x) => { + self.1 = Some(x); + ControlFlow::Continue(()) + }, + None => ControlFlow::Break(()), + }, + } + } +} + +pub fn until(f: F) -> Until +where + F: FnMut(&mut NpcCtx) -> Option, +{ + Until(f, None, PhantomData) +} + +// Just + +/// See [`just`]. +#[derive(Copy, Clone)] +pub struct Just(F, PhantomData); + +impl R + Send + Sync + 'static> Action + for Just +{ + fn is_same(&self, _other: &Self) -> bool { true } + + fn dyn_is_same(&self, other: &dyn Action) -> bool { self.dyn_is_same_sized(other) } + + fn backtrace(&self, _bt: &mut Vec) {} + + // TODO: Reset closure? + fn reset(&mut self) {} + + // TODO: Reset closure state? + fn tick(&mut self, ctx: &mut NpcCtx) -> ControlFlow { ControlFlow::Break((self.0)(ctx)) } +} + +/// An action that executes some code just once when performed. +/// +/// If you want to execute this code on every tick, consider combining it with +/// [`Action::repeat`]. +/// +/// # Example +/// +/// ```ignore +/// // Make the current NPC say 'Hello, world!' exactly once +/// just(|ctx| ctx.controller.say("Hello, world!")) +/// ``` +pub fn just(f: F) -> Just +where + F: FnMut(&mut NpcCtx) -> R + Send + Sync + 'static, +{ + Just(f, PhantomData) +} + +// Finish + +/// See [`finish`]. +#[derive(Copy, Clone)] +pub struct Finish; + +impl Action<()> for Finish { + fn is_same(&self, _other: &Self) -> bool { true } + + fn dyn_is_same(&self, other: &dyn Action<()>) -> bool { self.dyn_is_same_sized(other) } + + fn backtrace(&self, _bt: &mut Vec) {} + + fn reset(&mut self) {} + + fn tick(&mut self, _ctx: &mut NpcCtx) -> ControlFlow<()> { ControlFlow::Break(()) } +} + +/// An action that immediately finishes without doing anything. +/// +/// This action is useless by itself, but becomes useful when combined with +/// actions that make decisions. +/// +/// # Example +/// +/// ```ignore +/// now(|ctx| { +/// if ctx.npc.is_tired() { +/// sleep().boxed() // If we're tired, sleep +/// } else if ctx.npc.is_hungry() { +/// eat().boxed() // If we're hungry, eat +/// } else { +/// finish().boxed() // Otherwise, do nothing +/// } +/// }) +/// ``` +#[must_use] +pub fn finish() -> Finish { Finish } + +// Tree + +pub type Priority = usize; + +pub const URGENT: Priority = 0; +pub const IMPORTANT: Priority = 1; +pub const CASUAL: Priority = 2; + +pub struct Node(Box>, Priority); + +/// Perform an action with [`URGENT`] priority (see [`choose`]). +#[must_use] +pub fn urgent, R>(a: A) -> Node { Node(Box::new(a), URGENT) } + +/// Perform an action with [`IMPORTANT`] priority (see [`choose`]). +#[must_use] +pub fn important, R>(a: A) -> Node { Node(Box::new(a), IMPORTANT) } + +/// Perform an action with [`CASUAL`] priority (see [`choose`]). +#[must_use] +pub fn casual, R>(a: A) -> Node { Node(Box::new(a), CASUAL) } + +/// See [`choose`] and [`watch`]. +pub struct Tree { + next: F, + prev: Option>, + interrupt: bool, +} + +impl Node + Send + Sync + 'static, R: 'static> Action + for Tree +{ + fn is_same(&self, _other: &Self) -> bool { true } + + fn dyn_is_same(&self, other: &dyn Action) -> bool { self.dyn_is_same_sized(other) } + + fn backtrace(&self, bt: &mut Vec) { + if let Some(prev) = &self.prev { + prev.0.backtrace(bt); + } else { + bt.push("".to_string()); + } + } + + fn reset(&mut self) { self.prev = None; } + + // TODO: Reset `next` too? + fn tick(&mut self, ctx: &mut NpcCtx) -> ControlFlow { + let new = (self.next)(ctx); + + let prev = match &mut self.prev { + Some(prev) if prev.1 <= new.1 && (prev.0.dyn_is_same(&*new.0) || !self.interrupt) => { + prev + }, + _ => self.prev.insert(new), + }; + + match prev.0.tick(ctx) { + ControlFlow::Continue(()) => ControlFlow::Continue(()), + ControlFlow::Break(r) => { + self.prev = None; + ControlFlow::Break(r) + }, + } + } +} + +/// An action that allows implementing a decision tree, with action +/// prioritisation. +/// +/// The inner function will be run every tick to decide on an action. When an +/// action is chosen, it will be performed until completed *UNLESS* an action +/// with a more urgent priority is chosen in a subsequent tick. [`choose`] tries +/// to commit to actions when it can: only more urgent actions will interrupt an +/// action that's currently being performed. If you want something that's more +/// eager to switch actions, see [`watch`]. +/// +/// # Example +/// +/// ```ignore +/// choose(|ctx| { +/// if ctx.npc.is_being_attacked() { +/// urgent(combat()) // If we're in danger, do something! +/// } else if ctx.npc.is_hungry() { +/// important(eat()) // If we're hungry, eat +/// } else { +/// casual(idle()) // Otherwise, do nothing +/// } +/// }) +/// ``` +#[must_use] +pub fn choose(f: F) -> impl Action +where + F: FnMut(&mut NpcCtx) -> Node + Send + Sync + 'static, +{ + Tree { + next: f, + prev: None, + interrupt: false, + } +} + +/// An action that allows implementing a decision tree, with action +/// prioritisation. +/// +/// The inner function will be run every tick to decide on an action. When an +/// action is chosen, it will be performed until completed unless a different +/// action of the same or higher priority is chosen in a subsequent tick. +/// [`watch`] is very unfocused and will happily switch between actions +/// rapidly between ticks if conditions change. If you want something that +/// tends to commit to actions until they are completed, see [`choose`]. +/// +/// # Example +/// +/// ```ignore +/// watch(|ctx| { +/// if ctx.npc.is_being_attacked() { +/// urgent(combat()) // If we're in danger, do something! +/// } else if ctx.npc.is_hungry() { +/// important(eat()) // If we're hungry, eat +/// } else { +/// casual(idle()) // Otherwise, do nothing +/// } +/// }) +/// ``` +#[must_use] +pub fn watch(f: F) -> impl Action +where + F: FnMut(&mut NpcCtx) -> Node + Send + Sync + 'static, +{ + Tree { + next: f, + prev: None, + interrupt: true, + } +} + +// Then + +/// See [`Action::then`]. +#[derive(Copy, Clone)] +pub struct Then { + a0: A0, + a0_finished: bool, + a1: A1, + phantom: PhantomData, +} + +impl, A1: Action, R0: Send + Sync + 'static, R1: Send + Sync + 'static> + Action for Then +{ + fn is_same(&self, other: &Self) -> bool { + self.a0.is_same(&other.a0) && self.a1.is_same(&other.a1) + } + + fn dyn_is_same(&self, other: &dyn Action) -> bool { self.dyn_is_same_sized(other) } + + fn backtrace(&self, bt: &mut Vec) { + if self.a0_finished { + self.a1.backtrace(bt); + } else { + self.a0.backtrace(bt); + } + } + + fn reset(&mut self) { + self.a0.reset(); + self.a0_finished = false; + self.a1.reset(); + } + + fn tick(&mut self, ctx: &mut NpcCtx) -> ControlFlow { + if !self.a0_finished { + match self.a0.tick(ctx) { + ControlFlow::Continue(()) => return ControlFlow::Continue(()), + ControlFlow::Break(_) => self.a0_finished = true, + } + } + self.a1.tick(ctx) + } +} + +// InterruptWith + +/// See [`Action::then`]. +#[derive(Copy, Clone)] +pub struct InterruptWith { + a0: A0, + f: F, + f2: F, + a1: Option, + phantom: PhantomData, +} + +impl< + A0: Action, + A1: Action, + F: FnMut(&mut NpcCtx) -> Option + Clone + Send + Sync + 'static, + R0: Send + Sync + 'static, + R1: Send + Sync + 'static, +> Action for InterruptWith +{ + fn is_same(&self, other: &Self) -> bool { self.a0.is_same(&other.a0) } + + fn dyn_is_same(&self, other: &dyn Action) -> bool { self.dyn_is_same_sized(other) } + + fn backtrace(&self, bt: &mut Vec) { + if let Some(a1) = &self.a1 { + // TODO: Find a way to represent interrupts in backtraces + bt.push("".to_string()); + a1.backtrace(bt); + } else { + self.a0.backtrace(bt); + } + } + + fn reset(&mut self) { + self.a0.reset(); + self.f = self.f2.clone(); + self.a1 = None; + } + + fn tick(&mut self, ctx: &mut NpcCtx) -> ControlFlow { + if let Some(new_a1) = (self.f)(ctx) { + self.a1 = Some(new_a1); + } + + if let Some(a1) = &mut self.a1 { + match a1.tick(ctx) { + ControlFlow::Continue(()) => return ControlFlow::Continue(()), + ControlFlow::Break(_) => self.a1 = None, + } + } + + self.a0.tick(ctx) + } +} + +// Repeat + +/// See [`Action::repeat`]. +#[derive(Copy, Clone)] +pub struct Repeat(A, PhantomData); + +impl> Action for Repeat { + fn is_same(&self, other: &Self) -> bool { self.0.is_same(&other.0) } + + fn dyn_is_same(&self, other: &dyn Action) -> bool { self.dyn_is_same_sized(other) } + + fn backtrace(&self, bt: &mut Vec) { self.0.backtrace(bt); } + + fn reset(&mut self) { self.0.reset(); } + + fn tick(&mut self, ctx: &mut NpcCtx) -> ControlFlow { + match self.0.tick(ctx) { + ControlFlow::Continue(()) => ControlFlow::Continue(()), + ControlFlow::Break(_) => { + self.0.reset(); + ControlFlow::Continue(()) + }, + } + } +} + +// Sequence + +/// See [`seq`]. +#[derive(Copy, Clone)] +pub struct Sequence(I, I, Option, PhantomData); + +impl + Clone + Send + Sync + 'static, A: Action> + Action<()> for Sequence +{ + fn is_same(&self, _other: &Self) -> bool { true } + + fn dyn_is_same(&self, other: &dyn Action<()>) -> bool { self.dyn_is_same_sized(other) } + + fn backtrace(&self, bt: &mut Vec) { + if let Some(action) = &self.2 { + action.backtrace(bt); + } else { + bt.push("".to_string()); + } + } + + fn reset(&mut self) { + self.0 = self.1.clone(); + self.2 = None; + } + + fn tick(&mut self, ctx: &mut NpcCtx) -> ControlFlow<()> { + let item = if let Some(prev) = &mut self.2 { + prev + } else { + match self.0.next() { + Some(next) => self.2.insert(next), + None => return ControlFlow::Break(()), + } + }; + + if let ControlFlow::Break(_) = item.tick(ctx) { + self.2 = None; + } + + ControlFlow::Continue(()) + } +} + +/// An action that consumes and performs an iterator of actions in sequence, one +/// after another. +/// +/// # Example +/// +/// ```ignore +/// // A list of enemies we should attack in turn +/// let enemies = vec![ +/// ugly_goblin, +/// stinky_troll, +/// rude_dwarf, +/// ]; +/// +/// // Attack each enemy, one after another +/// seq(enemies +/// .into_iter() +/// .map(|enemy| attack(enemy))) +/// ``` +#[must_use] +pub fn seq(iter: I) -> Sequence +where + I: Iterator + Clone, + A: Action, +{ + Sequence(iter.clone(), iter, None, PhantomData) +} + +// StopIf + +/// See [`Action::stop_if`]. +#[derive(Copy, Clone)] +pub struct StopIf(A, F, F); + +impl, F: FnMut(&mut NpcCtx) -> bool + Clone + Send + Sync + 'static, R> + Action> for StopIf +{ + fn is_same(&self, other: &Self) -> bool { self.0.is_same(&other.0) } + + fn dyn_is_same(&self, other: &dyn Action>) -> bool { self.dyn_is_same_sized(other) } + + fn backtrace(&self, bt: &mut Vec) { self.0.backtrace(bt); } + + fn reset(&mut self) { + self.0.reset(); + self.1 = self.2.clone(); + } + + fn tick(&mut self, ctx: &mut NpcCtx) -> ControlFlow> { + if (self.1)(ctx) { + ControlFlow::Break(None) + } else { + self.0.tick(ctx).map_break(Some) + } + } +} + +// Map + +/// See [`Action::map`]. +#[derive(Copy, Clone)] +pub struct Map(A, F, PhantomData); + +impl, F: FnMut(R) -> R1 + Send + Sync + 'static, R: Send + Sync + 'static, R1> + Action for Map +{ + fn is_same(&self, other: &Self) -> bool { self.0.is_same(&other.0) } + + fn dyn_is_same(&self, other: &dyn Action) -> bool { self.dyn_is_same_sized(other) } + + fn backtrace(&self, bt: &mut Vec) { self.0.backtrace(bt); } + + fn reset(&mut self) { self.0.reset(); } + + fn tick(&mut self, ctx: &mut NpcCtx) -> ControlFlow { + self.0.tick(ctx).map_break(&mut self.1) + } +} + +// Debug + +/// See [`Action::debug`]. +#[derive(Copy, Clone)] +pub struct Debug(A, F, PhantomData); + +impl< + A: Action, + F: Fn() -> T + Send + Sync + 'static, + R: Send + Sync + 'static, + T: Send + Sync + std::fmt::Display + 'static, +> Action for Debug +{ + fn is_same(&self, other: &Self) -> bool { self.0.is_same(&other.0) } + + fn dyn_is_same(&self, other: &dyn Action) -> bool { self.dyn_is_same_sized(other) } + + fn backtrace(&self, bt: &mut Vec) { + bt.push((self.1)().to_string()); + self.0.backtrace(bt); + } + + fn reset(&mut self) { self.0.reset(); } + + fn tick(&mut self, ctx: &mut NpcCtx) -> ControlFlow { self.0.tick(ctx) } +} diff --git a/rtsim/src/data/faction.rs b/rtsim/src/data/faction.rs new file mode 100644 index 0000000000..c627823de2 --- /dev/null +++ b/rtsim/src/data/faction.rs @@ -0,0 +1,43 @@ +use crate::data::Sentiments; +use common::rtsim::Actor; +pub use common::rtsim::FactionId; +use serde::{Deserialize, Serialize}; +use slotmap::HopSlotMap; +use std::ops::{Deref, DerefMut}; +use vek::*; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Faction { + pub seed: u32, + pub leader: Option, + pub good_or_evil: bool, // TODO: Very stupid, get rid of this + + #[serde(default)] + pub sentiments: Sentiments, +} + +impl Faction { + pub fn cleanup(&mut self) { + self.sentiments + .cleanup(crate::data::sentiment::FACTION_MAX_SENTIMENTS); + } +} + +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct Factions { + pub factions: HopSlotMap, +} + +impl Factions { + pub fn create(&mut self, faction: Faction) -> FactionId { self.factions.insert(faction) } +} + +impl Deref for Factions { + type Target = HopSlotMap; + + fn deref(&self) -> &Self::Target { &self.factions } +} + +impl DerefMut for Factions { + fn deref_mut(&mut self) -> &mut Self::Target { &mut self.factions } +} diff --git a/rtsim/src/data/mod.rs b/rtsim/src/data/mod.rs new file mode 100644 index 0000000000..eb4e0748a9 --- /dev/null +++ b/rtsim/src/data/mod.rs @@ -0,0 +1,152 @@ +pub mod faction; +pub mod nature; +pub mod npc; +pub mod report; +pub mod sentiment; +pub mod site; + +pub use self::{ + faction::{Faction, FactionId, Factions}, + nature::Nature, + npc::{Npc, NpcId, Npcs}, + report::{Report, ReportId, ReportKind, Reports}, + sentiment::{Sentiment, Sentiments}, + site::{Site, SiteId, Sites}, +}; + +use common::resources::TimeOfDay; +use enum_map::{enum_map, EnumArray, EnumMap}; +use serde::{de, ser, Deserialize, Serialize}; +use std::{ + cmp::PartialEq, + fmt, + io::{Read, Write}, + marker::PhantomData, +}; + +/// The current version of rtsim data. +/// +/// Note that this number does *not* need incrementing on every change: most +/// field removals/additions are fine. This number should only be incremented +/// when we wish to perform a *hard purge* of rtsim data. +pub const CURRENT_VERSION: u32 = 0; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Data { + // Absence of field just implied version = 0 + #[serde(default)] + pub version: u32, + + pub nature: Nature, + #[serde(default)] + pub npcs: Npcs, + #[serde(default)] + pub sites: Sites, + #[serde(default)] + pub factions: Factions, + #[serde(default)] + pub reports: Reports, + + #[serde(default)] + pub tick: u64, + #[serde(default)] + pub time_of_day: TimeOfDay, + + // If true, rtsim data will be ignored (and, hence, overwritten on next save) on load. + #[serde(default)] + pub should_purge: bool, +} + +pub enum ReadError { + Load(rmp_serde::decode::Error), + // Preserve old data + VersionMismatch(Box), +} + +impl fmt::Debug for ReadError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Load(err) => err.fmt(f), + Self::VersionMismatch(_) => write!(f, "VersionMismatch"), + } + } +} + +pub type WriteError = rmp_serde::encode::Error; + +impl Data { + pub fn spawn_npc(&mut self, npc: Npc) -> NpcId { + let home = npc.home; + let id = self.npcs.create_npc(npc); + if let Some(home) = home.and_then(|home| self.sites.get_mut(home)) { + home.population.insert(id); + } + id + } + + pub fn from_reader(reader: R) -> Result, ReadError> { + rmp_serde::decode::from_read(reader) + .map_err(ReadError::Load) + .and_then(|data: Data| { + if data.version == CURRENT_VERSION { + Ok(Box::new(data)) + } else { + Err(ReadError::VersionMismatch(Box::new(data))) + } + }) + } + + pub fn write_to(&self, mut writer: W) -> Result<(), WriteError> { + rmp_serde::encode::write_named(&mut writer, self) + } +} + +fn rugged_ser_enum_map< + K: EnumArray + Serialize, + V: From + PartialEq + Serialize, + S: ser::Serializer, + const DEFAULT: i16, +>( + map: &EnumMap, + ser: S, +) -> Result { + ser.collect_map( + map.iter() + .filter(|(_, v)| v != &&V::from(DEFAULT)) + .map(|(k, v)| (k, v)), + ) +} + +fn rugged_de_enum_map< + 'a, + K: EnumArray + EnumArray> + Deserialize<'a>, + V: From + Deserialize<'a>, + D: de::Deserializer<'a>, + const DEFAULT: i16, +>( + de: D, +) -> Result, D::Error> { + struct Visitor(PhantomData<(K, V)>); + + impl<'de, K, V, const DEFAULT: i16> de::Visitor<'de> for Visitor + where + K: EnumArray + EnumArray> + Deserialize<'de>, + V: From + Deserialize<'de>, + { + type Value = EnumMap; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + write!(formatter, "a map") + } + + fn visit_map>(self, mut access: M) -> Result { + let mut entries = EnumMap::default(); + while let Some((key, value)) = access.next_entry()? { + entries[key] = Some(value); + } + Ok(enum_map! { key => entries[key].take().unwrap_or_else(|| V::from(DEFAULT)) }) + } + } + + de.deserialize_map(Visitor::<_, _, DEFAULT>(PhantomData)) +} diff --git a/rtsim/src/data/nature.rs b/rtsim/src/data/nature.rs new file mode 100644 index 0000000000..cece7ec555 --- /dev/null +++ b/rtsim/src/data/nature.rs @@ -0,0 +1,64 @@ +use common::{grid::Grid, rtsim::ChunkResource}; +use enum_map::EnumMap; +use serde::{Deserialize, Serialize}; +use vek::*; +use world::World; + +/// Represents the state of 'natural' elements of the world such as +/// plant/animal/resource populations, weather systems, etc. +/// +/// Where possible, this data does not define the state of natural aspects of +/// the world, but instead defines 'modifications' that sit on top of the world +/// data generated by initial generation. +#[derive(Clone, Serialize, Deserialize)] +pub struct Nature { + chunks: Grid, +} + +impl Nature { + pub fn generate(world: &World) -> Self { + Self { + chunks: Grid::populate_from(world.sim().get_size().map(|e| e as i32), |_| Chunk { + res: EnumMap::<_, f32>::default().map(|_, _| 1.0), + }), + } + } + + // TODO: Clean up this API a bit + pub fn get_chunk_resources(&self, key: Vec2) -> EnumMap { + self.chunks.get(key).map(|c| c.res).unwrap_or_default() + } + + pub fn set_chunk_resources(&mut self, key: Vec2, res: EnumMap) { + if let Some(chunk) = self.chunks.get_mut(key) { + chunk.res = res; + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Chunk { + /// Represent the 'naturally occurring' resource proportion that exists in + /// this chunk. + /// + /// 0.0 => None of the resources generated by terrain generation should be + /// present + /// + /// 1.0 => All of the resources generated by terrain generation should be + /// present + /// + /// It's important to understand this this number does not represent the + /// total amount of a resource present in a chunk, nor is it even + /// proportional to the amount of the resource present. To get the total + /// amount of the resource in a chunk, one must first multiply this + /// factor by the amount of 'natural' resources given by terrain + /// generation. This value represents only the variable 'depletion' factor + /// of that resource, which shall change over time as the world evolves + /// and players interact with it. + // TODO: Consider whether we can use `i16` or similar here instead: `f32` has more resolution + // than we might need. + #[serde(rename = "r")] + #[serde(serialize_with = "crate::data::rugged_ser_enum_map::<_, _, _, 1>")] + #[serde(deserialize_with = "crate::data::rugged_de_enum_map::<_, _, _, 1>")] + res: EnumMap, +} diff --git a/rtsim/src/data/npc.rs b/rtsim/src/data/npc.rs new file mode 100644 index 0000000000..c04b83202b --- /dev/null +++ b/rtsim/src/data/npc.rs @@ -0,0 +1,396 @@ +use crate::{ + ai::Action, + data::{ReportId, Reports, Sentiments}, + gen::name, +}; +pub use common::rtsim::{NpcId, Profession}; +use common::{ + character::CharacterId, + comp, + grid::Grid, + rtsim::{ + Actor, ChunkResource, FactionId, NpcAction, NpcActivity, Personality, SiteId, VehicleId, + }, + store::Id, + terrain::CoordinateConversions, +}; +use hashbrown::{HashMap, HashSet}; +use rand::prelude::*; +use serde::{Deserialize, Serialize}; +use slotmap::HopSlotMap; +use std::{ + collections::VecDeque, + ops::{Deref, DerefMut}, +}; +use vek::*; +use world::{ + civ::Track, + site::Site as WorldSite, + util::{RandomPerm, LOCALITY}, +}; + +#[derive(Copy, Clone, Debug, Default)] +pub enum SimulationMode { + /// The NPC is unloaded and is being simulated via rtsim. + #[default] + Simulated, + /// The NPC has been loaded into the game world as an ECS entity. + Loaded, +} + +#[derive(Clone)] +pub struct PathData { + pub end: N, + pub path: VecDeque

, + pub repoll: bool, +} + +#[derive(Clone, Default)] +pub struct PathingMemory { + pub intrasite_path: Option<(PathData, Vec2>, Id)>, + pub intersite_path: Option<(PathData<(Id, bool), SiteId>, usize)>, +} + +#[derive(Default)] +pub struct Controller { + pub actions: Vec, + pub activity: Option, +} + +impl Controller { + pub fn do_idle(&mut self) { self.activity = None; } + + pub fn do_goto(&mut self, wpos: Vec3, speed_factor: f32) { + self.activity = Some(NpcActivity::Goto(wpos, speed_factor)); + } + + pub fn do_gather(&mut self, resources: &'static [ChunkResource]) { + self.activity = Some(NpcActivity::Gather(resources)); + } + + pub fn do_hunt_animals(&mut self) { self.activity = Some(NpcActivity::HuntAnimals); } + + pub fn do_dance(&mut self) { self.activity = Some(NpcActivity::Dance); } + + pub fn say(&mut self, target: impl Into>, content: comp::Content) { + self.actions.push(NpcAction::Say(target.into(), content)); + } + + pub fn attack(&mut self, target: impl Into) { + self.actions.push(NpcAction::Attack(target.into())); + } +} + +pub struct Brain { + pub action: Box>, +} + +#[derive(Serialize, Deserialize)] +pub struct Npc { + // Persisted state + pub seed: u32, + /// Represents the location of the NPC. + pub wpos: Vec3, + + pub body: comp::Body, + pub profession: Option, + pub home: Option, + pub faction: Option, + pub riding: Option, + + pub is_dead: bool, + + /// The [`Report`]s that the NPC is aware of. + pub known_reports: HashSet, + + #[serde(default)] + pub personality: Personality, + #[serde(default)] + pub sentiments: Sentiments, + + // Unpersisted state + #[serde(skip)] + pub chunk_pos: Option>, + #[serde(skip)] + pub current_site: Option, + + #[serde(skip)] + pub controller: Controller, + #[serde(skip)] + pub inbox: VecDeque, + + /// Whether the NPC is in simulated or loaded mode (when rtsim is run on the + /// server, loaded corresponds to being within a loaded chunk). When in + /// loaded mode, the interactions of the NPC should not be simulated but + /// should instead be derived from the game. + #[serde(skip)] + pub mode: SimulationMode, + + #[serde(skip)] + pub brain: Option, +} + +impl Clone for Npc { + fn clone(&self) -> Self { + Self { + seed: self.seed, + wpos: self.wpos, + profession: self.profession.clone(), + home: self.home, + faction: self.faction, + riding: self.riding.clone(), + is_dead: self.is_dead, + known_reports: self.known_reports.clone(), + body: self.body, + personality: self.personality, + sentiments: self.sentiments.clone(), + // Not persisted + chunk_pos: None, + current_site: Default::default(), + controller: Default::default(), + inbox: Default::default(), + mode: Default::default(), + brain: Default::default(), + } + } +} + +impl Npc { + pub const PERM_ENTITY_CONFIG: u32 = 1; + const PERM_NAME: u32 = 0; + + pub fn new(seed: u32, wpos: Vec3, body: comp::Body) -> Self { + Self { + seed, + wpos, + body, + personality: Default::default(), + sentiments: Default::default(), + profession: None, + home: None, + faction: None, + riding: None, + is_dead: false, + known_reports: Default::default(), + chunk_pos: None, + current_site: None, + controller: Default::default(), + inbox: Default::default(), + mode: SimulationMode::Simulated, + brain: None, + } + } + + // TODO: have a dedicated `NpcBuilder` type for this. + pub fn with_personality(mut self, personality: Personality) -> Self { + self.personality = personality; + self + } + + // TODO: have a dedicated `NpcBuilder` type for this. + pub fn with_profession(mut self, profession: impl Into>) -> Self { + self.profession = profession.into(); + self + } + + // TODO: have a dedicated `NpcBuilder` type for this. + pub fn with_home(mut self, home: impl Into>) -> Self { + self.home = home.into(); + self + } + + // TODO: have a dedicated `NpcBuilder` type for this. + pub fn steering(mut self, vehicle: impl Into>) -> Self { + self.riding = vehicle.into().map(|vehicle| Riding { + vehicle, + steering: true, + }); + self + } + + // TODO: have a dedicated `NpcBuilder` type for this. + pub fn riding(mut self, vehicle: impl Into>) -> Self { + self.riding = vehicle.into().map(|vehicle| Riding { + vehicle, + steering: false, + }); + self + } + + // TODO: have a dedicated `NpcBuilder` type for this. + pub fn with_faction(mut self, faction: impl Into>) -> Self { + self.faction = faction.into(); + self + } + + pub fn rng(&self, perm: u32) -> impl Rng { RandomPerm::new(self.seed.wrapping_add(perm)) } + + // TODO: Don't make this depend on deterministic RNG, actually persist names + // once we've decided that we want to + pub fn get_name(&self) -> String { name::generate(&mut self.rng(Self::PERM_NAME)) } + + pub fn cleanup(&mut self, reports: &Reports) { + // Clear old or superfluous sentiments + // TODO: It might be worth giving more important NPCs a higher sentiment + // 'budget' than less important ones. + self.sentiments + .cleanup(crate::data::sentiment::NPC_MAX_SENTIMENTS); + // Clear reports that have been forgotten + self.known_reports + .retain(|report| reports.contains_key(*report)); + // TODO: Limit number of reports + // TODO: Clear old inbox items + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Riding { + pub vehicle: VehicleId, + pub steering: bool, +} + +#[derive(Clone, Serialize, Deserialize)] +pub enum VehicleKind { + Airship, + Boat, +} + +// TODO: Merge into `Npc`? +#[derive(Clone, Serialize, Deserialize)] +pub struct Vehicle { + pub wpos: Vec3, + + pub body: comp::ship::Body, + + #[serde(skip)] + pub chunk_pos: Option>, + + #[serde(skip)] + pub driver: Option, + + /// Whether the Vehicle is in simulated or loaded mode (when rtsim is run on + /// the server, loaded corresponds to being within a loaded chunk). When + /// in loaded mode, the interactions of the Vehicle should not be + /// simulated but should instead be derived from the game. + #[serde(skip)] + pub mode: SimulationMode, +} + +impl Vehicle { + pub fn new(wpos: Vec3, body: comp::ship::Body) -> Self { + Self { + wpos, + body, + chunk_pos: None, + driver: None, + mode: SimulationMode::Simulated, + } + } + + pub fn get_body(&self) -> comp::Body { comp::Body::Ship(self.body) } + + /// Max speed in block/s + pub fn get_speed(&self) -> f32 { + match self.body { + comp::ship::Body::DefaultAirship => 15.0, + comp::ship::Body::AirBalloon => 16.0, + comp::ship::Body::SailBoat => 12.0, + comp::ship::Body::Galleon => 13.0, + _ => 10.0, + } + } +} + +#[derive(Default, Clone, Serialize, Deserialize)] +pub struct GridCell { + pub npcs: Vec, + pub vehicles: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Npcs { + pub npcs: HopSlotMap, + pub vehicles: HopSlotMap, + // TODO: This feels like it should be its own rtsim resource + // TODO: Consider switching to `common::util::SpatialGrid` instead + #[serde(skip, default = "construct_npc_grid")] + pub npc_grid: Grid, + #[serde(skip)] + pub character_map: HashMap, Vec<(CharacterId, Vec3)>>, +} + +impl Default for Npcs { + fn default() -> Self { + Self { + npcs: Default::default(), + vehicles: Default::default(), + npc_grid: construct_npc_grid(), + character_map: Default::default(), + } + } +} + +fn construct_npc_grid() -> Grid { Grid::new(Vec2::zero(), Default::default()) } + +impl Npcs { + pub fn create_npc(&mut self, npc: Npc) -> NpcId { self.npcs.insert(npc) } + + pub fn create_vehicle(&mut self, vehicle: Vehicle) -> VehicleId { + self.vehicles.insert(vehicle) + } + + /// Queries nearby npcs, not garantueed to work if radius > 32.0 + // TODO: Find a more efficient way to implement this, it's currently + // (theoretically) O(n^2). + pub fn nearby( + &self, + this_npc: Option, + wpos: Vec3, + radius: f32, + ) -> impl Iterator + '_ { + let chunk_pos = wpos.xy().as_().wpos_to_cpos(); + let r_sqr = radius * radius; + LOCALITY + .into_iter() + .flat_map(move |neighbor| { + self.npc_grid.get(chunk_pos + neighbor).map(move |cell| { + cell.npcs + .iter() + .copied() + .filter(move |npc| { + self.npcs + .get(*npc) + .map_or(false, |npc| npc.wpos.distance_squared(wpos) < r_sqr) + && Some(*npc) != this_npc + }) + .map(Actor::Npc) + }) + }) + .flatten() + .chain( + self.character_map + .get(&chunk_pos) + .map(|characters| { + characters.iter().filter_map(move |(character, c_wpos)| { + if c_wpos.distance_squared(wpos) < r_sqr { + Some(Actor::Character(*character)) + } else { + None + } + }) + }) + .into_iter() + .flatten(), + ) + } +} + +impl Deref for Npcs { + type Target = HopSlotMap; + + fn deref(&self) -> &Self::Target { &self.npcs } +} + +impl DerefMut for Npcs { + fn deref_mut(&mut self) -> &mut Self::Target { &mut self.npcs } +} diff --git a/rtsim/src/data/report.rs b/rtsim/src/data/report.rs new file mode 100644 index 0000000000..127c0c6653 --- /dev/null +++ b/rtsim/src/data/report.rs @@ -0,0 +1,69 @@ +use common::{resources::TimeOfDay, rtsim::Actor}; +use serde::{Deserialize, Serialize}; +use slotmap::HopSlotMap; +use std::ops::Deref; +use vek::*; + +slotmap::new_key_type! { pub struct ReportId; } + +/// Represents a single piece of information known by an rtsim entity. +/// +/// Reports are the medium through which rtsim represents information sharing +/// between NPCs, factions, and sites. They can represent deaths, attacks, +/// changes in diplomacy, or any other piece of information representing a +/// singular event that might be communicated. +/// +/// Note that they should not be used to communicate sentiments like 'this actor +/// is friendly': the [`crate::data::Sentiment`] system should be used for that. +/// Some events might generate both a report and a change in sentiment. For +/// example, the murder of an NPC might generate both a murder report and highly +/// negative sentiments. +#[derive(Clone, Serialize, Deserialize)] +pub struct Report { + pub kind: ReportKind, + pub at: TimeOfDay, +} + +impl Report { + /// The time, in in-game seconds, for which the report will be remembered + fn remember_for(&self) -> f64 { + const DAYS: f64 = 60.0 * 60.0 * 24.0; + match &self.kind { + ReportKind::Death { killer, .. } => { + if killer.is_some() { + // Murder is less easy to forget + DAYS * 15.0 + } else { + DAYS * 5.0 + } + }, + } + } +} + +#[derive(Copy, Clone, Serialize, Deserialize)] +pub enum ReportKind { + Death { actor: Actor, killer: Option }, +} + +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct Reports { + pub reports: HopSlotMap, +} + +impl Reports { + pub fn create(&mut self, report: Report) -> ReportId { self.reports.insert(report) } + + pub fn cleanup(&mut self, current_time: TimeOfDay) { + // Forget reports that are too old + self.reports + .retain(|_, report| (current_time.0 - report.at.0).max(0.0) < report.remember_for()); + // TODO: Limit global number of reports + } +} + +impl Deref for Reports { + type Target = HopSlotMap; + + fn deref(&self) -> &Self::Target { &self.reports } +} diff --git a/rtsim/src/data/sentiment.rs b/rtsim/src/data/sentiment.rs new file mode 100644 index 0000000000..45c5d9119d --- /dev/null +++ b/rtsim/src/data/sentiment.rs @@ -0,0 +1,204 @@ +use common::{ + character::CharacterId, + rtsim::{Actor, FactionId, NpcId}, +}; +use hashbrown::HashMap; +use rand::prelude::*; +use serde::{Deserialize, Serialize}; +use std::collections::BinaryHeap; + +// Factions have a larger 'social memory' than individual NPCs and so we allow +// them to have more sentiments +pub const FACTION_MAX_SENTIMENTS: usize = 1024; +pub const NPC_MAX_SENTIMENTS: usize = 128; + +/// Magic factor used to control sentiment decay speed (note: higher = slower +/// decay, for implementation reasons). +const DECAY_TIME_FACTOR: f32 = 1.0; //6.0; TODO: Use this value when we're happy that everything is working as intended + +/// The target that a sentiment is felt toward. +// NOTE: More could be added to this! For example: +// - Animal species (dislikes spiders?) +// - Kind of food (likes meat?) +// - Occupations (hatred of hunters or chefs?) +// - Ideologies (dislikes democracy, likes monarchy?) +// - etc. +#[derive(Copy, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)] +pub enum Target { + Character(CharacterId), + Npc(NpcId), + Faction(FactionId), +} + +impl From for Target { + fn from(npc: NpcId) -> Self { Self::Npc(npc) } +} +impl From for Target { + fn from(faction: FactionId) -> Self { Self::Faction(faction) } +} +impl From for Target { + fn from(character: CharacterId) -> Self { Self::Character(character) } +} +impl From for Target { + fn from(actor: Actor) -> Self { + match actor { + Actor::Character(character) => Self::Character(character), + Actor::Npc(npc) => Self::Npc(npc), + } + } +} + +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct Sentiments { + #[serde(rename = "m")] + map: HashMap, +} + +impl Sentiments { + /// Return the sentiment that is felt toward the given target. + pub fn toward(&self, target: impl Into) -> Sentiment { + self.map.get(&target.into()).copied().unwrap_or_default() + } + + /// Change the sentiment toward the given target by the given amount, + /// capping out at the given value. + pub fn change_by(&mut self, target: impl Into, change: f32, cap: f32) { + let target = target.into(); + self.map.entry(target).or_default().change_by(change, cap); + } + + /// Progressively decay the sentiment back to a neutral sentiment. + /// + /// Note that sentiment get decay gets slower the harsher the sentiment is. + /// You can calculate the **average** number of seconds required for a + /// sentiment to neutral decay with the following formula: + /// + /// ```ignore + /// seconds_until_neutrality = ((sentiment_value * 127 * DECAY_TIME_FACTOR) ^ 2) / 2 + /// ``` + /// + /// For example, a positive (see [`Sentiment::POSITIVE`]) sentiment has a + /// value of `0.2`, so we get + /// + /// ```ignore + /// seconds_until_neutrality = ((0.1 * 127 * DECAY_TIME_FACTOR) ^ 2) / 2 = ~2,903 seconds, or 48 minutes + /// ``` + /// + /// Some 'common' sentiment decay times are as follows: + /// + /// - `POSITIVE`/`NEGATIVE`: ~48 minutes + /// - `ALLY`/`RIVAL`: ~7 hours + /// - `FRIEND`/`ENEMY`: ~29 hours + /// - `HERO`/`VILLAIN`: ~65 hours + pub fn decay(&mut self, rng: &mut impl Rng, dt: f32) { + self.map.retain(|_, sentiment| { + sentiment.decay(rng, dt); + // We can eliminate redundant sentiments that don't need remembering + !sentiment.is_redundant() + }); + } + + /// Clean up sentiments to avoid them growing too large + pub fn cleanup(&mut self, max_sentiments: usize) { + if self.map.len() > max_sentiments { + let mut sentiments = self.map + .iter() + // For each sentiment, calculate how valuable it is for us to remember. + // For now, we just use the absolute value of the sentiment but later on we might want to favour + // sentiments toward factions and other 'larger' groups over, say, sentiments toward players/other NPCs + .map(|(tgt, sentiment)| (sentiment.positivity.unsigned_abs(), *tgt)) + .collect::>(); + + // Remove the superfluous sentiments + for (_, tgt) in sentiments + .drain_sorted() + .take(self.map.len() - max_sentiments) + { + self.map.remove(&tgt); + } + } + } +} + +#[derive(Copy, Clone, Default, Serialize, Deserialize)] +pub struct Sentiment { + /// How positive the sentiment is. + /// + /// Using i8 to reduce on-disk memory footprint. + /// Semantically, this value is -1 <= x <= 1. + #[serde(rename = "p")] + positivity: i8, +} + +impl Sentiment { + /// Substantial positive sentiments: NPC may go out of their way to help + /// actors associated with the target, greet them, etc. + pub const ALLY: f32 = 0.3; + /// Very negative sentiments: NPC may confront the actor, get aggressive + /// with them, or even use force against them. + pub const ENEMY: f32 = -0.6; + /// Very positive sentiments: NPC may join the actor as a companion, + /// encourage them to join their faction, etc. + pub const FRIEND: f32 = 0.6; + /// Extremely positive sentiments: NPC may switch sides to join the actor's + /// faction, protect them at all costs, turn against friends for them, + /// etc. Verging on cult-like behaviour. + pub const HERO: f32 = 0.8; + /// Minor negative sentiments: NPC might be less willing to provide + /// information, give worse trade deals, etc. + pub const NEGATIVE: f32 = -0.1; + /// Minor positive sentiments: NPC might be more willing to provide + /// information, give better trade deals, etc. + pub const POSITIVE: f32 = 0.1; + /// Substantial positive sentiments: NPC may reject attempts to trade or + /// avoid actors associated with the target, insult them, but will not + /// use physical force. + pub const RIVAL: f32 = -0.3; + /// Extremely negative sentiments: NPC may aggressively persue or hunt down + /// the actor, organise others around them to do the same, and will + /// generally try to harm the actor in any way they can. + pub const VILLAIN: f32 = -0.8; + + fn value(&self) -> f32 { self.positivity as f32 * (1.0 / 126.0) } + + fn change_by(&mut self, change: f32, cap: f32) { + // There's a bit of ceremony here for two reasons: + // 1) Very small changes should not be rounded to 0 + // 2) Sentiment should never (over/under)flow + if change != 0.0 { + let abs = (change * 126.0).abs().clamp(1.0, 126.0) as i8; + let cap = (cap.abs().min(1.0) * 126.0) as i8; + self.positivity = if change > 0.0 { + self.positivity.saturating_add(abs).min(cap) + } else { + self.positivity.saturating_sub(abs).max(-cap) + }; + } + } + + fn decay(&mut self, rng: &mut impl Rng, dt: f32) { + if self.positivity != 0 { + // TODO: Find a slightly nicer way to have sentiment decay, perhaps even by + // remembering the last interaction instead of constant updates. + if rng.gen_bool( + (1.0 / (self.positivity.unsigned_abs() as f32 * DECAY_TIME_FACTOR.powi(2) * dt)) + as f64, + ) { + self.positivity -= self.positivity.signum(); + } + } + } + + /// Return `true` if the sentiment can be forgotten without changing + /// anything (i.e: is entirely neutral, the default stance). + fn is_redundant(&self) -> bool { self.positivity == 0 } + + /// Returns `true` if the sentiment has reached the given threshold. + pub fn is(&self, val: f32) -> bool { + if val > 0.0 { + self.value() >= val + } else { + self.value() <= val + } + } +} diff --git a/rtsim/src/data/site.rs b/rtsim/src/data/site.rs new file mode 100644 index 0000000000..c736e49623 --- /dev/null +++ b/rtsim/src/data/site.rs @@ -0,0 +1,86 @@ +use crate::data::{ReportId, Reports}; +pub use common::rtsim::SiteId; +use common::{ + rtsim::{FactionId, NpcId}, + store::Id, +}; +use hashbrown::{HashMap, HashSet}; +use serde::{Deserialize, Serialize}; +use slotmap::HopSlotMap; +use std::ops::{Deref, DerefMut}; +use vek::*; +use world::site::Site as WorldSite; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Site { + pub seed: u32, + pub wpos: Vec2, + pub faction: Option, + + /// The [`Report`]s that the site tracks (you can imagine them being on a + /// noticeboard or something). + pub known_reports: HashSet, + + /// The site generated during initial worldgen that this site corresponds + /// to. + /// + /// Eventually, rtsim should replace initial worldgen's site system and this + /// will not be necessary. + /// + /// When setting up rtsim state, we try to 'link' these two definitions of a + /// site: but if initial worldgen has changed, this might not be + /// possible. We try to delete sites that no longer exist during setup, but + /// this is an inherent fallible process. If linking fails, we try to + /// delete the site in rtsim2 in order to avoid an 'orphaned' site. + /// (TODO: create new sites for new initial worldgen sites that come into + /// being too). + #[serde(skip_serializing, skip_deserializing)] + pub world_site: Option>, + + // Note: there's currently no guarantee that site populations are non-intersecting + #[serde(skip_serializing, skip_deserializing)] + pub population: HashSet, +} + +impl Site { + pub fn with_faction(mut self, faction: impl Into>) -> Self { + self.faction = faction.into(); + self + } + + pub fn cleanup(&mut self, reports: &Reports) { + // Clear reports that have been forgotten + self.known_reports + .retain(|report| reports.contains_key(*report)); + // TODO: Limit number of reports + } +} + +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct Sites { + pub sites: HopSlotMap, + + #[serde(skip_serializing, skip_deserializing)] + pub world_site_map: HashMap, SiteId>, +} + +impl Sites { + pub fn create(&mut self, site: Site) -> SiteId { + let world_site = site.world_site; + let key = self.sites.insert(site); + if let Some(world_site) = world_site { + self.world_site_map.insert(world_site, key); + } + key + } +} + +impl Deref for Sites { + type Target = HopSlotMap; + + fn deref(&self) -> &Self::Target { &self.sites } +} + +impl DerefMut for Sites { + fn deref_mut(&mut self) -> &mut Self::Target { &mut self.sites } +} diff --git a/rtsim/src/event.rs b/rtsim/src/event.rs new file mode 100644 index 0000000000..4edd6a1f1b --- /dev/null +++ b/rtsim/src/event.rs @@ -0,0 +1,38 @@ +use crate::{RtState, Rule}; +use common::{ + resources::{Time, TimeOfDay}, + rtsim::Actor, +}; +use vek::*; +use world::{IndexRef, World}; + +pub trait Event: Clone + 'static {} + +pub struct EventCtx<'a, R: Rule, E: Event> { + pub state: &'a RtState, + pub rule: &'a mut R, + pub event: &'a E, + pub world: &'a World, + pub index: IndexRef<'a>, +} + +#[derive(Clone)] +pub struct OnSetup; +impl Event for OnSetup {} + +#[derive(Clone)] +pub struct OnTick { + pub time_of_day: TimeOfDay, + pub time: Time, + pub tick: u64, + pub dt: f32, +} +impl Event for OnTick {} + +#[derive(Clone)] +pub struct OnDeath { + pub actor: Actor, + pub wpos: Option>, + pub killer: Option, +} +impl Event for OnDeath {} diff --git a/rtsim/src/gen/faction.rs b/rtsim/src/gen/faction.rs new file mode 100644 index 0000000000..a7426552bb --- /dev/null +++ b/rtsim/src/gen/faction.rs @@ -0,0 +1,14 @@ +use crate::data::Faction; +use rand::prelude::*; +use world::{IndexRef, World}; + +impl Faction { + pub fn generate(_world: &World, _index: IndexRef, rng: &mut impl Rng) -> Self { + Self { + seed: rng.gen(), + leader: None, + good_or_evil: rng.gen(), + sentiments: Default::default(), + } + } +} diff --git a/rtsim/src/gen/mod.rs b/rtsim/src/gen/mod.rs new file mode 100644 index 0000000000..1c6c8c13ce --- /dev/null +++ b/rtsim/src/gen/mod.rs @@ -0,0 +1,222 @@ +pub mod faction; +pub mod name; +pub mod site; + +use crate::data::{ + faction::Faction, + npc::{Npc, Npcs, Profession, Vehicle}, + site::Site, + Data, Nature, CURRENT_VERSION, +}; +use common::{ + comp::{self, Body}, + grid::Grid, + resources::TimeOfDay, + rtsim::{Personality, WorldSettings}, + terrain::TerrainChunkSize, + vol::RectVolSize, +}; +use rand::prelude::*; +use tracing::info; +use vek::*; +use world::{site::SiteKind, site2::PlotKind, IndexRef, World}; + +impl Data { + pub fn generate(settings: &WorldSettings, world: &World, index: IndexRef) -> Self { + let mut seed = [0; 32]; + seed.iter_mut() + .zip(&mut index.seed.to_le_bytes()) + .for_each(|(dst, src)| *dst = *src); + let mut rng = SmallRng::from_seed(seed); + + let mut this = Self { + version: CURRENT_VERSION, + nature: Nature::generate(world), + npcs: Npcs { + npcs: Default::default(), + vehicles: Default::default(), + npc_grid: Grid::new(Vec2::zero(), Default::default()), + character_map: Default::default(), + }, + sites: Default::default(), + factions: Default::default(), + reports: Default::default(), + + tick: 0, + time_of_day: TimeOfDay(settings.start_time), + should_purge: false, + }; + + let initial_factions = (0..16) + .map(|_| { + let faction = Faction::generate(world, index, &mut rng); + let wpos = world + .sim() + .get_size() + .map2(TerrainChunkSize::RECT_SIZE, |e, sz| { + rng.gen_range(0..(e * sz) as i32) + }); + (wpos, this.factions.create(faction)) + }) + .collect::>(); + info!("Generated {} rtsim factions.", this.factions.len()); + + // Register sites with rtsim + for (world_site_id, _) in index.sites.iter() { + let site = Site::generate( + world_site_id, + world, + index, + &initial_factions, + &this.factions, + &mut rng, + ); + this.sites.create(site); + } + info!( + "Registering {} rtsim sites from world sites.", + this.sites.len() + ); + // Spawn some test entities at the sites + for (site_id, site, site2) in this.sites.iter() + // TODO: Stupid. Only find site2 towns + .filter_map(|(site_id, site)| Some((site_id, site, site.world_site + .and_then(|ws| match &index.sites.get(ws).kind { + SiteKind::Refactor(site2) + | SiteKind::CliffTown(site2) + | SiteKind::SavannahPit(site2) + | SiteKind::DesertCity(site2) => Some(site2), + _ => None, + })?))) + { + let Some(good_or_evil) = site + .faction + .and_then(|f| this.factions.get(f)) + .map(|f| f.good_or_evil) + else { continue }; + + let rand_wpos = |rng: &mut SmallRng, matches_plot: fn(&PlotKind) -> bool| { + let wpos2d = site2 + .plots() + .filter(|plot| matches_plot(plot.kind())) + .choose(&mut thread_rng()) + .map(|plot| site2.tile_center_wpos(plot.root_tile())) + .unwrap_or_else(|| site.wpos.map(|e| e + rng.gen_range(-10..10))); + wpos2d + .map(|e| e as f32 + 0.5) + .with_z(world.sim().get_alt_approx(wpos2d).unwrap_or(0.0)) + }; + let random_humanoid = |rng: &mut SmallRng| { + let species = comp::humanoid::ALL_SPECIES.choose(&mut *rng).unwrap(); + Body::Humanoid(comp::humanoid::Body::random_with(rng, species)) + }; + let matches_buildings = (|kind: &PlotKind| { + matches!( + kind, + PlotKind::House(_) | PlotKind::Workshop(_) | PlotKind::Plaza + ) + }) as _; + let matches_plazas = (|kind: &PlotKind| matches!(kind, PlotKind::Plaza)) as _; + if good_or_evil { + for _ in 0..site2.plots().len() { + this.npcs.create_npc( + Npc::new( + rng.gen(), + rand_wpos(&mut rng, matches_buildings), + random_humanoid(&mut rng), + ) + .with_faction(site.faction) + .with_home(site_id) + .with_personality(Personality::random(&mut rng)) + .with_profession(match rng.gen_range(0..20) { + 0 => Profession::Hunter, + 1 => Profession::Blacksmith, + 2 => Profession::Chef, + 3 => Profession::Alchemist, + 5..=8 => Profession::Farmer, + 9..=10 => Profession::Herbalist, + 11..=16 => Profession::Guard, + _ => Profession::Adventurer(rng.gen_range(0..=3)), + }), + ); + } + } else { + for _ in 0..15 { + this.npcs.create_npc( + Npc::new( + rng.gen(), + rand_wpos(&mut rng, matches_buildings), + random_humanoid(&mut rng), + ) + .with_personality(Personality::random_evil(&mut rng)) + .with_faction(site.faction) + .with_home(site_id) + .with_profession(Profession::Cultist), + ); + } + } + // Merchants + if good_or_evil { + for _ in 0..(site2.plots().len() / 6) + 1 { + this.npcs.create_npc( + Npc::new( + rng.gen(), + rand_wpos(&mut rng, matches_plazas), + random_humanoid(&mut rng), + ) + .with_home(site_id) + .with_personality(Personality::random_good(&mut rng)) + .with_profession(Profession::Merchant), + ); + } + } + + if rng.gen_bool(0.4) { + let wpos = rand_wpos(&mut rng, matches_plazas) + Vec3::unit_z() * 50.0; + let vehicle_id = this + .npcs + .create_vehicle(Vehicle::new(wpos, comp::body::ship::Body::DefaultAirship)); + + this.npcs.create_npc( + Npc::new(rng.gen(), wpos, random_humanoid(&mut rng)) + .with_home(site_id) + .with_profession(Profession::Captain) + .with_personality(Personality::random_good(&mut rng)) + .steering(vehicle_id), + ); + } + } + + for (site_id, site) in this.sites.iter() + // TODO: Stupid + .filter(|(_, site)| site.world_site.map_or(false, |ws| + matches!(&index.sites.get(ws).kind, SiteKind::Dungeon(_)))) + { + let rand_wpos = |rng: &mut SmallRng| { + let wpos2d = site.wpos.map(|e| e + rng.gen_range(-10..10)); + wpos2d + .map(|e| e as f32 + 0.5) + .with_z(world.sim().get_alt_approx(wpos2d).unwrap_or(0.0)) + }; + + let species = [ + comp::body::bird_large::Species::Phoenix, + comp::body::bird_large::Species::Cockatrice, + comp::body::bird_large::Species::Roc, + ] + .choose(&mut rng) + .unwrap(); + this.npcs.create_npc( + Npc::new( + rng.gen(), + rand_wpos(&mut rng), + Body::BirdLarge(comp::body::bird_large::Body::random_with(&mut rng, species)), + ) + .with_home(site_id), + ); + } + info!("Generated {} rtsim NPCs.", this.npcs.len()); + + this + } +} diff --git a/rtsim/src/gen/name.rs b/rtsim/src/gen/name.rs new file mode 100644 index 0000000000..4c56387804 --- /dev/null +++ b/rtsim/src/gen/name.rs @@ -0,0 +1,22 @@ +use rand::prelude::*; + +pub fn generate(rng: &mut impl Rng) -> String { + let starts = ["ad", "tr", "b", "l", "p", "d", "r", "w", "t", "fr", "s"]; + let vowels = ["o", "e", "a", "i"]; + let cons = ["m", "d", "st", "n", "y", "gh", "s"]; + + let mut name = String::new(); + + name += starts.choose(rng).unwrap(); + + for _ in 0..rng.gen_range(1..=3) { + name += vowels.choose(rng).unwrap(); + name += cons.choose(rng).unwrap(); + } + + // Make the first letter uppercase (hacky) + name.chars() + .enumerate() + .map(|(i, c)| if i == 0 { c.to_ascii_uppercase() } else { c }) + .collect() +} diff --git a/rtsim/src/gen/site.rs b/rtsim/src/gen/site.rs new file mode 100644 index 0000000000..fd4c09eed5 --- /dev/null +++ b/rtsim/src/gen/site.rs @@ -0,0 +1,58 @@ +use crate::data::{FactionId, Factions, Site}; +use common::store::Id; +use rand::prelude::*; +use vek::*; +use world::{ + site::{Site as WorldSite, SiteKind}, + IndexRef, World, +}; + +impl Site { + pub fn generate( + world_site_id: Id, + _world: &World, + index: IndexRef, + nearby_factions: &[(Vec2, FactionId)], + factions: &Factions, + rng: &mut impl Rng, + ) -> Self { + let world_site = index.sites.get(world_site_id); + let wpos = world_site.get_origin(); + + // TODO: This is stupid, do better + let good_or_evil = match &world_site.kind { + // Good + SiteKind::Refactor(_) + | SiteKind::CliffTown(_) + | SiteKind::DesertCity(_) + | SiteKind::SavannahPit(_) => Some(true), + // Evil + SiteKind::Dungeon(_) | SiteKind::ChapelSite(_) | SiteKind::Gnarling(_) => Some(false), + // Neutral + SiteKind::Settlement(_) + | SiteKind::Castle(_) + | SiteKind::Tree(_) + | SiteKind::GiantTree(_) + | SiteKind::Bridge(_) => None, + }; + + Self { + seed: rng.gen(), + wpos, + world_site: Some(world_site_id), + faction: good_or_evil.and_then(|good_or_evil| { + nearby_factions + .iter() + .filter(|(_, faction)| { + factions + .get(*faction) + .map_or(false, |f| f.good_or_evil == good_or_evil) + }) + .min_by_key(|(faction_wpos, _)| faction_wpos.distance_squared(wpos)) + .map(|(_, faction)| *faction) + }), + population: Default::default(), + known_reports: Default::default(), + } + } +} diff --git a/rtsim/src/lib.rs b/rtsim/src/lib.rs new file mode 100644 index 0000000000..14844c1e6c --- /dev/null +++ b/rtsim/src/lib.rs @@ -0,0 +1,189 @@ +#![feature( + never_type, + try_blocks, + generator_trait, + generators, + trait_alias, + trait_upcasting, + control_flow_enum, + let_chains, + binary_heap_drain_sorted +)] + +pub mod ai; +pub mod data; +pub mod event; +pub mod gen; +pub mod rule; + +pub use self::{ + data::Data, + event::{Event, EventCtx, OnTick}, + rule::{Rule, RuleError}, +}; +use anymap2::SendSyncAnyMap; +use atomic_refcell::AtomicRefCell; +use common::resources::{Time, TimeOfDay}; +use std::{ + any::type_name, + ops::{Deref, DerefMut}, +}; +use tracing::{error, info}; +use world::{IndexRef, World}; + +pub struct RtState { + resources: SendSyncAnyMap, + rules: SendSyncAnyMap, + event_handlers: SendSyncAnyMap, +} + +type RuleState = AtomicRefCell; +type EventHandlersOf = Vec>; + +impl RtState { + pub fn new(data: Data) -> Self { + let mut this = Self { + resources: SendSyncAnyMap::new(), + rules: SendSyncAnyMap::new(), + event_handlers: SendSyncAnyMap::new(), + } + .with_resource(data); + + this.start_default_rules(); + + this + } + + pub fn with_resource(mut self, r: R) -> Self { + self.resources.insert(AtomicRefCell::new(r)); + self + } + + fn start_default_rules(&mut self) { + info!("Starting default rtsim rules..."); + self.start_rule::(); + self.start_rule::(); + self.start_rule::(); + self.start_rule::(); + self.start_rule::(); + self.start_rule::(); + self.start_rule::(); + } + + pub fn start_rule(&mut self) { + info!("Initiating '{}' rule...", type_name::()); + match R::start(self) { + Ok(rule) => { + self.rules.insert::>(AtomicRefCell::new(rule)); + }, + Err(e) => error!("Error when initiating '{}' rule: {}", type_name::(), e), + } + } + + fn rule_mut(&self) -> impl DerefMut + '_ { + self.rules + .get::>() + .unwrap_or_else(|| { + panic!( + "Tried to access rule '{}' but it does not exist", + type_name::() + ) + }) + .borrow_mut() + } + + // TODO: Consider whether it's worth explicitly calling rule event handlers + // instead of allowing them to bind event handlers. Less modular, but + // potentially easier to deal with data dependencies? + pub fn bind( + &mut self, + f: impl FnMut(EventCtx) + Send + Sync + 'static, + ) { + let f = AtomicRefCell::new(f); + self.event_handlers + .entry::>() + .or_default() + .push(Box::new(move |state, world, index, event| { + (f.borrow_mut())(EventCtx { + state, + rule: &mut state.rule_mut(), + event, + world, + index, + }) + })); + } + + pub fn data(&self) -> impl Deref + '_ { self.resource() } + + pub fn data_mut(&self) -> impl DerefMut + '_ { self.resource_mut() } + + pub fn get_data_mut(&mut self) -> &mut Data { self.get_resource_mut() } + + pub fn resource(&self) -> impl Deref + '_ { + self.resources + .get::>() + .unwrap_or_else(|| { + panic!( + "Tried to access resource '{}' but it does not exist", + type_name::() + ) + }) + .borrow() + } + + pub fn get_resource_mut(&mut self) -> &mut R { + self.resources + .get_mut::>() + .unwrap_or_else(|| { + panic!( + "Tried to access resource '{}' but it does not exist", + type_name::() + ) + }) + .get_mut() + } + + pub fn resource_mut(&self) -> impl DerefMut + '_ { + self.resources + .get::>() + .unwrap_or_else(|| { + panic!( + "Tried to access resource '{}' but it does not exist", + type_name::() + ) + }) + .borrow_mut() + } + + pub fn emit(&mut self, e: E, world: &World, index: IndexRef) { + // TODO: Queue these events up and handle them on a regular rtsim tick instead + // of executing their handlers immediately. + if let Some(handlers) = self.event_handlers.get::>() { + handlers.iter().for_each(|f| f(self, world, index, &e)); + } + } + + pub fn tick( + &mut self, + world: &World, + index: IndexRef, + time_of_day: TimeOfDay, + time: Time, + dt: f32, + ) { + let tick = { + let mut data = self.data_mut(); + data.time_of_day = time_of_day; + data.tick += 1; + data.tick + }; + let event = OnTick { + time_of_day, + tick, + time, + dt, + }; + self.emit(event, world, index); + } +} diff --git a/rtsim/src/rule.rs b/rtsim/src/rule.rs new file mode 100644 index 0000000000..696b86ff9a --- /dev/null +++ b/rtsim/src/rule.rs @@ -0,0 +1,29 @@ +pub mod cleanup; +pub mod migrate; +pub mod npc_ai; +pub mod replenish_resources; +pub mod report; +pub mod simulate_npcs; +pub mod sync_npcs; + +use super::RtState; +use std::fmt; + +#[derive(Debug)] +pub enum RuleError { + NoSuchRule(&'static str), +} + +impl fmt::Display for RuleError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::NoSuchRule(r) => { + write!(f, "tried to fetch rule state '{}' but it does not exist", r) + }, + } + } +} + +pub trait Rule: Sized + Send + Sync + 'static { + fn start(rtstate: &mut RtState) -> Result; +} diff --git a/rtsim/src/rule/cleanup.rs b/rtsim/src/rule/cleanup.rs new file mode 100644 index 0000000000..5cc9391ed4 --- /dev/null +++ b/rtsim/src/rule/cleanup.rs @@ -0,0 +1,70 @@ +use crate::{event::OnTick, RtState, Rule, RuleError}; +use rand::prelude::*; +use rand_chacha::ChaChaRng; + +/// Prevent performing cleanup for every NPC every tick +const NPC_SENTIMENT_TICK_SKIP: u64 = 30; +const NPC_CLEANUP_TICK_SKIP: u64 = 100; +const FACTION_CLEANUP_TICK_SKIP: u64 = 30; +const SITE_CLEANUP_TICK_SKIP: u64 = 30; + +/// A rule that cleans up data structures in rtsim: removing old reports, +/// irrelevant sentiments, etc. +/// +/// Also performs sentiment decay (although this should be moved elsewhere) +pub struct CleanUp; + +impl Rule for CleanUp { + fn start(rtstate: &mut RtState) -> Result { + rtstate.bind::(|ctx| { + let data = &mut *ctx.state.data_mut(); + let mut rng = ChaChaRng::from_seed(thread_rng().gen::<[u8; 32]>()); + + // TODO: Use `.into_par_iter()` for these by implementing rayon traits in upstream slotmap. + + // Decay NPC sentiments + data.npcs + .iter_mut() + // Only cleanup NPCs every few ticks + .filter(|(_, npc)| (npc.seed as u64 + ctx.event.tick) % NPC_SENTIMENT_TICK_SKIP == 0) + .for_each(|(_, npc)| npc.sentiments.decay(&mut rng, ctx.event.dt * NPC_SENTIMENT_TICK_SKIP as f32)); + + // Remove dead NPCs + // TODO: Don't do this every tick, find a sensible way to gradually remove dead NPCs after they've been + // forgotten + data.npcs + .retain(|npc_id, npc| if npc.is_dead { + // Remove NPC from home population + if let Some(home) = npc.home.and_then(|home| data.sites.get_mut(home)) { + home.population.remove(&npc_id); + } + false + } else { + true + }); + + // Clean up entities + data.npcs + .iter_mut() + .filter(|(_, npc)| (npc.seed as u64 + ctx.event.tick) % NPC_CLEANUP_TICK_SKIP == 0) + .for_each(|(_, npc)| npc.cleanup(&data.reports)); + + // Clean up factions + data.factions + .iter_mut() + .filter(|(_, faction)| (faction.seed as u64 + ctx.event.tick) % FACTION_CLEANUP_TICK_SKIP == 0) + .for_each(|(_, faction)| faction.cleanup()); + + // Clean up sites + data.sites + .iter_mut() + .filter(|(_, site)| (site.seed as u64 + ctx.event.tick) % SITE_CLEANUP_TICK_SKIP == 0) + .for_each(|(_, site)| site.cleanup(&data.reports)); + + // Clean up old reports + data.reports.cleanup(data.time_of_day); + }); + + Ok(Self) + } +} diff --git a/rtsim/src/rule/migrate.rs b/rtsim/src/rule/migrate.rs new file mode 100644 index 0000000000..2153fb6131 --- /dev/null +++ b/rtsim/src/rule/migrate.rs @@ -0,0 +1,87 @@ +use crate::{data::Site, event::OnSetup, RtState, Rule, RuleError}; +use rand::prelude::*; +use rand_chacha::ChaChaRng; +use tracing::warn; +use world::site::SiteKind; + +/// This rule runs at rtsim startup and broadly acts to perform some primitive +/// migration/sanitisation in order to ensure that the state of rtsim is mostly +/// sensible. +pub struct Migrate; + +impl Rule for Migrate { + fn start(rtstate: &mut RtState) -> Result { + rtstate.bind::(|ctx| { + let data = &mut *ctx.state.data_mut(); + + let mut rng = ChaChaRng::from_seed(thread_rng().gen::<[u8; 32]>()); + + // Delete rtsim sites that don't correspond to a world site + data.sites.sites.retain(|site_id, site| { + if let Some((world_site_id, _)) = ctx + .index + .sites + .iter() + .find(|(_, world_site)| world_site.get_origin() == site.wpos) + { + site.world_site = Some(world_site_id); + data.sites.world_site_map.insert(world_site_id, site_id); + true + } else { + warn!( + "{:?} is no longer valid because the site it was derived from no longer \ + exists. It will now be deleted.", + site_id + ); + false + } + }); + + // Generate rtsim sites for world sites that don't have a corresponding rtsim + // site yet + for (world_site_id, _) in ctx.index.sites.iter() { + if !data.sites.values().any(|site| { + site.world_site + .expect("Rtsim site not assigned to world site") + == world_site_id + }) { + warn!( + "{:?} is new and does not have a corresponding rtsim site. One will now \ + be generated afresh.", + world_site_id + ); + data.sites.create(Site::generate( + world_site_id, + ctx.world, + ctx.index, + &[], + &data.factions, + &mut rng, + )); + } + } + + // Reassign NPCs to sites if their old one was deleted. If they were already homeless, no need to do anything. + for npc in data.npcs.values_mut() { + if let Some(home) = npc.home + && !data.sites.contains_key(home) + { + // Choose the closest habitable site as the new home for the NPC + npc.home = data.sites.sites + .iter() + .filter(|(_, site)| { + // TODO: This is a bit silly, but needs to wait on the removal of site1 + site.world_site.map_or(false, |ws| matches!(&ctx.index.sites.get(ws).kind, SiteKind::Refactor(_) + | SiteKind::CliffTown(_) + | SiteKind::SavannahPit(_) + | SiteKind::DesertCity(_))) + }) + .min_by_key(|(_, site)| site.wpos.as_().distance_squared(npc.wpos.xy()) as i32) + .map(|(site_id, _)| site_id); + } + } + }); + + Ok(Self) + } +} diff --git a/rtsim/src/rule/npc_ai.rs b/rtsim/src/rule/npc_ai.rs new file mode 100644 index 0000000000..7c562319a0 --- /dev/null +++ b/rtsim/src/rule/npc_ai.rs @@ -0,0 +1,1034 @@ +use std::hash::BuildHasherDefault; + +use crate::{ + ai::{casual, choose, finish, important, just, now, seq, until, Action, NpcCtx}, + data::{ + npc::{Brain, PathData, SimulationMode}, + ReportKind, Sentiment, Sites, + }, + event::OnTick, + RtState, Rule, RuleError, +}; +use common::{ + astar::{Astar, PathResult}, + comp::Content, + path::Path, + rtsim::{ChunkResource, Profession, SiteId}, + spiral::Spiral2d, + store::Id, + terrain::{CoordinateConversions, SiteKindMeta, TerrainChunkSize}, + time::DayPeriod, +}; +use fxhash::FxHasher64; +use itertools::{Either, Itertools}; +use rand::prelude::*; +use rand_chacha::ChaChaRng; +use rayon::iter::{IntoParallelRefMutIterator, ParallelIterator}; +use vek::*; +use world::{ + civ::{self, Track}, + site::{Site as WorldSite, SiteKind}, + site2::{self, PlotKind, TileKind}, + util::NEIGHBORS, + IndexRef, World, +}; + +/// How many ticks should pass between running NPC AI. +/// Note that this only applies to simulated NPCs: loaded NPCs have their AI +/// code run every tick. This means that AI code should be broadly +/// DT-independent. +const SIMULATED_TICK_SKIP: u64 = 10; + +pub struct NpcAi; + +const CARDINALS: &[Vec2] = &[ + Vec2::new(1, 0), + Vec2::new(0, 1), + Vec2::new(-1, 0), + Vec2::new(0, -1), +]; + +fn path_in_site(start: Vec2, end: Vec2, site: &site2::Site) -> PathResult> { + let heuristic = |tile: &Vec2, _: &Vec2| tile.as_::().distance(end.as_()); + let mut astar = Astar::new(1000, start, BuildHasherDefault::::default()); + + let transition = |a: &Vec2, b: &Vec2| { + let distance = a.as_::().distance(b.as_()); + let a_tile = site.tiles.get(*a); + let b_tile = site.tiles.get(*b); + + let terrain = match &b_tile.kind { + TileKind::Empty => 3.0, + TileKind::Hazard(_) => 50.0, + TileKind::Field => 8.0, + TileKind::Plaza | TileKind::Road { .. } | TileKind::Path => 1.0, + + TileKind::Building + | TileKind::Castle + | TileKind::Wall(_) + | TileKind::Tower(_) + | TileKind::Keep(_) + | TileKind::Gate + | TileKind::GnarlingFortification => 5.0, + }; + let is_door_tile = |plot: Id, tile: Vec2| match site.plot(plot).kind() { + site2::PlotKind::House(house) => house.door_tile == tile, + site2::PlotKind::Workshop(_) => true, + _ => false, + }; + let building = if a_tile.is_building() && b_tile.is_road() { + a_tile + .plot + .and_then(|plot| is_door_tile(plot, *a).then_some(1.0)) + .unwrap_or(10000.0) + } else if b_tile.is_building() && a_tile.is_road() { + b_tile + .plot + .and_then(|plot| is_door_tile(plot, *b).then_some(1.0)) + .unwrap_or(10000.0) + } else if (a_tile.is_building() || b_tile.is_building()) && a_tile.plot != b_tile.plot { + 10000.0 + } else { + 1.0 + }; + + distance * terrain + building + }; + + astar.poll( + 1000, + heuristic, + |&tile| CARDINALS.iter().map(move |c| tile + *c), + transition, + |tile| *tile == end || site.tiles.get_known(*tile).is_none(), + ) +} + +fn path_between_sites( + start: SiteId, + end: SiteId, + sites: &Sites, + world: &World, +) -> PathResult<(Id, bool)> { + let world_site = |site_id: SiteId| { + let id = sites.get(site_id).and_then(|site| site.world_site)?; + world.civs().sites.recreate_id(id.id()) + }; + + let start = if let Some(start) = world_site(start) { + start + } else { + return PathResult::Pending; + }; + let end = if let Some(end) = world_site(end) { + end + } else { + return PathResult::Pending; + }; + + let get_site = |site: &Id| world.civs().sites.get(*site); + + let end_pos = get_site(&end).center.as_::(); + let heuristic = + |site: &Id, _: &Id| get_site(site).center.as_().distance(end_pos); + + let mut astar = Astar::new(250, start, BuildHasherDefault::::default()); + + let neighbors = |site: &Id| world.civs().neighbors(*site); + + let track_between = |a: Id, b: Id| { + world + .civs() + .tracks + .get(world.civs().track_between(a, b).unwrap().0) + }; + + let transition = |a: &Id, b: &Id| track_between(*a, *b).cost; + + let path = astar.poll(250, heuristic, neighbors, transition, |site| *site == end); + + path.map(|path| { + let path = path + .into_iter() + .tuple_windows::<(_, _)>() + .map(|(a, b)| world.civs().track_between(a, b).unwrap()) + .collect_vec(); + Path { nodes: path } + }) +} + +fn path_site( + start: Vec2, + end: Vec2, + site: Id, + index: IndexRef, +) -> Option>> { + if let Some(site) = index.sites.get(site).site2() { + let start = site.wpos_tile_pos(start.as_()); + + let end = site.wpos_tile_pos(end.as_()); + + let nodes = match path_in_site(start, end, site) { + PathResult::Path(p) => p.nodes, + PathResult::Exhausted(p) => p.nodes, + PathResult::None(_) | PathResult::Pending => return None, + }; + + Some( + nodes + .into_iter() + .map(|tile| site.tile_center_wpos(tile).as_() + 0.5) + .collect(), + ) + } else { + None + } +} + +fn path_towns( + start: SiteId, + end: SiteId, + sites: &Sites, + world: &World, +) -> Option, bool), SiteId>> { + match path_between_sites(start, end, sites, world) { + PathResult::Exhausted(p) => Some(PathData { + end, + path: p.nodes.into(), + repoll: true, + }), + PathResult::Path(p) => Some(PathData { + end, + path: p.nodes.into(), + repoll: false, + }), + PathResult::Pending | PathResult::None(_) => None, + } +} + +impl Rule for NpcAi { + fn start(rtstate: &mut RtState) -> Result { + rtstate.bind::(|ctx| { + // Temporarily take the brains of NPCs out of their heads to appease the borrow + // checker + let mut npc_data = { + let mut data = ctx.state.data_mut(); + data.npcs + .iter_mut() + // Don't run AI for dead NPCs + .filter(|(_, npc)| !npc.is_dead) + // Don't run AI for simulated NPCs every tick + .filter(|(_, npc)| matches!(npc.mode, SimulationMode::Loaded) || (npc.seed as u64 + ctx.event.tick) % SIMULATED_TICK_SKIP == 0) + .map(|(npc_id, npc)| { + let controller = std::mem::take(&mut npc.controller); + let inbox = std::mem::take(&mut npc.inbox); + let sentiments = std::mem::take(&mut npc.sentiments); + let known_reports = std::mem::take(&mut npc.known_reports); + let brain = npc.brain.take().unwrap_or_else(|| Brain { + action: Box::new(think().repeat()), + }); + (npc_id, controller, inbox, sentiments, known_reports, brain) + }) + .collect::>() + }; + + // Do a little thinking + { + let data = &*ctx.state.data(); + + npc_data + .par_iter_mut() + .for_each(|(npc_id, controller, inbox, sentiments, known_reports, brain)| { + let npc = &data.npcs[*npc_id]; + + brain.action.tick(&mut NpcCtx { + state: ctx.state, + world: ctx.world, + index: ctx.index, + time_of_day: ctx.event.time_of_day, + time: ctx.event.time, + npc, + npc_id: *npc_id, + controller, + inbox, + known_reports, + sentiments, + rng: ChaChaRng::from_seed(thread_rng().gen::<[u8; 32]>()), + }); + }); + } + + // Reinsert NPC brains + let mut data = ctx.state.data_mut(); + for (npc_id, controller, inbox, sentiments, known_reports, brain) in npc_data { + data.npcs[npc_id].controller = controller; + data.npcs[npc_id].brain = Some(brain); + data.npcs[npc_id].inbox = inbox; + data.npcs[npc_id].sentiments = sentiments; + data.npcs[npc_id].known_reports = known_reports; + } + }); + + Ok(Self) + } +} + +fn idle() -> impl Action { just(|ctx| ctx.controller.do_idle()).debug(|| "idle") } + +/// Try to walk toward a 3D position without caring for obstacles. +fn goto(wpos: Vec3, speed_factor: f32, goal_dist: f32) -> impl Action { + const STEP_DIST: f32 = 24.0; + const WAYPOINT_DIST: f32 = 12.0; + + let mut waypoint = None; + + just(move |ctx| { + let rpos = wpos - ctx.npc.wpos; + let len = rpos.magnitude(); + + // If we're close to the next waypoint, complete it + if waypoint.map_or(false, |waypoint: Vec3| { + ctx.npc.wpos.xy().distance_squared(waypoint.xy()) < WAYPOINT_DIST.powi(2) + }) { + waypoint = None; + } + + // Get the next waypoint on the route toward the goal + let waypoint = waypoint.get_or_insert_with(|| { + let wpos = ctx.npc.wpos + (rpos / len) * len.min(STEP_DIST); + + wpos.with_z( + ctx.world + .sim() + .get_surface_alt_approx(wpos.xy().as_()) + .unwrap_or(wpos.z), + ) + }); + + ctx.controller.do_goto(*waypoint, speed_factor); + }) + .repeat() + .stop_if(move |ctx| ctx.npc.wpos.xy().distance_squared(wpos.xy()) < goal_dist.powi(2)) + .debug(move || format!("goto {}, {}, {}", wpos.x, wpos.y, wpos.z)) + .map(|_| {}) +} + +/// Try to walk toward a 2D position on the terrain without caring for +/// obstacles. +fn goto_2d(wpos2d: Vec2, speed_factor: f32, goal_dist: f32) -> impl Action { + now(move |ctx| { + let wpos = wpos2d.with_z(ctx.world.sim().get_alt_approx(wpos2d.as_()).unwrap_or(0.0)); + goto(wpos, speed_factor, goal_dist) + }) +} + +fn traverse_points(mut next_point: F, speed_factor: f32) -> impl Action +where + F: FnMut(&mut NpcCtx) -> Option> + Send + Sync + 'static, +{ + until(move |ctx| { + let wpos = next_point(ctx)?; + + let wpos_site = |wpos: Vec2| { + ctx.world + .sim() + .get(wpos.as_().wpos_to_cpos()) + .and_then(|chunk| chunk.sites.first().copied()) + }; + + // If we're traversing within a site, to intra-site pathfinding + if let Some(site) = wpos_site(wpos) { + let mut site_exit = wpos; + while let Some(next) = next_point(ctx).filter(|next| wpos_site(*next) == Some(site)) { + site_exit = next; + } + + if let Some(path) = path_site(wpos, site_exit, site, ctx.index) { + Some(Either::Left( + seq(path.into_iter().map(|wpos| goto_2d(wpos, 1.0, 8.0))).then(goto_2d( + site_exit, + speed_factor, + 8.0, + )), + )) + } else { + Some(Either::Right(goto_2d(site_exit, speed_factor, 8.0))) + } + } else { + Some(Either::Right(goto_2d(wpos, speed_factor, 8.0))) + } + }) +} + +/// Try to travel to a site. Where practical, paths will be taken. +fn travel_to_point(wpos: Vec2, speed_factor: f32) -> impl Action { + now(move |ctx| { + const WAYPOINT: f32 = 48.0; + let start = ctx.npc.wpos.xy(); + let diff = wpos - start; + let n = (diff.magnitude() / WAYPOINT).max(1.0); + let mut points = (1..n as usize + 1).map(move |i| start + diff * (i as f32 / n)); + traverse_points(move |_| points.next(), speed_factor) + }) + .debug(|| "travel to point") +} + +/// Try to travel to a site. Where practical, paths will be taken. +fn travel_to_site(tgt_site: SiteId, speed_factor: f32) -> impl Action { + now(move |ctx| { + let sites = &ctx.state.data().sites; + + // If we're currently in a site, try to find a path to the target site via + // tracks + if let Some(current_site) = ctx.npc.current_site + && let Some(tracks) = path_towns(current_site, tgt_site, sites, ctx.world) + { + + let mut nodes = tracks.path + .into_iter() + .flat_map(move |(track_id, reversed)| (0..) + .map(move |node_idx| (node_idx, track_id, reversed))); + + traverse_points(move |ctx| { + let (node_idx, track_id, reversed) = nodes.next()?; + let nodes = &ctx.world.civs().tracks.get(track_id).path().nodes; + + // Handle the case where we walk paths backward + let idx = if reversed { + nodes.len().checked_sub(node_idx + 1) + } else { + Some(node_idx) + }; + + if let Some(node) = idx.and_then(|idx| nodes.get(idx)) { + // Find the centre of the track node's chunk + let node_chunk_wpos = TerrainChunkSize::center_wpos(*node); + + // Refine the node position a bit more based on local path information + Some(ctx.world.sim() + .get_nearest_path(node_chunk_wpos) + .map_or(node_chunk_wpos, |(_, wpos, _, _)| wpos.as_()) + .as_::()) + } else { + None + } + }, speed_factor) + .boxed() + + // For every track in the path we discovered between the sites... + // seq(tracks + // .path + // .into_iter() + // .enumerate() + // // ...traverse the nodes of that path. + // .map(move |(i, (track_id, reversed))| now(move |ctx| { + // let track_len = ctx.world.civs().tracks.get(track_id).path().len(); + // // Tracks can be traversed backward (i.e: from end to beginning). Account for this. + // seq(if reversed { + // Either::Left((0..track_len).rev()) + // } else { + // Either::Right(0..track_len) + // } + // .enumerate() + // .map(move |(i, node_idx)| now(move |ctx| { + // // Find the centre of the track node's chunk + // let node_chunk_wpos = TerrainChunkSize::center_wpos(ctx.world + // .civs() + // .tracks + // .get(track_id) + // .path() + // .nodes[node_idx]); + + // // Refine the node position a bit more based on local path information + // let node_wpos = ctx.world.sim() + // .get_nearest_path(node_chunk_wpos) + // .map_or(node_chunk_wpos, |(_, wpos, _, _)| wpos.as_()); + + // // Walk toward the node + // goto_2d(node_wpos.as_(), 1.0, 8.0) + // .debug(move || format!("traversing track node ({}/{})", i + 1, track_len)) + // }))) + // }) + // .debug(move || format!("travel via track {:?} ({}/{})", track_id, i + 1, track_count)))) + // .boxed() + } else if let Some(site) = sites.get(tgt_site) { + // If all else fails, just walk toward the target site in a straight line + travel_to_point(site.wpos.map(|e| e as f32 + 0.5), speed_factor).boxed() + } else { + // If we can't find a way to get to the site at all, there's nothing more to be done + finish().boxed() + } + }) + .debug(move || format!("travel_to_site {:?}", tgt_site)) +} + +// Seconds +fn timeout(time: f64) -> impl FnMut(&mut NpcCtx) -> bool + Clone + Send + Sync { + let mut timeout = None; + move |ctx| ctx.time.0 > *timeout.get_or_insert(ctx.time.0 + time) +} + +fn socialize() -> impl Action { + now(|ctx| { + // Skip most socialising actions if we're not loaded + if matches!(ctx.npc.mode, SimulationMode::Loaded) && ctx.rng.gen_bool(0.002) { + if ctx.rng.gen_bool(0.15) { + return Either::Left( + just(|ctx| ctx.controller.do_dance()) + .repeat() + .stop_if(timeout(6.0)) + .debug(|| "dancing") + .map(|_| ()) + .boxed(), + ); + } else if let Some(other) = ctx + .state + .data() + .npcs + .nearby(Some(ctx.npc_id), ctx.npc.wpos, 8.0) + .choose(&mut ctx.rng) + { + return Either::Left( + just(move |ctx| ctx.controller.say(other, ctx.npc.personality.get_generic_comment(&mut ctx.rng))) + // After greeting the actor, wait for a while + .then(idle().repeat().stop_if(timeout(4.0))) + .map(|_| ()) + .boxed(), + ); + } + } + + Either::Right(idle()) + }) +} + +fn adventure() -> impl Action { + choose(|ctx| { + // Choose a random site that's fairly close by + if let Some(tgt_site) = ctx + .state + .data() + .sites + .iter() + .filter(|(site_id, site)| { + // Only path toward towns + matches!( + site.world_site.map(|ws| &ctx.index.sites.get(ws).kind), + Some( + SiteKind::Refactor(_) + | SiteKind::CliffTown(_) + | SiteKind::SavannahPit(_) + | SiteKind::DesertCity(_) + ), + ) && ctx.npc.current_site.map_or(true, |cs| *site_id != cs) + && ctx.rng.gen_bool(0.25) + }) + .min_by_key(|(_, site)| site.wpos.as_().distance(ctx.npc.wpos.xy()) as i32) + .map(|(site_id, _)| site_id) + { + let wait_time = if matches!(ctx.npc.profession, Some(Profession::Merchant)) { + 60.0 * 15.0 + } else { + 60.0 * 3.0 + }; + let site_name = ctx.state.data().sites[tgt_site].world_site + .map(|ws| ctx.index.sites.get(ws).name().to_string()) + .unwrap_or_default(); + // Travel to the site + important(just(move |ctx| ctx.controller.say(None, Content::localized_with_args("npc-speech-moving_on", [("site", site_name.clone())]))) + .then(travel_to_site(tgt_site, 0.6)) + // Stop for a few minutes + .then(villager(tgt_site).repeat().stop_if(timeout(wait_time))) + .map(|_| ()) + .boxed(), + ) + } else { + casual(finish().boxed()) + } + }) + .debug(move || "adventure") +} + +fn gather_ingredients() -> impl Action { + just(|ctx| { + ctx.controller.do_gather( + &[ + ChunkResource::Fruit, + ChunkResource::Mushroom, + ChunkResource::Plant, + ][..], + ) + }) + .debug(|| "gather ingredients") +} + +fn hunt_animals() -> impl Action { + just(|ctx| ctx.controller.do_hunt_animals()).debug(|| "hunt_animals") +} + +fn find_forest(ctx: &mut NpcCtx) -> Option> { + let chunk_pos = ctx.npc.wpos.xy().as_().wpos_to_cpos(); + Spiral2d::new() + .skip(ctx.rng.gen_range(1..=8)) + .take(49) + .map(|rpos| chunk_pos + rpos) + .find(|cpos| { + ctx.world + .sim() + .get(*cpos) + .map_or(false, |c| c.tree_density > 0.75 && c.surface_veg > 0.5) + }) + .map(|chunk| TerrainChunkSize::center_wpos(chunk).as_()) +} + +fn choose_plaza(ctx: &mut NpcCtx, site: SiteId) -> Option> { + ctx.state + .data() + .sites + .get(site) + .and_then(|site| ctx.index.sites.get(site.world_site?).site2()) + .and_then(|site2| { + let plaza = &site2.plots[site2.plazas().choose(&mut ctx.rng)?]; + let tile = plaza + .tiles() + .choose(&mut ctx.rng) + .unwrap_or_else(|| plaza.root_tile()); + Some(site2.tile_center_wpos(tile).as_()) + }) +} + +fn villager(visiting_site: SiteId) -> impl Action { + choose(move |ctx| { + /* + if ctx + .state + .data() + .sites + .get(visiting_site) + .map_or(true, |s| s.world_site.is_none()) + { + return casual(idle() + .debug(|| "idling (visiting site does not exist, perhaps it's stale data?)")); + } else if ctx.npc.current_site != Some(visiting_site) { + let npc_home = ctx.npc.home; + // Travel to the site we're supposed to be in + return urgent(travel_to_site(visiting_site, 1.0).debug(move || { + if npc_home == Some(visiting_site) { + "travel home".to_string() + } else { + "travel to visiting site".to_string() + } + })); + } else + */ + if DayPeriod::from(ctx.time_of_day.0).is_dark() + && !matches!(ctx.npc.profession, Some(Profession::Guard)) + { + return important( + now(move |ctx| { + if let Some(house_wpos) = ctx + .state + .data() + .sites + .get(visiting_site) + .and_then(|site| ctx.index.sites.get(site.world_site?).site2()) + .and_then(|site2| { + // Find a house in the site we're visiting + let house = site2 + .plots() + .filter(|p| matches!(p.kind(), PlotKind::House(_))) + .choose(&mut ctx.rng)?; + Some(site2.tile_center_wpos(house.root_tile()).as_()) + }) + { + just(|ctx| { + ctx.controller + .say(None, Content::localized("npc-speech-night_time")) + }) + .then(travel_to_point(house_wpos, 0.65)) + .debug(|| "walk to house") + .then(socialize().repeat().debug(|| "wait in house")) + .stop_if(|ctx| DayPeriod::from(ctx.time_of_day.0).is_light()) + .then(just(|ctx| { + ctx.controller + .say(None, Content::localized("npc-speech-day_time")) + })) + .map(|_| ()) + .boxed() + } else { + finish().boxed() + } + }) + .debug(|| "find somewhere to sleep"), + ); + // Villagers with roles should perform those roles + } else if matches!(ctx.npc.profession, Some(Profession::Herbalist)) && ctx.rng.gen_bool(0.8) + { + if let Some(forest_wpos) = find_forest(ctx) { + return casual( + travel_to_point(forest_wpos, 0.5) + .debug(|| "walk to forest") + .then({ + let wait_time = ctx.rng.gen_range(10.0..30.0); + gather_ingredients().repeat().stop_if(timeout(wait_time)) + }) + .map(|_| ()), + ); + } + } else if matches!(ctx.npc.profession, Some(Profession::Hunter)) && ctx.rng.gen_bool(0.8) { + if let Some(forest_wpos) = find_forest(ctx) { + return casual( + just(|ctx| { + ctx.controller + .say(None, Content::localized("npc-speech-start_hunting")) + }) + .then(travel_to_point(forest_wpos, 0.75)) + .debug(|| "walk to forest") + .then({ + let wait_time = ctx.rng.gen_range(30.0..60.0); + hunt_animals().repeat().stop_if(timeout(wait_time)) + }) + .map(|_| ()), + ); + } + } else if matches!(ctx.npc.profession, Some(Profession::Guard)) && ctx.rng.gen_bool(0.5) { + if let Some(plaza_wpos) = choose_plaza(ctx, visiting_site) { + return important( + travel_to_point(plaza_wpos, 0.45) + .debug(|| "patrol") + .interrupt_with(|ctx| { + if ctx.rng.gen_bool(0.0003) { + Some(just(move |ctx| { + ctx.controller + .say(None, Content::localized("npc-speech-guard_thought")) + })) + } else { + None + } + }) + .map(|_| ()), + ); + } + } else if matches!(ctx.npc.profession, Some(Profession::Merchant)) && ctx.rng.gen_bool(0.8) + { + return casual( + just(|ctx| { + // Try to direct our speech at nearby actors, if there are any + let (target, phrase) = if ctx.rng.gen_bool(0.3) && let Some(other) = ctx + .state + .data() + .npcs + .nearby(Some(ctx.npc_id), ctx.npc.wpos, 8.0) + .choose(&mut ctx.rng) + { + (Some(other), "npc-speech-merchant_sell_directed") + } else { + // Otherwise, resort to generic expressions + (None, "npc-speech-merchant_sell_undirected") + }; + + ctx.controller.say(target, Content::localized(phrase)); + }) + .then(idle().repeat().stop_if(timeout(8.0))) + .repeat() + .stop_if(timeout(60.0)) + .debug(|| "sell wares") + .map(|_| ()), + ); + } + + // If nothing else needs doing, walk between plazas and socialize + casual(now(move |ctx| { + // Choose a plaza in the site we're visiting to walk to + if let Some(plaza_wpos) = choose_plaza(ctx, visiting_site) { + // Walk to the plaza... + Either::Left(travel_to_point(plaza_wpos, 0.5) + .debug(|| "walk to plaza")) + } else { + // No plazas? :( + Either::Right(finish()) + } + // ...then socialize for some time before moving on + .then(socialize() + .repeat() + .stop_if(timeout(ctx.rng.gen_range(30.0..90.0))) + .debug(|| "wait at plaza")) + .map(|_| ()) + })) + }) + .debug(move || format!("villager at site {:?}", visiting_site)) +} + +/* +fn follow(npc: NpcId, distance: f32) -> impl Action { + const STEP_DIST: f32 = 1.0; + now(move |ctx| { + if let Some(npc) = ctx.state.data().npcs.get(npc) { + let d = npc.wpos.xy() - ctx.npc.wpos.xy(); + let len = d.magnitude(); + let dir = d / len; + let wpos = ctx.npc.wpos.xy() + dir * STEP_DIST.min(len - distance); + goto_2d(wpos, 1.0, distance).boxed() + } else { + // The npc we're trying to follow doesn't exist. + finish().boxed() + } + }) + .repeat() + .debug(move || format!("Following npc({npc:?})")) + .map(|_| {}) +} +*/ + +fn chunk_path( + from: Vec2, + to: Vec2, + chunk_height: impl Fn(Vec2) -> Option, +) -> Box { + let heuristics = + |(p, _): &(Vec2, i32), _: &(Vec2, i32)| p.distance_squared(to) as f32; + let start = (from, chunk_height(from).unwrap()); + let mut astar = Astar::new(1000, start, BuildHasherDefault::::default()); + + let path = astar.poll( + 1000, + heuristics, + |&(p, _)| { + NEIGHBORS + .into_iter() + .map(move |n| p + n) + .filter_map(|p| Some((p, chunk_height(p)?))) + }, + |(p0, h0), (p1, h1)| { + let diff = (p0 - p1).as_().cpos_to_wpos().with_z((h0 - h1) as f32); + + diff.magnitude_squared() + }, + |(e, _)| *e == to, + ); + let path = match path { + PathResult::Exhausted(p) | PathResult::Path(p) => p, + _ => return finish().boxed(), + }; + let len = path.len(); + seq(path + .into_iter() + .enumerate() + .map(move |(i, (chunk_pos, height))| { + let wpos = TerrainChunkSize::center_wpos(chunk_pos) + .with_z(height) + .as_(); + goto(wpos, 1.0, 5.0) + .debug(move || format!("chunk path {i}/{len} chunk: {chunk_pos}, height: {height}")) + })) + .boxed() +} + +fn pilot() -> impl Action { + // Travel between different towns in a straight line + now(|ctx| { + let data = &*ctx.state.data(); + let site = data + .sites + .iter() + .filter(|(id, _)| Some(*id) != ctx.npc.current_site) + .filter(|(_, site)| { + site.world_site + .and_then(|site| ctx.index.sites.get(site).kind.convert_to_meta()) + .map_or(false, |meta| matches!(meta, SiteKindMeta::Settlement(_))) + }) + .choose(&mut ctx.rng); + if let Some((_id, site)) = site { + let start_chunk = ctx.npc.wpos.xy().as_().wpos_to_cpos(); + let end_chunk = site.wpos.wpos_to_cpos(); + chunk_path(start_chunk, end_chunk, |chunk| { + ctx.world + .sim() + .get_alt_approx(TerrainChunkSize::center_wpos(chunk)) + .map(|f| (f + 150.0) as i32) + }) + } else { + finish().boxed() + } + }) + .repeat() + .map(|_| ()) +} + +fn captain() -> impl Action { + // For now just randomly travel the sea + now(|ctx| { + let chunk = ctx.npc.wpos.xy().as_().wpos_to_cpos(); + if let Some(chunk) = NEIGHBORS + .into_iter() + .map(|neighbor| chunk + neighbor) + .filter(|neighbor| { + ctx.world + .sim() + .get(*neighbor) + .map_or(false, |c| c.river.river_kind.is_some()) + }) + .choose(&mut ctx.rng) + { + let wpos = TerrainChunkSize::center_wpos(chunk); + let wpos = wpos.as_().with_z( + ctx.world + .sim() + .get_interpolated(wpos, |chunk| chunk.water_alt) + .unwrap_or(0.0), + ); + goto(wpos, 0.7, 5.0).boxed() + } else { + idle().boxed() + } + }) + .repeat() + .map(|_| ()) +} + +fn check_inbox(ctx: &mut NpcCtx) -> Option { + loop { + match ctx.inbox.pop_front() { + Some(report_id) if !ctx.known_reports.contains(&report_id) => { + #[allow(clippy::single_match)] + match ctx.state.data().reports.get(report_id).map(|r| r.kind) { + Some(ReportKind::Death { killer, .. }) => { + // TODO: Sentiment should be positive if we didn't like actor that died + // TODO: Don't report self + let phrase = if let Some(killer) = killer { + // TODO: Don't hard-code sentiment change + ctx.sentiments.change_by(killer, -0.7, Sentiment::VILLAIN); + "npc-speech-witness_murder" + } else { + "npc-speech-witness_death" + }; + ctx.known_reports.insert(report_id); + break Some(just(move |ctx| { + ctx.controller.say(killer, Content::localized(phrase)) + })); + }, + None => {}, // Stale report, ignore + } + }, + Some(_) => {}, // Reports we already know of are ignored + None => break None, + } + } +} + +fn check_for_enemies(ctx: &mut NpcCtx) -> Option { + // TODO: Instead of checking all nearby actors every tick, it would be more + // effective to have the actor grid generate a per-tick diff so that we only + // need to check new actors in the local area. Be careful though: + // implementing this means accounting for changes in sentiment (that could + // suddenly make a nearby actor an enemy) as well as variable NPC tick + // rates! + ctx.state + .data() + .npcs + .nearby(Some(ctx.npc_id), ctx.npc.wpos, 24.0) + .find(|actor| ctx.sentiments.toward(*actor).is(Sentiment::ENEMY)) + .map(|enemy| just(move |ctx| ctx.controller.attack(enemy))) +} + +fn react_to_events(ctx: &mut NpcCtx) -> Option { + check_inbox(ctx) + .map(|action| action.boxed()) + .or_else(|| check_for_enemies(ctx).map(|action| action.boxed())) +} + +fn humanoid() -> impl Action { + choose(|ctx| { + if let Some(riding) = &ctx.npc.riding { + if riding.steering { + if let Some(vehicle) = ctx.state.data().npcs.vehicles.get(riding.vehicle) { + match vehicle.body { + common::comp::ship::Body::DefaultAirship + | common::comp::ship::Body::AirBalloon => important(pilot()), + common::comp::ship::Body::SailBoat | common::comp::ship::Body::Galleon => { + important(captain()) + }, + _ => casual(idle()), + } + } else { + casual(finish()) + } + } else { + important(socialize()) + } + } else { + let action = if matches!( + ctx.npc.profession, + Some(Profession::Adventurer(_) | Profession::Merchant) + ) { + adventure().boxed() + } else if let Some(home) = ctx.npc.home { + villager(home).boxed() + } else { + idle().boxed() // Homeless + }; + + casual(action.interrupt_with(react_to_events)) + } + }) +} + +fn bird_large() -> impl Action { + choose(|ctx| { + let data = ctx.state.data(); + if let Some(home) = ctx.npc.home { + let is_home = ctx.npc.current_site.map_or(false, |site| home == site); + if is_home { + if let Some((_, site)) = data + .sites + .iter() + .filter(|(id, site)| { + *id != home + && site.world_site.map_or(false, |site| { + matches!(ctx.index.sites.get(site).kind, SiteKind::Dungeon(_)) + }) + }) + .choose(&mut ctx.rng) + { + casual(goto( + site.wpos.as_::().with_z( + ctx.world + .sim() + .get_surface_alt_approx(site.wpos) + .unwrap_or(0.0) + + ctx.npc.body.flying_height(), + ), + 1.0, + 20.0, + )) + } else { + casual(idle()) + } + } else if let Some(site) = data.sites.get(home) { + casual(goto( + site.wpos.as_::().with_z( + ctx.world + .sim() + .get_surface_alt_approx(site.wpos) + .unwrap_or(0.0) + + ctx.npc.body.flying_height(), + ), + 1.0, + 20.0, + )) + } else { + casual(idle()) + } + } else { + casual(idle()) + } + }) +} + +fn think() -> impl Action { + choose(|ctx| match ctx.npc.body { + common::comp::Body::Humanoid(_) => casual(humanoid()), + common::comp::Body::BirdLarge(_) => casual(bird_large()), + _ => casual(socialize()), + }) +} diff --git a/rtsim/src/rule/replenish_resources.rs b/rtsim/src/rule/replenish_resources.rs new file mode 100644 index 0000000000..b2f3c0969d --- /dev/null +++ b/rtsim/src/rule/replenish_resources.rs @@ -0,0 +1,42 @@ +use crate::{event::OnTick, RtState, Rule, RuleError}; +use rand::prelude::*; + +pub struct ReplenishResources; + +/// Take 1 hour to replenish resources entirely. Makes farming unviable, but +/// probably still poorly balanced. +// TODO: Different rates for different resources? +// TODO: Non-renewable resources? +pub const REPLENISH_TIME: f32 = 60.0 * 60.0; +/// How many chunks should be replenished per tick? +// TODO: It should be possible to optimise this be remembering the last +// modification time for each chunk, then lazily projecting forward using a +// closed-form solution to the replenishment to calculate resources in a lazy +// manner. +pub const REPLENISH_PER_TICK: usize = 8192; + +impl Rule for ReplenishResources { + fn start(rtstate: &mut RtState) -> Result { + rtstate.bind::(|ctx| { + let world_size = ctx.world.sim().get_size(); + let mut data = ctx.state.data_mut(); + + // How much should be replenished for each chosen chunk to hit our target + // replenishment rate? + let replenish_amount = world_size.product() as f32 + * ctx.event.dt + * (1.0 / REPLENISH_TIME / REPLENISH_PER_TICK as f32); + for _ in 0..REPLENISH_PER_TICK { + let key = world_size.map(|e| thread_rng().gen_range(0..e as i32)); + + let mut res = data.nature.get_chunk_resources(key); + for (_, res) in &mut res { + *res = (*res + replenish_amount).clamp(0.0, 1.0); + } + data.nature.set_chunk_resources(key, res); + } + }); + + Ok(Self) + } +} diff --git a/rtsim/src/rule/report.rs b/rtsim/src/rule/report.rs new file mode 100644 index 0000000000..74874a2605 --- /dev/null +++ b/rtsim/src/rule/report.rs @@ -0,0 +1,46 @@ +use crate::{ + data::{report::ReportKind, Report}, + event::{EventCtx, OnDeath}, + RtState, Rule, RuleError, +}; + +pub struct ReportEvents; + +impl Rule for ReportEvents { + fn start(rtstate: &mut RtState) -> Result { + rtstate.bind::(on_death); + + Ok(Self) + } +} + +fn on_death(ctx: EventCtx) { + let data = &mut *ctx.state.data_mut(); + + if let Some(wpos) = ctx.event.wpos { + let nearby = data + .npcs + .nearby(None, wpos, 32.0) + .filter_map(|actor| actor.npc()) + .collect::>(); + + if !nearby.is_empty() { + let report = data.reports.create(Report { + kind: ReportKind::Death { + actor: ctx.event.actor, + killer: ctx.event.killer, + }, + at: data.time_of_day, + }); + + // TODO: Don't push report to NPC inboxes, have a dedicated data structure that + // tracks reports by chunks and then have NPCs decide to query this + // data structure in their own time. + for npc_id in nearby { + if let Some(npc) = data.npcs.get_mut(npc_id) { + npc.inbox.push_back(report); + } + } + } + } +} diff --git a/rtsim/src/rule/simulate_npcs.rs b/rtsim/src/rule/simulate_npcs.rs new file mode 100644 index 0000000000..c788d9527d --- /dev/null +++ b/rtsim/src/rule/simulate_npcs.rs @@ -0,0 +1,275 @@ +use crate::{ + data::{npc::SimulationMode, Npc}, + event::{EventCtx, OnDeath, OnSetup, OnTick}, + RtState, Rule, RuleError, +}; +use common::{ + comp::{self, Body}, + rtsim::{Actor, NpcAction, NpcActivity, Personality}, + terrain::CoordinateConversions, +}; +use rand::prelude::*; +use rand_chacha::ChaChaRng; +use tracing::{error, warn}; +use world::site::SiteKind; + +pub struct SimulateNpcs; + +impl Rule for SimulateNpcs { + fn start(rtstate: &mut RtState) -> Result { + rtstate.bind::(on_setup); + rtstate.bind::(on_death); + rtstate.bind::(on_tick); + + Ok(Self) + } +} + +fn on_setup(ctx: EventCtx) { + let data = &mut *ctx.state.data_mut(); + + // Add riders to vehicles + for (npc_id, npc) in data.npcs.npcs.iter_mut() { + if let Some(ride) = &npc.riding { + if let Some(vehicle) = data.npcs.vehicles.get_mut(ride.vehicle) { + let actor = Actor::Npc(npc_id); + if ride.steering && vehicle.driver.replace(actor).is_some() { + error!("Replaced driver"); + npc.riding = None; + } + } + } + } +} + +fn on_death(ctx: EventCtx) { + let data = &mut *ctx.state.data_mut(); + + if let Actor::Npc(npc_id) = ctx.event.actor + && let Some(npc) = data.npcs.get(npc_id) + { + let mut rng = ChaChaRng::from_seed(thread_rng().gen::<[u8; 32]>()); + + // Respawn dead NPCs + let details = match npc.body { + Body::Humanoid(_) => { + if let Some((site_id, site)) = data + .sites + .iter() + .filter(|(id, site)| { + Some(*id) != npc.home + && (npc.faction.is_none() || site.faction == npc.faction) + && site.world_site.map_or(false, |s| { + matches!(ctx.index.sites.get(s).kind, SiteKind::Refactor(_) + | SiteKind::CliffTown(_) + | SiteKind::SavannahPit(_) + | SiteKind::DesertCity(_)) + }) + }) + .min_by_key(|(_, site)| site.population.len()) + { + let rand_wpos = |rng: &mut ChaChaRng| { + let wpos2d = site.wpos.map(|e| e + rng.gen_range(-10..10)); + wpos2d + .map(|e| e as f32 + 0.5) + .with_z(ctx.world.sim().get_alt_approx(wpos2d).unwrap_or(0.0)) + }; + let random_humanoid = |rng: &mut ChaChaRng| { + let species = comp::humanoid::ALL_SPECIES.choose(&mut *rng).unwrap(); + Body::Humanoid(comp::humanoid::Body::random_with(rng, species)) + }; + let npc_id = data.spawn_npc( + Npc::new(rng.gen(), rand_wpos(&mut rng), random_humanoid(&mut rng)) + .with_personality(Personality::random(&mut rng)) + .with_home(site_id) + .with_faction(npc.faction) + .with_profession(npc.profession.clone()), + ); + Some((npc_id, site_id)) + } else { + warn!("No site found for respawning humanoid"); + None + } + }, + Body::BirdLarge(_) => { + if let Some((site_id, site)) = data + .sites + .iter() + .filter(|(id, site)| { + Some(*id) != npc.home + && site.world_site.map_or(false, |s| { + matches!(ctx.index.sites.get(s).kind, SiteKind::Dungeon(_)) + }) + }) + .min_by_key(|(_, site)| site.population.len()) + { + let rand_wpos = |rng: &mut ChaChaRng| { + let wpos2d = site.wpos.map(|e| e + rng.gen_range(-10..10)); + wpos2d + .map(|e| e as f32 + 0.5) + .with_z(ctx.world.sim().get_alt_approx(wpos2d).unwrap_or(0.0)) + }; + let species = [ + comp::body::bird_large::Species::Phoenix, + comp::body::bird_large::Species::Cockatrice, + comp::body::bird_large::Species::Roc, + ] + .choose(&mut rng) + .unwrap(); + let npc_id = data.npcs.create_npc( + Npc::new( + rng.gen(), + rand_wpos(&mut rng), + Body::BirdLarge(comp::body::bird_large::Body::random_with( + &mut rng, species, + )), + ) + .with_home(site_id), + ); + Some((npc_id, site_id)) + } else { + warn!("No site found for respawning bird"); + None + } + }, + body => { + error!("Tried to respawn rtsim NPC with invalid body: {:?}", body); + None + }, + }; + + // Add the NPC to their home site + if let Some((npc_id, home_site)) = details { + if let Some(home) = data.sites.get_mut(home_site) { + home.population.insert(npc_id); + } + } + } else { + error!("Trying to respawn non-existent NPC"); + } +} + +fn on_tick(ctx: EventCtx) { + let data = &mut *ctx.state.data_mut(); + for npc in data + .npcs + .npcs + .values_mut() + .filter(|npc| matches!(npc.mode, SimulationMode::Simulated) && !npc.is_dead) + { + // Simulate NPC movement when riding + if let Some(riding) = &npc.riding { + if let Some(vehicle) = data.npcs.vehicles.get_mut(riding.vehicle) { + match npc.controller.activity { + // If steering, the NPC controls the vehicle's motion + Some(NpcActivity::Goto(target, speed_factor)) if riding.steering => { + let diff = target.xy() - vehicle.wpos.xy(); + let dist2 = diff.magnitude_squared(); + + if dist2 > 0.5f32.powi(2) { + let mut wpos = vehicle.wpos + + (diff + * (vehicle.get_speed() * speed_factor * ctx.event.dt + / dist2.sqrt()) + .min(1.0)) + .with_z(0.0); + + let is_valid = match vehicle.body { + common::comp::ship::Body::DefaultAirship + | common::comp::ship::Body::AirBalloon => true, + common::comp::ship::Body::SailBoat + | common::comp::ship::Body::Galleon => { + let chunk_pos = wpos.xy().as_().wpos_to_cpos(); + ctx.world + .sim() + .get(chunk_pos) + .map_or(true, |f| f.river.river_kind.is_some()) + }, + _ => false, + }; + + if is_valid { + match vehicle.body { + common::comp::ship::Body::DefaultAirship + | common::comp::ship::Body::AirBalloon => { + if let Some(alt) = ctx + .world + .sim() + .get_alt_approx(wpos.xy().as_()) + .filter(|alt| wpos.z < *alt) + { + wpos.z = alt; + } + }, + common::comp::ship::Body::SailBoat + | common::comp::ship::Body::Galleon => { + wpos.z = ctx + .world + .sim() + .get_interpolated( + wpos.xy().map(|e| e as i32), + |chunk| chunk.water_alt, + ) + .unwrap_or(0.0); + }, + _ => {}, + } + vehicle.wpos = wpos; + } + } + }, + // When riding, other actions are disabled + Some( + NpcActivity::Goto(_, _) + | NpcActivity::Gather(_) + | NpcActivity::HuntAnimals + | NpcActivity::Dance, + ) => {}, + None => {}, + } + npc.wpos = vehicle.wpos; + } else { + // Vehicle doens't exist anymore + npc.riding = None; + } + // If not riding, we assume they're just walking + } else { + match npc.controller.activity { + // Move NPCs if they have a target destination + Some(NpcActivity::Goto(target, speed_factor)) => { + let diff = target.xy() - npc.wpos.xy(); + let dist2 = diff.magnitude_squared(); + + if dist2 > 0.5f32.powi(2) { + npc.wpos += (diff + * (npc.body.max_speed_approx() * speed_factor * ctx.event.dt + / dist2.sqrt()) + .min(1.0)) + .with_z(0.0); + } + }, + Some(NpcActivity::Gather(_) | NpcActivity::HuntAnimals | NpcActivity::Dance) => { + // TODO: Maybe they should walk around randomly + // when gathering resources? + }, + None => {}, + } + } + + // Consume NPC actions + for action in std::mem::take(&mut npc.controller.actions) { + match action { + NpcAction::Say(_, _) => {}, // Currently, just swallow interactions + NpcAction::Attack(_) => {}, // TODO: Implement simulated combat + } + } + + // Make sure NPCs remain on the surface + npc.wpos.z = ctx + .world + .sim() + .get_surface_alt_approx(npc.wpos.xy().map(|e| e as i32)) + .unwrap_or(0.0) + + npc.body.flying_height(); + } +} diff --git a/rtsim/src/rule/sync_npcs.rs b/rtsim/src/rule/sync_npcs.rs new file mode 100644 index 0000000000..af71b08779 --- /dev/null +++ b/rtsim/src/rule/sync_npcs.rs @@ -0,0 +1,111 @@ +use crate::{ + event::{EventCtx, OnDeath, OnSetup, OnTick}, + RtState, Rule, RuleError, +}; +use common::{grid::Grid, rtsim::Actor, terrain::CoordinateConversions}; + +pub struct SyncNpcs; + +impl Rule for SyncNpcs { + fn start(rtstate: &mut RtState) -> Result { + rtstate.bind::(on_setup); + rtstate.bind::(on_death); + rtstate.bind::(on_tick); + + Ok(Self) + } +} + +fn on_setup(ctx: EventCtx) { + let data = &mut *ctx.state.data_mut(); + + // Create NPC grid + data.npcs.npc_grid = Grid::new(ctx.world.sim().get_size().as_(), Default::default()); + + // Add NPCs to home population + for (npc_id, npc) in data.npcs.npcs.iter() { + if let Some(home) = npc.home.and_then(|home| data.sites.get_mut(home)) { + home.population.insert(npc_id); + } + } +} + +fn on_death(ctx: EventCtx) { + let data = &mut *ctx.state.data_mut(); + + if let Actor::Npc(npc_id) = ctx.event.actor { + if let Some(npc) = data.npcs.get_mut(npc_id) { + // Mark the NPC as dead, allowing us to clear them up later + npc.is_dead = true; + } + } +} + +fn on_tick(ctx: EventCtx) { + let data = &mut *ctx.state.data_mut(); + // Update vehicle grid cells + for (vehicle_id, vehicle) in data.npcs.vehicles.iter_mut() { + let chunk_pos = vehicle.wpos.xy().as_().wpos_to_cpos(); + if vehicle.chunk_pos != Some(chunk_pos) { + if let Some(cell) = vehicle + .chunk_pos + .and_then(|chunk_pos| data.npcs.npc_grid.get_mut(chunk_pos)) + { + if let Some(index) = cell.vehicles.iter().position(|id| *id == vehicle_id) { + cell.vehicles.swap_remove(index); + } + } + vehicle.chunk_pos = Some(chunk_pos); + if let Some(cell) = data.npcs.npc_grid.get_mut(chunk_pos) { + cell.vehicles.push(vehicle_id); + } + } + } + for (npc_id, npc) in data.npcs.npcs.iter_mut() { + // Update the NPC's current site, if any + npc.current_site = ctx + .world + .sim() + .get(npc.wpos.xy().as_().wpos_to_cpos()) + .and_then(|chunk| { + chunk + .sites + .iter() + .find_map(|site| data.sites.world_site_map.get(site).copied()) + }); + + // Share known reports with current site, if it's our home + // TODO: Only share new reports + if let Some(current_site) = npc.current_site + && Some(current_site) == npc.home + { + if let Some(site) = data.sites.get_mut(current_site) { + // TODO: Sites should have an inbox and their own AI code + site.known_reports.extend(npc.known_reports + .iter() + .copied()); + npc.inbox.extend(site.known_reports + .iter() + .copied() + .filter(|report| !npc.known_reports.contains(report))); + } + } + + // Update the NPC's grid cell + let chunk_pos = npc.wpos.xy().as_().wpos_to_cpos(); + if npc.chunk_pos != Some(chunk_pos) { + if let Some(cell) = npc + .chunk_pos + .and_then(|chunk_pos| data.npcs.npc_grid.get_mut(chunk_pos)) + { + if let Some(index) = cell.npcs.iter().position(|id| *id == npc_id) { + cell.npcs.swap_remove(index); + } + } + npc.chunk_pos = Some(chunk_pos); + if let Some(cell) = data.npcs.npc_grid.get_mut(chunk_pos) { + cell.npcs.push(npc_id); + } + } + } +} diff --git a/server/Cargo.toml b/server/Cargo.toml index 0cc75a54e1..54347b0473 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -23,6 +23,7 @@ common-state = { package = "veloren-common-state", path = "../common/state" } common-systems = { package = "veloren-common-systems", path = "../common/systems" } common-net = { package = "veloren-common-net", path = "../common/net" } world = { package = "veloren-world", path = "../world" } +rtsim = { package = "veloren-rtsim", path = "../rtsim" } network = { package = "veloren-network", path = "../network", features = ["metrics", "compression", "quic"], default-features = false } server-agent = {package = "veloren-server-agent", path = "agent"} @@ -63,6 +64,7 @@ authc = { git = "https://gitlab.com/veloren/auth.git", rev = "fb3dcbc4962b367253 slab = "0.4" rand_distr = "0.4.0" enumset = "1.0.8" +enum-map = "2.4" noise = { version = "0.7", default-features = false } censor = "0.2" diff --git a/server/agent/Cargo.toml b/server/agent/Cargo.toml index 9b2ee49c14..1c617fc1e6 100644 --- a/server/agent/Cargo.toml +++ b/server/agent/Cargo.toml @@ -9,10 +9,12 @@ use-dyn-lib = ["common-dynlib"] be-dyn-lib = [] [dependencies] -common = {package = "veloren-common", path = "../../common"} +common = { package = "veloren-common", path = "../../common"} common-base = { package = "veloren-common-base", path = "../../common/base" } +common-net = { package = "veloren-common-net", path = "../../common/net" } common-ecs = { package = "veloren-common-ecs", path = "../../common/ecs" } -common-dynlib = {package = "veloren-common-dynlib", path = "../../common/dynlib", optional = true} +common-dynlib = { package = "veloren-common-dynlib", path = "../../common/dynlib", optional = true} +rtsim = { package = "veloren-rtsim", path = "../../rtsim" } specs = { version = "0.18", features = ["shred-derive"] } vek = { version = "0.15.8", features = ["serde"] } diff --git a/server/agent/src/action_nodes.rs b/server/agent/src/action_nodes.rs index cc590e5879..a5e8c66e7b 100644 --- a/server/agent/src/action_nodes.rs +++ b/server/agent/src/action_nodes.rs @@ -22,12 +22,13 @@ use common::{ }, item_drop, projectile::ProjectileConstructor, - Agent, Alignment, Body, CharacterState, ControlAction, ControlEvent, Controller, - HealthChange, InputKind, InventoryAction, Pos, UnresolvedChatMsg, UtteranceKind, + Agent, Alignment, Body, CharacterState, Content, ControlAction, ControlEvent, Controller, + HealthChange, InputKind, InventoryAction, Pos, Scale, UnresolvedChatMsg, UtteranceKind, }, effect::{BuffEffect, Effect}, event::{Emitter, ServerEvent}, path::TraversalConfig, + rtsim::NpcActivity, states::basic_beam, terrain::{Block, TerrainGrid}, time::DayPeriod, @@ -160,6 +161,7 @@ impl<'a> AgentData<'a> { agent: &mut Agent, controller: &mut Controller, read_data: &ReadData, + event_emitter: &mut Emitter, rng: &mut impl Rng, ) { enum ActionTimers { @@ -212,123 +214,161 @@ impl<'a> AgentData<'a> { } agent.action_state.timers[ActionTimers::TimerIdle as usize] = 0.0; - if let Some((travel_to, _destination)) = &agent.rtsim_controller.travel_to { - // If it has an rtsim destination and can fly, then it should. - // If it is flying and bumps something above it, then it should move down. - if self.traversal_config.can_fly - && !read_data - .terrain - .ray(self.pos.0, self.pos.0 + (Vec3::unit_z() * 3.0)) - .until(Block::is_solid) - .cast() - .1 - .map_or(true, |b| b.is_some()) - { - controller.push_basic_input(InputKind::Fly); - } else { - controller.push_cancel_input(InputKind::Fly) - } - - if let Some((bearing, speed)) = agent.chaser.chase( - &*read_data.terrain, - self.pos.0, - self.vel.0, - *travel_to, - TraversalConfig { - min_tgt_dist: 1.25, - ..self.traversal_config - }, - ) { - controller.inputs.move_dir = - bearing.xy().try_normalized().unwrap_or_else(Vec2::zero) - * speed.min(agent.rtsim_controller.speed_factor); - self.jump_if(bearing.z > 1.5 || self.traversal_config.can_fly, controller); - controller.inputs.climb = Some(comp::Climb::Up); - //.filter(|_| bearing.z > 0.1 || self.physics_state.in_liquid().is_some()); - - let height_offset = bearing.z - + if self.traversal_config.can_fly { - // NOTE: costs 4 us (imbris) - let obstacle_ahead = read_data + 'activity: { + match agent.rtsim_controller.activity { + Some(NpcActivity::Goto(travel_to, speed_factor)) => { + // If it has an rtsim destination and can fly, then it should. + // If it is flying and bumps something above it, then it should move down. + if self.traversal_config.can_fly + && !read_data .terrain - .ray( - self.pos.0 + Vec3::unit_z(), - self.pos.0 - + bearing.try_normalized().unwrap_or_else(Vec3::unit_y) * 80.0 - + Vec3::unit_z(), - ) + .ray(self.pos.0, self.pos.0 + (Vec3::unit_z() * 3.0)) .until(Block::is_solid) .cast() .1 - .map_or(true, |b| b.is_some()); + .map_or(true, |b| b.is_some()) + { + controller.push_basic_input(InputKind::Fly); + } else { + controller.push_cancel_input(InputKind::Fly) + } - let mut ground_too_close = self - .body - .map(|body| { - #[cfg(feature = "worldgen")] - let height_approx = self.pos.0.z - - read_data - .world - .sim() - .get_alt_approx(self.pos.0.xy().map(|x: f32| x as i32)) - .unwrap_or(0.0); - #[cfg(not(feature = "worldgen"))] - let height_approx = self.pos.0.z; + let chase_tgt = if self.traversal_config.can_fly { + read_data.terrain.try_find_space(travel_to.as_()) + } else { + read_data.terrain.try_find_ground(travel_to.as_()) + } + .map(|pos| pos.as_()) + .unwrap_or(travel_to); - height_approx < body.flying_height() - }) - .unwrap_or(false); + if let Some((bearing, speed)) = agent.chaser.chase( + &*read_data.terrain, + self.pos.0, + self.vel.0, + chase_tgt, + TraversalConfig { + min_tgt_dist: 1.25, + ..self.traversal_config + }, + ) { + controller.inputs.move_dir = + bearing.xy().try_normalized().unwrap_or_else(Vec2::zero) + * speed.min(speed_factor); + self.jump_if(bearing.z > 1.5 || self.traversal_config.can_fly, controller); + controller.inputs.climb = Some(comp::Climb::Up); + //.filter(|_| bearing.z > 0.1 || self.physics_state.in_liquid().is_some()); - const NUM_RAYS: usize = 5; - - // NOTE: costs 15-20 us (imbris) - for i in 0..=NUM_RAYS { - let magnitude = self.body.map_or(20.0, |b| b.flying_height()); - // Lerp between a line straight ahead and straight down to detect a - // wedge of obstacles we might fly into (inclusive so that both vectors - // are sampled) - if let Some(dir) = Lerp::lerp( - -Vec3::unit_z(), - Vec3::new(bearing.x, bearing.y, 0.0), - i as f32 / NUM_RAYS as f32, - ) - .try_normalized() - { - ground_too_close |= read_data + let height_offset = bearing.z + + if self.traversal_config.can_fly { + // NOTE: costs 4 us (imbris) + let obstacle_ahead = read_data .terrain - .ray(self.pos.0, self.pos.0 + magnitude * dir) - .until(|b: &Block| b.is_solid() || b.is_liquid()) + .ray( + self.pos.0 + Vec3::unit_z(), + self.pos.0 + + bearing.try_normalized().unwrap_or_else(Vec3::unit_y) + * 80.0 + + Vec3::unit_z(), + ) + .until(Block::is_solid) .cast() .1 - .map_or(false, |b| b.is_some()) - } - } + .map_or(true, |b| b.is_some()); - if obstacle_ahead || ground_too_close { - 5.0 //fly up when approaching obstacles + let mut ground_too_close = self + .body + .map(|body| { + #[cfg(feature = "worldgen")] + let height_approx = self.pos.0.z + - read_data + .world + .sim() + .get_alt_approx( + self.pos.0.xy().map(|x: f32| x as i32), + ) + .unwrap_or(0.0); + #[cfg(not(feature = "worldgen"))] + let height_approx = self.pos.0.z; + + height_approx < body.flying_height() + }) + .unwrap_or(false); + + const NUM_RAYS: usize = 5; + + // NOTE: costs 15-20 us (imbris) + for i in 0..=NUM_RAYS { + let magnitude = self.body.map_or(20.0, |b| b.flying_height()); + // Lerp between a line straight ahead and straight down to + // detect a + // wedge of obstacles we might fly into (inclusive so that both + // vectors are sampled) + if let Some(dir) = Lerp::lerp( + -Vec3::unit_z(), + Vec3::new(bearing.x, bearing.y, 0.0), + i as f32 / NUM_RAYS as f32, + ) + .try_normalized() + { + ground_too_close |= read_data + .terrain + .ray(self.pos.0, self.pos.0 + magnitude * dir) + .until(|b: &Block| b.is_solid() || b.is_liquid()) + .cast() + .1 + .map_or(false, |b| b.is_some()) + } + } + + if obstacle_ahead || ground_too_close { + 5.0 //fly up when approaching obstacles + } else { + -2.0 + } //flying things should slowly come down from the stratosphere + } else { + 0.05 //normal land traveller offset + }; + if let Some(pid) = agent.position_pid_controller.as_mut() { + pid.sp = self.pos.0.z + height_offset * Vec3::unit_z(); + controller.inputs.move_z = pid.calc_err(); } else { - -2.0 - } //flying things should slowly come down from the stratosphere - } else { - 0.05 //normal land traveller offset - }; - if let Some(pid) = agent.position_pid_controller.as_mut() { - pid.sp = self.pos.0.z + height_offset * Vec3::unit_z(); - controller.inputs.move_z = pid.calc_err(); - } else { - controller.inputs.move_z = height_offset; - } - // Put away weapon - if rng.gen_bool(0.1) - && matches!( - read_data.char_states.get(*self.entity), - Some(CharacterState::Wielding(_)) - ) - { - controller.push_action(ControlAction::Unwield); - } + controller.inputs.move_z = height_offset; + } + // Put away weapon + if rng.gen_bool(0.1) + && matches!( + read_data.char_states.get(*self.entity), + Some(CharacterState::Wielding(_)) + ) + { + controller.push_action(ControlAction::Unwield); + } + } + break 'activity; // Don't fall through to idle wandering + }, + Some(NpcActivity::Gather(_resources)) => { + // TODO: Implement + controller.push_action(ControlAction::Dance); + break 'activity; // Don't fall through to idle wandering + }, + Some(NpcActivity::Dance) => { + controller.push_action(ControlAction::Dance); + break 'activity; // Don't fall through to idle wandering + }, + Some(NpcActivity::HuntAnimals) => { + if rng.gen::() < 0.1 { + self.choose_target( + agent, + controller, + read_data, + event_emitter, + AgentData::is_hunting_animal, + ); + } + }, + None => {}, } - } else { + // Bats should fly // Use a proportional controller as the bouncing effect mimics bat flight if self.traversal_config.can_fly @@ -476,8 +516,10 @@ impl<'a> AgentData<'a> { target: EcsEntity, ) -> bool { if let Some(tgt_pos) = read_data.positions.get(target) { - let eye_offset = self.body.map_or(0.0, |b| b.eye_height()); - let tgt_eye_offset = read_data.bodies.get(target).map_or(0.0, |b| b.eye_height()); + let eye_offset = self.body.map_or(0.0, |b| b.eye_height(self.scale)); + let tgt_eye_offset = read_data.bodies.get(target).map_or(0.0, |b| { + b.eye_height(read_data.scales.get(target).map_or(1.0, |s| s.0)) + }); if let Some(dir) = Dir::from_unnormalized( Vec3::new(tgt_pos.0.x, tgt_pos.0.y, tgt_pos.0.z + tgt_eye_offset) - Vec3::new(self.pos.0.x, self.pos.0.y, self.pos.0.z + eye_offset), @@ -645,7 +687,7 @@ impl<'a> AgentData<'a> { controller: &mut Controller, read_data: &ReadData, event_emitter: &mut Emitter, - will_ambush: bool, + is_enemy: fn(&Self, EcsEntity, &ReadData) -> bool, ) { enum ActionStateTimers { TimerChooseTarget = 0, @@ -668,7 +710,7 @@ impl<'a> AgentData<'a> { .get(entity) .map_or(false, |eu| eu != self.uid) }; - if will_ambush + if agent.rtsim_controller.personality.will_ambush() && self_different_from_entity() && !self.passive_towards(entity, read_data) { @@ -686,12 +728,12 @@ impl<'a> AgentData<'a> { let get_pos = |entity| read_data.positions.get(entity); let get_enemy = |(entity, attack_target): (EcsEntity, bool)| { if attack_target { - if self.is_enemy(entity, read_data) { + if is_enemy(self, entity, read_data) { Some((entity, true)) } else if can_ambush(entity, read_data) { controller.clone().push_utterance(UtteranceKind::Ambush); self.chat_npc_if_allowed_to_speak( - "npc-speech-ambush".to_string(), + Content::localized("npc-speech-ambush"), agent, event_emitter, ); @@ -756,8 +798,8 @@ impl<'a> AgentData<'a> { }, }; - let is_detected = |entity: &EcsEntity, e_pos: &Pos| { - self.detects_other(agent, controller, entity, e_pos, read_data) + let is_detected = |entity: &EcsEntity, e_pos: &Pos, e_scale: Option<&Scale>| { + self.detects_other(agent, controller, entity, e_pos, e_scale, read_data) }; let target = entities_nearby @@ -767,7 +809,7 @@ impl<'a> AgentData<'a> { .filter_map(|(entity, attack_target)| { get_pos(entity).map(|pos| (entity, pos, attack_target)) }) - .filter(|(entity, e_pos, _)| is_detected(entity, e_pos)) + .filter(|(entity, e_pos, _)| is_detected(entity, e_pos, read_data.scales.get(*entity))) .min_by_key(|(_, e_pos, attack_target)| { ( *attack_target, @@ -959,9 +1001,11 @@ impl<'a> AgentData<'a> { .angle_between((tgt_data.pos.0 - self.pos.0).xy()) .to_degrees(); - let eye_offset = self.body.map_or(0.0, |b| b.eye_height()); + let eye_offset = self.body.map_or(0.0, |b| b.eye_height(self.scale)); - let tgt_eye_height = tgt_data.body.map_or(0.0, |b| b.eye_height()); + let tgt_eye_height = tgt_data + .body + .map_or(0.0, |b| b.eye_height(tgt_data.scale.map_or(1.0, |s| s.0))); let tgt_eye_offset = tgt_eye_height + // Special case for jumping attacks to jump at the body // of the target and not the ground around the target @@ -999,7 +1043,7 @@ impl<'a> AgentData<'a> { projectile_speed, self.pos.0 + self.body.map_or(Vec3::zero(), |body| { - body.projectile_offsets(self.ori.look_vec()) + body.projectile_offsets(self.ori.look_vec(), self.scale) }), Vec3::new( tgt_data.pos.0.x, @@ -1024,7 +1068,7 @@ impl<'a> AgentData<'a> { projectile_speed, self.pos.0 + self.body.map_or(Vec3::zero(), |body| { - body.projectile_offsets(self.ori.look_vec()) + body.projectile_offsets(self.ori.look_vec(), self.scale) }), Vec3::new( tgt_data.pos.0.x, @@ -1039,7 +1083,7 @@ impl<'a> AgentData<'a> { projectile_speed, self.pos.0 + self.body.map_or(Vec3::zero(), |body| { - body.projectile_offsets(self.ori.look_vec()) + body.projectile_offsets(self.ori.look_vec(), self.scale) }), Vec3::new( tgt_data.pos.0.x, @@ -1371,12 +1415,13 @@ impl<'a> AgentData<'a> { agent: &mut Agent, controller: &mut Controller, read_data: &ReadData, + event_emitter: &mut Emitter, rng: &mut impl Rng, ) { agent.forget_old_sounds(read_data.time.0); if is_invulnerable(*self.entity, read_data) { - self.idle(agent, controller, read_data, rng); + self.idle(agent, controller, read_data, event_emitter, rng); return; } @@ -1409,13 +1454,13 @@ impl<'a> AgentData<'a> { } else if self.below_flee_health(agent) || !follows_threatening_sounds { self.flee(agent, controller, &sound_pos, &read_data.terrain); } else { - self.idle(agent, controller, read_data, rng); + self.idle(agent, controller, read_data, event_emitter, rng); } } else { - self.idle(agent, controller, read_data, rng); + self.idle(agent, controller, read_data, event_emitter, rng); } } else { - self.idle(agent, controller, read_data, rng); + self.idle(agent, controller, read_data, event_emitter, rng); } } @@ -1424,6 +1469,7 @@ impl<'a> AgentData<'a> { agent: &mut Agent, read_data: &ReadData, controller: &mut Controller, + event_emitter: &mut Emitter, rng: &mut impl Rng, ) { if let Some(Target { target, .. }) = agent.target { @@ -1453,14 +1499,15 @@ impl<'a> AgentData<'a> { Some(tgt_pos.0), )); - self.idle(agent, controller, read_data, rng); + self.idle(agent, controller, read_data, event_emitter, rng); } else { let target_data = TargetData::new(tgt_pos, target, read_data); - if let Some(tgt_name) = - read_data.stats.get(target).map(|stats| stats.name.clone()) - { - agent.add_fight_to_memory(&tgt_name, read_data.time.0) - } + // TODO: Reimplement this in rtsim + // if let Some(tgt_name) = + // read_data.stats.get(target).map(|stats| stats.name.clone()) + // { + // agent.add_fight_to_memory(&tgt_name, read_data.time.0) + // } self.attack(agent, controller, &target_data, read_data, rng); } } @@ -1470,9 +1517,11 @@ impl<'a> AgentData<'a> { } } + // TODO: Pass a localisation key instead of `Content` to avoid allocating if + // we're not permitted to speak. pub fn chat_npc_if_allowed_to_speak( &self, - msg: impl ToString, + msg: Content, agent: &Agent, event_emitter: &mut Emitter<'_, ServerEvent>, ) -> bool { @@ -1484,10 +1533,9 @@ impl<'a> AgentData<'a> { } } - pub fn chat_npc(&self, msg: impl ToString, event_emitter: &mut Emitter<'_, ServerEvent>) { + pub fn chat_npc(&self, content: Content, event_emitter: &mut Emitter<'_, ServerEvent>) { event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc( - *self.uid, - msg.to_string(), + *self.uid, content, ))); } @@ -1516,13 +1564,13 @@ impl<'a> AgentData<'a> { // FIXME: If going to use "cultist + low health + fleeing" string, make sure // they are each true. self.chat_npc_if_allowed_to_speak( - "npc-speech-cultist_low_health_fleeing", + Content::localized("npc-speech-cultist_low_health_fleeing"), agent, event_emitter, ); } else if is_villager(self.alignment) { self.chat_npc_if_allowed_to_speak( - "npc-speech-villager_under_attack", + Content::localized("npc-speech-villager_under_attack"), agent, event_emitter, ); @@ -1537,7 +1585,7 @@ impl<'a> AgentData<'a> { ) { if is_villager(self.alignment) { self.chat_npc_if_allowed_to_speak( - "npc-speech-villager_enemy_killed", + Content::localized("npc-speech-villager_enemy_killed"), agent, event_emitter, ); @@ -1580,7 +1628,7 @@ impl<'a> AgentData<'a> { }) } - fn is_enemy(&self, entity: EcsEntity, read_data: &ReadData) -> bool { + pub fn is_enemy(&self, entity: EcsEntity, read_data: &ReadData) -> bool { let other_alignment = read_data.alignments.get(entity); (entity != *self.entity) @@ -1589,6 +1637,12 @@ impl<'a> AgentData<'a> { || (is_villager(self.alignment) && is_dressed_as_cultist(entity, read_data))) } + pub fn is_hunting_animal(&self, entity: EcsEntity, read_data: &ReadData) -> bool { + (entity != *self.entity) + && !self.friendly_towards(entity, read_data) + && matches!(read_data.bodies.get(entity), Some(Body::QuadrupedSmall(_))) + } + fn should_defend(&self, entity: EcsEntity, read_data: &ReadData) -> bool { let entity_alignment = read_data.alignments.get(entity); @@ -1621,12 +1675,23 @@ impl<'a> AgentData<'a> { } } + fn friendly_towards(&self, entity: EcsEntity, read_data: &ReadData) -> bool { + if let (Some(self_alignment), Some(other_alignment)) = + (self.alignment, read_data.alignments.get(entity)) + { + self_alignment.friendly_towards(*other_alignment) + } else { + false + } + } + pub fn can_see_entity( &self, agent: &Agent, controller: &Controller, other: EcsEntity, other_pos: &Pos, + other_scale: Option<&Scale>, read_data: &ReadData, ) -> bool { let other_stealth_multiplier = { @@ -1651,7 +1716,15 @@ impl<'a> AgentData<'a> { (within_sight_dist) && within_fov - && entities_have_line_of_sight(self.pos, self.body, other_pos, other_body, read_data) + && entities_have_line_of_sight( + self.pos, + self.body, + self.scale, + other_pos, + other_body, + other_scale, + read_data, + ) } pub fn detects_other( @@ -1660,10 +1733,11 @@ impl<'a> AgentData<'a> { controller: &Controller, other: &EcsEntity, other_pos: &Pos, + other_scale: Option<&Scale>, read_data: &ReadData, ) -> bool { self.can_sense_directly_near(other_pos) - || self.can_see_entity(agent, controller, *other, other_pos, read_data) + || self.can_see_entity(agent, controller, *other, other_pos, other_scale, read_data) } pub fn can_sense_directly_near(&self, e_pos: &Pos) -> bool { @@ -1685,16 +1759,22 @@ impl<'a> AgentData<'a> { let move_dir = controller.inputs.move_dir; let move_dir_mag = move_dir.magnitude(); let small_chance = rng.gen::() < read_data.dt.0 * 0.25; - let mut chat = |msg: &str| { - self.chat_npc_if_allowed_to_speak(msg.to_string(), agent, event_emitter); + let mut chat = |content: Content| { + self.chat_npc_if_allowed_to_speak(content, agent, event_emitter); }; let mut chat_villager_remembers_fighting = || { let tgt_name = read_data.stats.get(target).map(|stats| stats.name.clone()); + // TODO: Localise if let Some(tgt_name) = tgt_name { - chat(format!("{}! How dare you cross me again!", &tgt_name).as_str()); + chat(Content::Plain(format!( + "{}! How dare you cross me again!", + &tgt_name + ))); } else { - chat("You! How dare you cross me again!"); + chat(Content::Plain( + "You! How dare you cross me again!".to_string(), + )); } }; @@ -1711,12 +1791,12 @@ impl<'a> AgentData<'a> { if remembers_fight_with_target { chat_villager_remembers_fighting(); } else if is_dressed_as_cultist(target, read_data) { - chat("npc-speech-villager_cultist_alarm"); + chat(Content::localized("npc-speech-villager_cultist_alarm")); } else { - chat("npc-speech-menacing"); + chat(Content::localized("npc-speech-menacing")); } } else { - chat("npc-speech-menacing"); + chat(Content::localized("npc-speech-menacing")); } } } diff --git a/server/agent/src/attack.rs b/server/agent/src/attack.rs index 9d5151edb6..85e4a16010 100644 --- a/server/agent/src/attack.rs +++ b/server/agent/src/attack.rs @@ -247,7 +247,15 @@ impl<'a> AgentData<'a> { read_data: &ReadData, ) { let line_of_sight_with_target = || { - entities_have_line_of_sight(self.pos, self.body, tgt_data.pos, tgt_data.body, read_data) + entities_have_line_of_sight( + self.pos, + self.body, + self.scale, + tgt_data.pos, + tgt_data.body, + tgt_data.scale, + read_data, + ) }; let elevation = self.pos.0.z - tgt_data.pos.0.z; @@ -374,8 +382,10 @@ impl<'a> AgentData<'a> { && entities_have_line_of_sight( self.pos, self.body, + self.scale, tgt_data.pos, tgt_data.body, + tgt_data.scale, read_data, ) { @@ -451,8 +461,10 @@ impl<'a> AgentData<'a> { && entities_have_line_of_sight( self.pos, self.body, + self.scale, tgt_data.pos, tgt_data.body, + tgt_data.scale, read_data, ) { @@ -1269,7 +1281,15 @@ impl<'a> AgentData<'a> { const DESIRED_ENERGY_LEVEL: f32 = 50.0; let line_of_sight_with_target = || { - entities_have_line_of_sight(self.pos, self.body, tgt_data.pos, tgt_data.body, read_data) + entities_have_line_of_sight( + self.pos, + self.body, + self.scale, + tgt_data.pos, + tgt_data.body, + tgt_data.scale, + read_data, + ) }; // Logic to use abilities @@ -1521,8 +1541,10 @@ impl<'a> AgentData<'a> { if entities_have_line_of_sight( self.pos, self.body, + self.scale, tgt_data.pos, tgt_data.body, + tgt_data.scale, read_data, ) && attack_data.angle < 45.0 { @@ -1574,7 +1596,15 @@ impl<'a> AgentData<'a> { const DESIRED_COMBO_LEVEL: u32 = 8; let line_of_sight_with_target = || { - entities_have_line_of_sight(self.pos, self.body, tgt_data.pos, tgt_data.body, read_data) + entities_have_line_of_sight( + self.pos, + self.body, + self.scale, + tgt_data.pos, + tgt_data.body, + tgt_data.scale, + read_data, + ) }; // Logic to use abilities @@ -1724,8 +1754,10 @@ impl<'a> AgentData<'a> { ) && entities_have_line_of_sight( self.pos, self.body, + self.scale, tgt_data.pos, tgt_data.body, + tgt_data.scale, read_data, ) && attack_data.angle < 90.0 { @@ -1907,8 +1939,10 @@ impl<'a> AgentData<'a> { && entities_have_line_of_sight( self.pos, self.body, + self.scale, tgt_data.pos, tgt_data.body, + tgt_data.scale, read_data, ) { @@ -2130,8 +2164,10 @@ impl<'a> AgentData<'a> { && entities_have_line_of_sight( self.pos, self.body, + self.scale, tgt_data.pos, tgt_data.body, + tgt_data.scale, read_data, ) { @@ -2313,9 +2349,7 @@ impl<'a> AgentData<'a> { { agent.action_state.timers[ActionStateTimers::TimerOrganAura as usize] = 0.0; } else if agent.action_state.timers[ActionStateTimers::TimerOrganAura as usize] < 1.0 { - controller - .actions - .push(ControlAction::basic_input(InputKind::Primary)); + controller.push_basic_input(InputKind::Primary); agent.action_state.timers[ActionStateTimers::TimerOrganAura as usize] += read_data.dt.0; } else { @@ -2367,8 +2401,15 @@ impl<'a> AgentData<'a> { tgt_data: &TargetData, read_data: &ReadData, ) { - if entities_have_line_of_sight(self.pos, self.body, tgt_data.pos, tgt_data.body, read_data) - && attack_data.angle < 15.0 + if entities_have_line_of_sight( + self.pos, + self.body, + self.scale, + tgt_data.pos, + tgt_data.body, + tgt_data.scale, + read_data, + ) && attack_data.angle < 15.0 { controller.push_basic_input(InputKind::Primary); } else { @@ -2385,8 +2426,15 @@ impl<'a> AgentData<'a> { read_data: &ReadData, ) { controller.inputs.look_dir = self.ori.look_dir(); - if entities_have_line_of_sight(self.pos, self.body, tgt_data.pos, tgt_data.body, read_data) - && attack_data.angle < 15.0 + if entities_have_line_of_sight( + self.pos, + self.body, + self.scale, + tgt_data.pos, + tgt_data.body, + tgt_data.scale, + read_data, + ) && attack_data.angle < 15.0 { controller.push_basic_input(InputKind::Primary); } else { @@ -2408,8 +2456,15 @@ impl<'a> AgentData<'a> { .try_normalized() .unwrap_or_default(), ); - if entities_have_line_of_sight(self.pos, self.body, tgt_data.pos, tgt_data.body, read_data) - { + if entities_have_line_of_sight( + self.pos, + self.body, + self.scale, + tgt_data.pos, + tgt_data.body, + tgt_data.scale, + read_data, + ) { controller.push_basic_input(InputKind::Primary); } else { agent.target = None; @@ -2469,8 +2524,10 @@ impl<'a> AgentData<'a> { if entities_have_line_of_sight( self.pos, self.body, + self.scale, tgt_data.pos, tgt_data.body, + tgt_data.scale, read_data, ) { // If close to target, use either primary or secondary ability @@ -2566,8 +2623,10 @@ impl<'a> AgentData<'a> { && entities_have_line_of_sight( self.pos, self.body, + self.scale, tgt_data.pos, tgt_data.body, + tgt_data.scale, read_data, ) && attack_data.angle < 15.0 @@ -2697,8 +2756,10 @@ impl<'a> AgentData<'a> { && entities_have_line_of_sight( self.pos, self.body, + self.scale, tgt_data.pos, tgt_data.body, + tgt_data.scale, read_data, ) && attack_data.angle < 15.0 @@ -2933,8 +2994,10 @@ impl<'a> AgentData<'a> { && entities_have_line_of_sight( self.pos, self.body, + self.scale, tgt_data.pos, tgt_data.body, + tgt_data.scale, read_data, ) { @@ -3188,7 +3251,15 @@ impl<'a> AgentData<'a> { .and_then(|e| read_data.velocities.get(e)) .map_or(0.0, |v| v.0.cross(self.ori.look_vec()).magnitude_squared()); let line_of_sight_with_target = || { - entities_have_line_of_sight(self.pos, self.body, tgt_data.pos, tgt_data.body, read_data) + entities_have_line_of_sight( + self.pos, + self.body, + self.scale, + tgt_data.pos, + tgt_data.body, + tgt_data.scale, + read_data, + ) }; if attack_data.dist_sqrd < golem_melee_range.powi(2) { @@ -3265,7 +3336,15 @@ impl<'a> AgentData<'a> { let health_fraction = self.health.map_or(0.5, |h| h.fraction()); let line_of_sight_with_target = || { - entities_have_line_of_sight(self.pos, self.body, tgt_data.pos, tgt_data.body, read_data) + entities_have_line_of_sight( + self.pos, + self.body, + self.scale, + tgt_data.pos, + tgt_data.body, + tgt_data.scale, + read_data, + ) }; // Sets counter at start of combat, using `condition` to keep track of whether @@ -3463,7 +3542,15 @@ impl<'a> AgentData<'a> { let health_fraction = self.health.map_or(0.5, |h| h.fraction()); let line_of_sight_with_target = || { - entities_have_line_of_sight(self.pos, self.body, tgt_data.pos, tgt_data.body, read_data) + entities_have_line_of_sight( + self.pos, + self.body, + self.scale, + tgt_data.pos, + tgt_data.body, + tgt_data.scale, + read_data, + ) }; if health_fraction < VINE_CREATION_THRESHOLD @@ -3532,7 +3619,15 @@ impl<'a> AgentData<'a> { } let line_of_sight_with_target = || { - entities_have_line_of_sight(self.pos, self.body, tgt_data.pos, tgt_data.body, read_data) + entities_have_line_of_sight( + self.pos, + self.body, + self.scale, + tgt_data.pos, + tgt_data.body, + tgt_data.scale, + read_data, + ) }; let health_fraction = self.health.map_or(0.5, |h| h.fraction()); // Sets counter at start of combat, using `condition` to keep track of whether @@ -3673,8 +3768,10 @@ impl<'a> AgentData<'a> { && entities_have_line_of_sight( self.pos, self.body, + self.scale, tgt_data.pos, tgt_data.body, + tgt_data.scale, read_data, ) { @@ -3740,8 +3837,10 @@ impl<'a> AgentData<'a> { && entities_have_line_of_sight( self.pos, self.body, + self.scale, tgt_data.pos, tgt_data.body, + tgt_data.scale, read_data, ) { @@ -3824,8 +3923,10 @@ impl<'a> AgentData<'a> { if entities_have_line_of_sight( self.pos, self.body, + self.scale, tgt_data.pos, tgt_data.body, + tgt_data.scale, read_data, ) && attack_data.angle < 45.0 { @@ -3903,8 +4004,10 @@ impl<'a> AgentData<'a> { } else if entities_have_line_of_sight( self.pos, self.body, + self.scale, tgt_data.pos, tgt_data.body, + tgt_data.scale, read_data, ) { // if enemy in mid range shoot dagon bombs and steamwave @@ -4018,8 +4121,10 @@ impl<'a> AgentData<'a> { && entities_have_line_of_sight( self.pos, self.body, + self.scale, tgt_data.pos, tgt_data.body, + tgt_data.scale, read_data, ) { @@ -4179,8 +4284,10 @@ impl<'a> AgentData<'a> { } else if entities_have_line_of_sight( self.pos, self.body, + self.scale, tgt_data.pos, tgt_data.body, + tgt_data.scale, read_data, ) { // Else if in sight, barrage @@ -4243,8 +4350,10 @@ impl<'a> AgentData<'a> { && entities_have_line_of_sight( self.pos, self.body, + self.scale, tgt_data.pos, tgt_data.body, + tgt_data.scale, read_data, ) && agent.action_state.timers[DASH_TIMER] > 4.0 diff --git a/server/agent/src/data.rs b/server/agent/src/data.rs index 0443413009..6ca3b9c9ce 100644 --- a/server/agent/src/data.rs +++ b/server/agent/src/data.rs @@ -6,21 +6,21 @@ use common::{ group, item::MaterialStatManifest, ActiveAbilities, Alignment, Body, CharacterState, Combo, Energy, Health, Inventory, - LightEmitter, LootOwner, Ori, PhysicsState, Poise, Pos, Scale, SkillSet, Stance, Stats, - Vel, + LightEmitter, LootOwner, Ori, PhysicsState, Poise, Pos, Presence, PresenceKind, Scale, + SkillSet, Stance, Stats, Vel, }, link::Is, - mounting::Mount, + mounting::{Mount, Rider}, path::TraversalConfig, resources::{DeltaTime, Time, TimeOfDay}, - rtsim::RtSimEntity, + rtsim::{Actor, RtSimEntity}, states::utils::{ForcedMovement, StageSection}, terrain::TerrainGrid, uid::{Uid, UidAllocator}, }; use specs::{ - shred::ResourceId, Entities, Entity as EcsEntity, Read, ReadExpect, ReadStorage, SystemData, - World, + shred::ResourceId, Entities, Entity as EcsEntity, Join, Read, ReadExpect, ReadStorage, + SystemData, World, }; // TODO: Move rtsim back into AgentData after rtsim2 when it has a separate @@ -54,6 +54,7 @@ pub struct AgentData<'a> { pub stance: Option<&'a Stance>, pub cached_spatial_grid: &'a common::CachedSpatialGrid, pub msm: &'a MaterialStatManifest, + pub rtsim_entity: Option<&'a RtSimEntity>, } pub struct TargetData<'a> { @@ -232,6 +233,7 @@ pub struct ReadData<'a> { pub alignments: ReadStorage<'a, Alignment>, pub bodies: ReadStorage<'a, Body>, pub is_mounts: ReadStorage<'a, Is>, + pub is_riders: ReadStorage<'a, Is>, pub time_of_day: Read<'a, TimeOfDay>, pub light_emitter: ReadStorage<'a, LightEmitter>, #[cfg(feature = "worldgen")] @@ -244,6 +246,25 @@ pub struct ReadData<'a> { pub msm: ReadExpect<'a, MaterialStatManifest>, pub poises: ReadStorage<'a, Poise>, pub stances: ReadStorage<'a, Stance>, + pub presences: ReadStorage<'a, Presence>, +} + +impl<'a> ReadData<'a> { + pub fn lookup_actor(&self, actor: Actor) -> Option { + // TODO: We really shouldn't be doing a linear search here. The only saving + // grace is that the set of entities that fit each case should be + // *relatively* small. + match actor { + Actor::Character(character_id) => (&self.entities, &self.presences) + .join() + .find(|(_, p)| p.kind == PresenceKind::Character(character_id)) + .map(|(entity, _)| entity), + Actor::Npc(npc_id) => (&self.entities, &self.rtsim_entities) + .join() + .find(|(_, e)| e.0 == npc_id) + .map(|(entity, _)| entity), + } + } } pub enum Path { diff --git a/server/agent/src/util.rs b/server/agent/src/util.rs index 22ba3b8ba4..7dfe62b4e2 100644 --- a/server/agent/src/util.rs +++ b/server/agent/src/util.rs @@ -2,7 +2,7 @@ use crate::data::{ActionMode, AgentData, AttackData, Path, ReadData, TargetData} use common::{ comp::{ agent::Psyche, buff::BuffKind, inventory::item::ItemTag, item::ItemDesc, Agent, Alignment, - Body, Controller, InputKind, Pos, + Body, Controller, InputKind, Pos, Scale, }, consts::GRAVITY, terrain::Block, @@ -146,17 +146,19 @@ pub fn are_our_owners_hostile( pub fn entities_have_line_of_sight( pos: &Pos, body: Option<&Body>, + scale: f32, other_pos: &Pos, other_body: Option<&Body>, + other_scale: Option<&Scale>, read_data: &ReadData, ) -> bool { - let get_eye_pos = |pos: &Pos, body: Option<&Body>| { - let eye_offset = body.map_or(0.0, |b| b.eye_height()); + let get_eye_pos = |pos: &Pos, body: Option<&Body>, scale: f32| { + let eye_offset = body.map_or(0.0, |b| b.eye_height(scale)); Pos(pos.0.with_z(pos.0.z + eye_offset)) }; - let eye_pos = get_eye_pos(pos, body); - let other_eye_pos = get_eye_pos(other_pos, other_body); + let eye_pos = get_eye_pos(pos, body, scale); + let other_eye_pos = get_eye_pos(other_pos, other_body, other_scale.map_or(1.0, |s| s.0)); positions_have_line_of_sight(&eye_pos, &other_eye_pos, read_data) } diff --git a/server/src/chunk_generator.rs b/server/src/chunk_generator.rs index e5a28f3315..c76eb6b75e 100644 --- a/server/src/chunk_generator.rs +++ b/server/src/chunk_generator.rs @@ -1,6 +1,6 @@ -use crate::metrics::ChunkGenMetrics; #[cfg(not(feature = "worldgen"))] use crate::test_world::{IndexOwned, World}; +use crate::{metrics::ChunkGenMetrics, rtsim::RtSim}; use common::{ calendar::Calendar, generation::ChunkSupplement, resources::TimeOfDay, slowjob::SlowJobPool, terrain::TerrainChunk, @@ -44,6 +44,8 @@ impl ChunkGenerator { key: Vec2, slowjob_pool: &SlowJobPool, world: Arc, + #[cfg(feature = "worldgen")] rtsim: &RtSim, + #[cfg(not(feature = "worldgen"))] rtsim: &(), index: IndexOwned, time: (TimeOfDay, Calendar), ) { @@ -56,10 +58,17 @@ impl ChunkGenerator { v.insert(Arc::clone(&cancel)); let chunk_tx = self.chunk_tx.clone(); self.metrics.chunks_requested.inc(); + + // Get state for this chunk from rtsim + #[cfg(feature = "worldgen")] + let rtsim_resources = Some(rtsim.get_chunk_resources(key)); + #[cfg(not(feature = "worldgen"))] + let rtsim_resources = None; + slowjob_pool.spawn("CHUNK_GENERATOR", move || { let index = index.as_index_ref(); let payload = world - .generate_chunk(index, key, || cancel.load(Ordering::Relaxed), Some(time)) + .generate_chunk(index, key, rtsim_resources, || cancel.load(Ordering::Relaxed), Some(time)) // FIXME: Since only the first entity who cancels a chunk is notified, we end up // delaying chunk re-requests for up to 3 seconds for other clients, which isn't // great. We *could* store all the other requesting clients here, but it could diff --git a/server/src/cmd.rs b/server/src/cmd.rs index 9bbc73a86d..cb64723a05 100644 --- a/server/src/cmd.rs +++ b/server/src/cmd.rs @@ -5,7 +5,6 @@ use crate::{ client::Client, location::Locations, login_provider::LoginProvider, - presence::Presence, settings::{ Ban, BanAction, BanInfo, EditableSetting, SettingError, WhitelistInfo, WhitelistRecord, }, @@ -31,7 +30,7 @@ use common::{ buff::{Buff, BuffCategory, BuffData, BuffKind, BuffSource}, inventory::item::{tool::AbilityMap, MaterialStatManifest, Quality}, invite::InviteKind, - AdminRole, ChatType, Inventory, Item, LightEmitter, WaypointArea, + AdminRole, ChatType, Inventory, Item, LightEmitter, Presence, PresenceKind, WaypointArea, }, depot, effect::Effect, @@ -43,13 +42,14 @@ use common::{ outcome::Outcome, parse_cmd_args, resources::{BattleMode, PlayerPhysicsSettings, Secs, Time, TimeOfDay}, + rtsim::Actor, terrain::{Block, BlockKind, CoordinateConversions, SpriteKind, TerrainChunkSize}, uid::{Uid, UidAllocator}, vol::ReadVol, weather, Damage, DamageKind, DamageSource, Explosion, LoadoutBuilder, RadiusEffect, }; use common_net::{ - msg::{DisconnectReason, Notification, PlayerListUpdate, PresenceKind, ServerGeneral}, + msg::{DisconnectReason, Notification, PlayerListUpdate, ServerGeneral}, sync::WorldSyncExt, }; use common_state::{BuildAreaError, BuildAreas}; @@ -184,6 +184,11 @@ fn do_command( ServerChatCommand::Tell => handle_tell, ServerChatCommand::Time => handle_time, ServerChatCommand::Tp => handle_tp, + ServerChatCommand::RtsimTp => handle_rtsim_tp, + ServerChatCommand::RtsimInfo => handle_rtsim_info, + ServerChatCommand::RtsimNpc => handle_rtsim_npc, + ServerChatCommand::RtsimPurge => handle_rtsim_purge, + ServerChatCommand::RtsimChunk => handle_rtsim_chunk, ServerChatCommand::Unban => handle_unban, ServerChatCommand::Version => handle_version, ServerChatCommand::Waypoint => handle_waypoint, @@ -196,6 +201,7 @@ fn do_command( ServerChatCommand::DeleteLocation => handle_delete_location, ServerChatCommand::WeatherZone => handle_weather_zone, ServerChatCommand::Lightning => handle_lightning, + ServerChatCommand::Scale => handle_scale, }; handler(server, client, target, args, cmd) @@ -1181,6 +1187,241 @@ fn handle_tp( }) } +fn handle_rtsim_tp( + server: &mut Server, + _client: EcsEntity, + target: EcsEntity, + args: Vec, + action: &ServerChatCommand, +) -> CmdResult<()> { + use crate::rtsim::RtSim; + let pos = if let Some(id) = parse_cmd_args!(args, u32) { + // TODO: Take some other identifier than an integer to this command. + server + .state + .ecs() + .read_resource::() + .state() + .data() + .npcs + .values() + .nth(id as usize) + .ok_or(action.help_string())? + .wpos + } else { + return Err(action.help_string()); + }; + position_mut(server, target, "target", |target_pos| { + target_pos.0 = pos; + }) +} + +fn handle_rtsim_info( + server: &mut Server, + client: EcsEntity, + _target: EcsEntity, + args: Vec, + action: &ServerChatCommand, +) -> CmdResult<()> { + use crate::rtsim::RtSim; + if let Some(id) = parse_cmd_args!(args, u32) { + // TODO: Take some other identifier than an integer to this command. + let rtsim = server.state.ecs().read_resource::(); + let data = rtsim.state().data(); + let npc = data + .npcs + .values() + .nth(id as usize) + .ok_or_else(|| format!("No NPC has index {}", id))?; + + let mut info = String::new(); + + let _ = writeln!(&mut info, "-- General Information --"); + let _ = writeln!(&mut info, "Seed: {}", npc.seed); + let _ = writeln!(&mut info, "Profession: {:?}", npc.profession); + let _ = writeln!(&mut info, "Home: {:?}", npc.home); + let _ = writeln!(&mut info, "-- Status --"); + let _ = writeln!(&mut info, "Current site: {:?}", npc.current_site); + let _ = writeln!(&mut info, "Current mode: {:?}", npc.mode); + let _ = writeln!(&mut info, "-- Action State --"); + if let Some(brain) = &npc.brain { + let mut bt = Vec::new(); + brain.action.backtrace(&mut bt); + for (i, action) in bt.into_iter().enumerate() { + let _ = writeln!(&mut info, "[{}] {}", i, action); + } + } else { + let _ = writeln!(&mut info, ""); + } + + server.notify_client( + client, + ServerGeneral::server_msg(ChatType::CommandInfo, info), + ); + + Ok(()) + } else { + Err(action.help_string()) + } +} + +fn handle_rtsim_npc( + server: &mut Server, + client: EcsEntity, + target: EcsEntity, + args: Vec, + action: &ServerChatCommand, +) -> CmdResult<()> { + use crate::rtsim::RtSim; + if let (Some(query), count) = parse_cmd_args!(args, String, u32) { + let terms = query + .split(',') + .filter(|s| !s.is_empty()) + .map(|s| s.trim().to_lowercase()) + .collect::>(); + + let rtsim = server.state.ecs().read_resource::(); + let data = rtsim.state().data(); + let mut npcs = data + .npcs + .values() + .enumerate() + .filter(|(idx, npc)| { + let tags = [ + npc.profession + .as_ref() + .map(|p| format!("{:?}", p)) + .unwrap_or_default(), + format!("{:?}", npc.mode), + format!("{}", idx), + ]; + terms + .iter() + .all(|term| tags.iter().any(|tag| term.eq_ignore_ascii_case(tag.trim()))) + }) + .collect::>(); + if let Ok(pos) = position(server, target, "target") { + npcs.sort_by_key(|(_, npc)| (npc.wpos.distance_squared(pos.0) * 10.0) as u64); + } + + let mut info = String::new(); + + let _ = writeln!(&mut info, "-- NPCs matching [{}] --", terms.join(", ")); + for (idx, _) in npcs.iter().take(count.unwrap_or(!0) as usize) { + let _ = write!(&mut info, "{}, ", idx); + } + let _ = writeln!(&mut info); + let _ = writeln!( + &mut info, + "Showing {}/{} matching NPCs.", + count.unwrap_or(npcs.len() as u32), + npcs.len() + ); + + server.notify_client( + client, + ServerGeneral::server_msg(ChatType::CommandInfo, info), + ); + + Ok(()) + } else { + Err(action.help_string()) + } +} + +// TODO: Remove this command when rtsim becomes more mature and we're sure we +// don't need purges to fix broken state. +fn handle_rtsim_purge( + server: &mut Server, + client: EcsEntity, + _target: EcsEntity, + args: Vec, + action: &ServerChatCommand, +) -> CmdResult<()> { + use crate::rtsim::RtSim; + let client_uuid = uuid(server, client, "client")?; + if !matches!(real_role(server, client_uuid, "client")?, AdminRole::Admin) { + return Err( + "You must be a real admin (not just a temporary admin) to purge rtsim data." + .to_string(), + ); + } + + if let Some(should_purge) = parse_cmd_args!(args, bool) { + server + .state + .ecs() + .write_resource::() + .set_should_purge(should_purge); + server.notify_client( + client, + ServerGeneral::server_msg( + ChatType::CommandInfo, + format!( + "Rtsim data {} be purged on next startup", + if should_purge { "WILL" } else { "will NOT" }, + ), + ), + ); + Ok(()) + } else { + Err(action.help_string()) + } +} + +fn handle_rtsim_chunk( + server: &mut Server, + client: EcsEntity, + target: EcsEntity, + _args: Vec, + _action: &ServerChatCommand, +) -> CmdResult<()> { + use crate::rtsim::{ChunkStates, RtSim}; + let pos = position(server, target, "target")?; + + let chunk_key = pos.0.xy().as_::().wpos_to_cpos(); + + let rtsim = server.state.ecs().read_resource::(); + let data = rtsim.state().data(); + + let chunk_states = rtsim.state().resource::(); + let chunk_state = match chunk_states.0.get(chunk_key) { + Some(Some(chunk_state)) => chunk_state, + Some(None) => return Err(format!("Chunk {}, {} not loaded", chunk_key.x, chunk_key.y)), + None => { + return Err(format!( + "Chunk {}, {} not within map bounds", + chunk_key.x, chunk_key.y + )); + }, + }; + + let mut info = String::new(); + let _ = writeln!( + &mut info, + "-- Chunk {}, {} Resources --", + chunk_key.x, chunk_key.y + ); + for (res, frac) in data.nature.get_chunk_resources(chunk_key) { + let total = chunk_state.max_res[res]; + let _ = writeln!( + &mut info, + "{:?}: {} / {} ({}%)", + res, + frac * total as f32, + total, + frac * 100.0 + ); + } + + server.notify_client( + client, + ServerGeneral::server_msg(ChatType::CommandInfo, info), + ); + + Ok(()) +} + fn handle_spawn( server: &mut Server, client: EcsEntity, @@ -1188,8 +1429,8 @@ fn handle_spawn( args: Vec, action: &ServerChatCommand, ) -> CmdResult<()> { - match parse_cmd_args!(args, String, npc::NpcBody, u32, bool) { - (Some(opt_align), Some(npc::NpcBody(id, mut body)), opt_amount, opt_ai) => { + match parse_cmd_args!(args, String, npc::NpcBody, u32, bool, f32) { + (Some(opt_align), Some(npc::NpcBody(id, mut body)), opt_amount, opt_ai, opt_scale) => { let uid = uid(server, target, "target")?; let alignment = parse_alignment(uid, &opt_align)?; let amount = opt_amount.filter(|x| *x > 0).unwrap_or(1).min(50); @@ -1226,7 +1467,8 @@ fn handle_spawn( body, ) .with(comp::Vel(vel)) - .with(body.scale()) + .with(opt_scale.map(comp::Scale).unwrap_or(body.scale())) + .maybe_with(opt_scale.map(|s| comp::Mass(body.mass().0 * s.powi(3)))) .with(alignment); if ai { @@ -1342,7 +1584,7 @@ fn handle_spawn_airship( let ship = comp::ship::Body::random_airship_with(&mut rng); let mut builder = server .state - .create_ship(pos, ship, |ship| ship.make_collider(), true) + .create_ship(pos, ship, |ship| ship.make_collider()) .with(LightEmitter { col: Rgb::new(1.0, 0.65, 0.2), strength: 2.0, @@ -1350,7 +1592,8 @@ fn handle_spawn_airship( animated: true, }); if let Some(pos) = destination { - let (kp, ki, kd) = comp::agent::pid_coefficients(&comp::Body::Ship(ship)); + let (kp, ki, kd) = + comp::agent::pid_coefficients(&comp::Body::Ship(ship)).unwrap_or((1.0, 0.0, 0.0)); fn pure_z(sp: Vec3, pv: Vec3) -> f32 { (sp - pv).z } let agent = comp::Agent::from_body(&comp::Body::Ship(ship)) .with_destination(pos) @@ -1390,7 +1633,7 @@ fn handle_spawn_ship( let ship = comp::ship::Body::random_ship_with(&mut rng); let mut builder = server .state - .create_ship(pos, ship, |ship| ship.make_collider(), true) + .create_ship(pos, ship, |ship| ship.make_collider()) .with(LightEmitter { col: Rgb::new(1.0, 0.65, 0.2), strength: 2.0, @@ -1398,7 +1641,8 @@ fn handle_spawn_ship( animated: true, }); if let Some(pos) = destination { - let (kp, ki, kd) = comp::agent::pid_coefficients(&comp::Body::Ship(ship)); + let (kp, ki, kd) = + comp::agent::pid_coefficients(&comp::Body::Ship(ship)).unwrap_or((1.0, 0.0, 0.0)); fn pure_z(sp: Vec3, pv: Vec3) -> f32 { (sp - pv).z } let agent = comp::Agent::from_body(&comp::Body::Ship(ship)) .with_destination(pos) @@ -1439,12 +1683,9 @@ fn handle_make_volume( }; server .state - .create_ship( - comp::Pos(pos.0 + Vec3::unit_z() * 50.0), - ship, - move |_| collider, - true, - ) + .create_ship(comp::Pos(pos.0 + Vec3::unit_z() * 50.0), ship, move |_| { + collider + }) .build(); server.notify_client( @@ -1847,13 +2088,22 @@ fn handle_kill_npcs( let to_kill = { let ecs = server.state.ecs(); let entities = ecs.entities(); + let positions = ecs.write_storage::(); let healths = ecs.write_storage::(); let players = ecs.read_storage::(); let alignments = ecs.read_storage::(); + let rtsim_entities = ecs.read_storage::(); + let mut rtsim = ecs.write_resource::(); - (&entities, &healths, !&players, alignments.maybe()) + ( + &entities, + &healths, + !&players, + alignments.maybe(), + &positions, + ) .join() - .filter_map(|(entity, _health, (), alignment)| { + .filter_map(|(entity, _health, (), alignment, pos)| { let should_kill = kill_pets || if let Some(Alignment::Owned(owned)) = alignment { ecs.entity_from_uid(owned.0) @@ -1862,7 +2112,20 @@ fn handle_kill_npcs( true }; - should_kill.then_some(entity) + if should_kill { + if let Some(rtsim_entity) = rtsim_entities.get(entity).copied() { + rtsim.hook_rtsim_actor_death( + &ecs.read_resource::>(), + ecs.read_resource::().as_index_ref(), + Actor::Npc(rtsim_entity.0), + Some(pos.0), + None, + ); + } + Some(entity) + } else { + None + } }) .collect::>() }; @@ -2482,7 +2745,7 @@ fn handle_tell( } else { message_opt.join(" ") }; - server.state.send_chat(mode.new_message(target_uid, msg)); + server.state.send_chat(mode.to_plain_msg(target_uid, msg)); server.notify_client(target, ServerGeneral::ChatMode(mode)); Ok(()) } else { @@ -2507,7 +2770,7 @@ fn handle_faction( let msg = args.join(" "); if !msg.is_empty() { if let Some(uid) = server.state.ecs().read_storage().get(target) { - server.state.send_chat(mode.new_message(*uid, msg)); + server.state.send_chat(mode.to_plain_msg(*uid, msg)); } } server.notify_client(target, ServerGeneral::ChatMode(mode)); @@ -2534,7 +2797,7 @@ fn handle_group( let msg = args.join(" "); if !msg.is_empty() { if let Some(uid) = server.state.ecs().read_storage().get(target) { - server.state.send_chat(mode.new_message(*uid, msg)); + server.state.send_chat(mode.to_plain_msg(*uid, msg)); } } server.notify_client(target, ServerGeneral::ChatMode(mode)); @@ -2658,7 +2921,7 @@ fn handle_region( let msg = args.join(" "); if !msg.is_empty() { if let Some(uid) = server.state.ecs().read_storage().get(target) { - server.state.send_chat(mode.new_message(*uid, msg)); + server.state.send_chat(mode.to_plain_msg(*uid, msg)); } } server.notify_client(target, ServerGeneral::ChatMode(mode)); @@ -2679,7 +2942,7 @@ fn handle_say( let msg = args.join(" "); if !msg.is_empty() { if let Some(uid) = server.state.ecs().read_storage().get(target) { - server.state.send_chat(mode.new_message(*uid, msg)); + server.state.send_chat(mode.to_plain_msg(*uid, msg)); } } server.notify_client(target, ServerGeneral::ChatMode(mode)); @@ -2700,7 +2963,7 @@ fn handle_world( let msg = args.join(" "); if !msg.is_empty() { if let Some(uid) = server.state.ecs().read_storage().get(target) { - server.state.send_chat(mode.new_message(*uid, msg)); + server.state.send_chat(mode.to_plain_msg(*uid, msg)); } } server.notify_client(target, ServerGeneral::ChatMode(mode)); @@ -2729,8 +2992,9 @@ fn handle_join_faction( .flatten() .map(|f| f.0); server.state.send_chat( + // TODO: Localise ChatType::FactionMeta(faction.clone()) - .chat_msg(format!("[{}] joined faction ({})", alias, faction)), + .into_plain_msg(format!("[{}] joined faction ({})", alias, faction)), ); (faction_join, mode) } else { @@ -2746,8 +3010,9 @@ fn handle_join_faction( }; if let Some(faction) = faction_leave { server.state.send_chat( + // TODO: Localise ChatType::FactionMeta(faction.clone()) - .chat_msg(format!("[{}] left faction ({})", alias, faction)), + .into_plain_msg(format!("[{}] left faction ({})", alias, faction)), ); } server.notify_client(target, ServerGeneral::ChatMode(mode)); @@ -3835,3 +4100,33 @@ fn handle_body( Err(action.help_string()) } } + +fn handle_scale( + server: &mut Server, + client: EcsEntity, + target: EcsEntity, + args: Vec, + action: &ServerChatCommand, +) -> CmdResult<()> { + if let (Some(scale), reset_mass) = parse_cmd_args!(args, f32, bool) { + let scale = scale.clamped(0.025, 1000.0); + insert_or_replace_component(server, target, comp::Scale(scale), "target")?; + if reset_mass.unwrap_or(true) { + let mass = server.state.ecs() + .read_storage::() + .get(target) + // Mass is derived from volume, which changes with the third power of scale + .map(|body| body.mass().0 * scale.powi(3)); + if let Some(mass) = mass { + insert_or_replace_component(server, target, comp::Mass(mass), "target")?; + } + } + server.notify_client( + client, + ServerGeneral::server_msg(ChatType::CommandInfo, format!("Set scale to {}", scale)), + ); + Ok(()) + } else { + Err(action.help_string()) + } +} diff --git a/server/src/error.rs b/server/src/error.rs index ac666301b3..e4a5c974cb 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -9,6 +9,7 @@ pub enum Error { StreamErr(StreamError), DatabaseErr(rusqlite::Error), PersistenceErr(PersistenceError), + RtsimError(ron::Error), Other(String), } @@ -41,6 +42,7 @@ impl Display for Error { Self::StreamErr(err) => write!(f, "Stream Error: {}", err), Self::DatabaseErr(err) => write!(f, "Database Error: {}", err), Self::PersistenceErr(err) => write!(f, "Persistence Error: {}", err), + Self::RtsimError(err) => write!(f, "Rtsim Error: {}", err), Self::Other(err) => write!(f, "Error: {}", err), } } diff --git a/server/src/events/entity_creation.rs b/server/src/events/entity_creation.rs index dd9173948b..61901ca597 100644 --- a/server/src/events/entity_creation.rs +++ b/server/src/events/entity_creation.rs @@ -1,24 +1,22 @@ use crate::{ - client::Client, events::player::handle_exit_ingame, persistence::PersistedComponents, sys, - CharacterUpdater, Server, StateExt, + client::Client, events::player::handle_exit_ingame, persistence::PersistedComponents, + presence::RepositionOnChunkLoad, sys, CharacterUpdater, Server, StateExt, }; use common::{ character::CharacterId, comp::{ self, - agent::pid_coefficients, aura::{Aura, AuraKind, AuraTarget}, beam, buff::{BuffCategory, BuffData, BuffKind, BuffSource}, - shockwave, Agent, Alignment, Anchor, BehaviorCapability, Body, Health, Inventory, ItemDrop, - LightEmitter, Object, Ori, PidController, Poise, Pos, Projectile, Scale, SkillSet, Stats, - TradingBehavior, Vel, WaypointArea, + shockwave, Alignment, BehaviorCapability, Body, ItemDrop, LightEmitter, Object, Ori, Pos, + Projectile, TradingBehavior, Vel, WaypointArea, }, - event::{EventBus, UpdateCharacterMetadata}, - lottery::LootSpec, + event::{EventBus, NpcBuilder, UpdateCharacterMetadata}, + mounting::Mounting, outcome::Outcome, resources::{Secs, Time}, - rtsim::RtSimEntity, + rtsim::RtSimVehicle, uid::Uid, util::Dir, ViewDistances, @@ -91,63 +89,56 @@ pub fn handle_loaded_character_data( server.notify_client(entity, ServerGeneral::CharacterDataLoadResult(Ok(metadata))); } -pub fn handle_create_npc( - server: &mut Server, - pos: Pos, - stats: Stats, - skill_set: SkillSet, - health: Option, - poise: Poise, - inventory: Inventory, - body: Body, - agent: impl Into>, - alignment: Alignment, - scale: Scale, - loot: LootSpec, - home_chunk: Option, - rtsim_entity: Option, - projectile: Option, -) { +pub fn handle_create_npc(server: &mut Server, pos: Pos, mut npc: NpcBuilder) -> EcsEntity { let entity = server .state - .create_npc(pos, stats, skill_set, health, poise, inventory, body) - .with(scale); + .create_npc( + pos, + npc.stats, + npc.skill_set, + npc.health, + npc.poise, + npc.inventory, + npc.body, + ) + .with(npc.scale); - let mut agent = agent.into(); - if let Some(agent) = &mut agent { - if let Alignment::Owned(_) = &alignment { + if let Some(agent) = &mut npc.agent { + if let Alignment::Owned(_) = &npc.alignment { agent.behavior.allow(BehaviorCapability::TRADE); agent.behavior.trading_behavior = TradingBehavior::AcceptFood; } } - let entity = entity.with(alignment); + let entity = entity.with(npc.alignment); - let entity = if let Some(agent) = agent { + let entity = if let Some(agent) = npc.agent { entity.with(agent) } else { entity }; - let entity = if let Some(drop_item) = loot.to_item() { + let entity = if let Some(drop_item) = npc.loot.to_item() { entity.with(ItemDrop(drop_item)) } else { entity }; - let entity = if let Some(home_chunk) = home_chunk { + let entity = if let Some(home_chunk) = npc.anchor { entity.with(home_chunk) } else { entity }; - let entity = if let Some(rtsim_entity) = rtsim_entity { - entity.with(rtsim_entity) + let entity = if let Some(rtsim_entity) = npc.rtsim_entity { + entity.with(rtsim_entity).with(RepositionOnChunkLoad { + needs_ground: false, + }) } else { entity }; - let entity = if let Some(projectile) = projectile { + let entity = if let Some(projectile) = npc.projectile { entity.with(projectile) } else { entity @@ -156,7 +147,7 @@ pub fn handle_create_npc( let new_entity = entity.build(); // Add to group system if a pet - if let comp::Alignment::Owned(owner_uid) = alignment { + if let comp::Alignment::Owned(owner_uid) = npc.alignment { let state = server.state(); let clients = state.ecs().read_storage::(); let uids = state.ecs().read_storage::(); @@ -187,7 +178,7 @@ pub fn handle_create_npc( }, ); } - } else if let Some(group) = match alignment { + } else if let Some(group) = match npc.alignment { Alignment::Wild => None, Alignment::Passive => None, Alignment::Enemy => Some(comp::group::ENEMY), @@ -196,19 +187,22 @@ pub fn handle_create_npc( } { let _ = server.state.ecs().write_storage().insert(new_entity, group); } + + new_entity } pub fn handle_create_ship( server: &mut Server, pos: Pos, ship: comp::ship::Body, - mountable: bool, - agent: Option, - rtsim_entity: Option, + rtsim_vehicle: Option, + driver: Option, + passengers: Vec, ) { let mut entity = server .state - .create_ship(pos, ship, |ship| ship.make_collider(), mountable); + .create_ship(pos, ship, |ship| ship.make_collider()); + /* if let Some(mut agent) = agent { let (kp, ki, kd) = pid_coefficients(&Body::Ship(ship)); fn pure_z(sp: Vec3, pv: Vec3) -> f32 { (sp - pv).z } @@ -216,10 +210,35 @@ pub fn handle_create_ship( agent.with_position_pid_controller(PidController::new(kp, ki, kd, pos.0, 0.0, pure_z)); entity = entity.with(agent); } - if let Some(rtsim_entity) = rtsim_entity { - entity = entity.with(rtsim_entity); + */ + if let Some(rtsim_vehicle) = rtsim_vehicle { + entity = entity.with(rtsim_vehicle); + } + let entity = entity.build(); + + if let Some(driver) = driver { + let npc_entity = handle_create_npc(server, pos, driver); + + let uids = server.state.ecs().read_storage::(); + if let (Some(rider_uid), Some(mount_uid)) = + (uids.get(npc_entity).copied(), uids.get(entity).copied()) + { + drop(uids); + server + .state + .link(Mounting { + mount: mount_uid, + rider: rider_uid, + }) + .expect("Failed to link driver to ship"); + } else { + panic!("Couldn't get Uid from newly created ship and npc"); + } + } + + for passenger in passengers { + handle_create_npc(server, Pos(pos.0 + Vec3::unit_z() * 5.0), passenger); } - entity.build(); } pub fn handle_shoot( diff --git a/server/src/events/entity_manipulation.rs b/server/src/events/entity_manipulation.rs index 2901aa2b4c..cde5f31602 100644 --- a/server/src/events/entity_manipulation.rs +++ b/server/src/events/entity_manipulation.rs @@ -7,7 +7,7 @@ use crate::{ skillset::SkillGroupKind, BuffKind, BuffSource, PhysicsState, }, - rtsim::RtSim, + rtsim, sys::terrain::SAFE_ZONE_RADIUS, Server, SpawnPoint, StateExt, }; @@ -26,7 +26,6 @@ use common::{ event::{EventBus, ServerEvent}, outcome::{HealthChangeInfo, Outcome}, resources::{Secs, Time}, - rtsim::RtSimEntity, states::utils::StageSection, terrain::{Block, BlockKind, TerrainGrid}, uid::{Uid, UidAllocator}, @@ -36,14 +35,13 @@ use common::{ }; use common_net::{msg::ServerGeneral, sync::WorldSyncExt}; use common_state::BlockChange; -use comp::chat::GenericChatMsg; use hashbrown::HashSet; use rand::{distributions::WeightedIndex, Rng}; use rand_distr::Distribution; use specs::{ join::Join, saveload::MarkerAllocator, Builder, Entity as EcsEntity, Entity, WorldExt, }; -use std::{collections::HashMap, iter, time::Duration}; +use std::{collections::HashMap, iter, sync::Arc, time::Duration}; use tracing::{debug, error}; use vek::{Vec2, Vec3}; @@ -199,10 +197,7 @@ pub fn handle_destroy(server: &mut Server, entity: EcsEntity, last_change: Healt _ => KillSource::Other, }; - state.send_chat(GenericChatMsg { - chat_type: comp::ChatType::Kill(kill_source, *uid), - message: "".to_string(), - }); + state.send_chat(comp::ChatType::Kill(kill_source, *uid).into_plain_msg("")); } } @@ -519,16 +514,32 @@ pub fn handle_destroy(server: &mut Server, entity: EcsEntity, last_change: Healt } if should_delete { - if let Some(rtsim_entity) = state - .ecs() - .read_storage::() - .get(entity) - .copied() - { + if let Some(actor) = state.entity_as_actor(entity) { state .ecs() - .write_resource::() - .destroy_entity(rtsim_entity.0); + .write_resource::() + .hook_rtsim_actor_death( + &state.ecs().read_resource::>(), + state + .ecs() + .read_resource::() + .as_index_ref(), + actor, + state.ecs().read_storage::().get(entity).map(|p| p.0), + last_change + .by + .as_ref() + .and_then( + |(DamageContributor::Solo(entity_uid) + | DamageContributor::Group { entity_uid, .. })| { + state + .ecs() + .read_resource::() + .retrieve_entity_internal((*entity_uid).into()) + }, + ) + .and_then(|killer| state.entity_as_actor(killer)), + ); } if let Err(e) = state.delete_entity_recorded(entity) { diff --git a/server/src/events/interaction.rs b/server/src/events/interaction.rs index 0aad278d92..8a56355aad 100644 --- a/server/src/events/interaction.rs +++ b/server/src/events/interaction.rs @@ -9,6 +9,7 @@ use common::{ dialogue::Subject, inventory::slot::EquipSlot, loot_owner::LootOwnerKind, + pet::is_mountable, tool::ToolKind, Inventory, LootOwner, Pos, SkillGroupKind, }, @@ -119,7 +120,15 @@ pub fn handle_mount(server: &mut Server, rider: EcsEntity, mount: EcsEntity) { Some(comp::Alignment::Owned(owner)) if *owner == rider_uid, ); - if is_pet { + let can_ride = state + .ecs() + .read_storage() + .get(mount) + .map_or(false, |mount_body| { + is_mountable(mount_body, state.ecs().read_storage().get(rider)) + }); + + if is_pet && can_ride { drop(uids); drop(healths); let _ = state.link(Mounting { diff --git a/server/src/events/mod.rs b/server/src/events/mod.rs index 01bcd58e3e..a6c4882257 100644 --- a/server/src/events/mod.rs +++ b/server/src/events/mod.rs @@ -188,45 +188,15 @@ impl Server { ServerEvent::ExitIngame { entity } => { handle_exit_ingame(self, entity, false); }, - ServerEvent::CreateNpc { - pos, - stats, - skill_set, - health, - poise, - inventory, - body, - agent, - alignment, - scale, - anchor: home_chunk, - loot, - rtsim_entity, - projectile, - } => handle_create_npc( - self, - pos, - stats, - skill_set, - health, - poise, - inventory, - body, - agent, - alignment, - scale, - loot, - home_chunk, - rtsim_entity, - projectile, - ), + ServerEvent::CreateNpc { pos, npc } => { + handle_create_npc(self, pos, npc); + }, ServerEvent::CreateShip { pos, ship, - mountable, - agent, rtsim_entity, - } => handle_create_ship(self, pos, ship, mountable, agent, rtsim_entity), + driver, + } => handle_create_ship(self, pos, ship, rtsim_entity, driver, Vec::new()), ServerEvent::CreateWaypoint(pos) => handle_create_waypoint(self, pos), ServerEvent::ClientDisconnect(entity, reason) => { frontend_events.push(handle_client_disconnect(self, entity, reason, false)) diff --git a/server/src/events/player.rs b/server/src/events/player.rs index a634686cb3..3a0cdce141 100644 --- a/server/src/events/player.rs +++ b/server/src/events/player.rs @@ -1,16 +1,16 @@ use super::Event; use crate::{ client::Client, metrics::PlayerMetrics, persistence::character_updater::CharacterUpdater, - presence::Presence, state_ext::StateExt, BattleModeBuffer, Server, + state_ext::StateExt, BattleModeBuffer, Server, }; use common::{ character::CharacterId, comp, - comp::{group, pet::is_tameable}, + comp::{group, pet::is_tameable, Presence, PresenceKind}, uid::{Uid, UidAllocator}, }; use common_base::span; -use common_net::msg::{PlayerListUpdate, PresenceKind, ServerGeneral}; +use common_net::msg::{PlayerListUpdate, ServerGeneral}; use common_state::State; use specs::{saveload::MarkerAllocator, Builder, Entity as EcsEntity, Join, WorldExt}; use tracing::{debug, error, trace, warn, Instrument}; diff --git a/server/src/lib.rs b/server/src/lib.rs index f5f15e6c14..e108015a27 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -62,8 +62,7 @@ use crate::{ location::Locations, login_provider::LoginProvider, persistence::PersistedComponents, - presence::{Presence, RegionSubscription, RepositionOnChunkLoad}, - rtsim::RtSim, + presence::{RegionSubscription, RepositionOnChunkLoad}, state_ext::StateExt, sys::sentinel::DeletedEntities, }; @@ -78,7 +77,7 @@ use common::{ comp, event::{EventBus, ServerEvent}, resources::{BattleMode, GameMode, Time, TimeOfDay}, - rtsim::RtSimEntity, + rtsim::{RtSimEntity, RtSimVehicle}, shared_server_config::ServerConstants, slowjob::SlowJobPool, terrain::{TerrainChunk, TerrainChunkSize}, @@ -89,7 +88,7 @@ use common_net::{ msg::{ClientType, DisconnectReason, ServerGeneral, ServerInfo, ServerMsg}, sync::WorldSyncExt, }; -use common_state::{BuildAreas, State}; +use common_state::{BlockDiff, BuildAreas, State}; use common_systems::add_local_systems; use metrics::{EcsSystemMetrics, PhysicsMetrics, TickMetrics}; use network::{ListenAddr, Network, Pid}; @@ -341,6 +340,7 @@ impl Server { pool.configure("CHUNK_DROP", |_n| 1); pool.configure("CHUNK_GENERATOR", |n| n / 2 + n / 4); pool.configure("CHUNK_SERIALIZER", |n| n / 2); + pool.configure("RTSIM_SAVE", |_| 1); } state .ecs_mut() @@ -376,13 +376,15 @@ impl Server { // Server-only components state.ecs_mut().register::(); state.ecs_mut().register::(); - state.ecs_mut().register::(); + state.ecs_mut().register::(); state.ecs_mut().register::(); state.ecs_mut().register::(); state.ecs_mut().register::(); state.ecs_mut().register::(); state.ecs_mut().register::(); state.ecs_mut().register::(); + state.ecs_mut().register::(); + state.ecs_mut().register::(); // Load banned words list let banned_words = settings.moderation.load_banned_words(data_dir); @@ -455,7 +457,7 @@ impl Server { state.ecs_mut().insert(index.clone()); // Set starting time for the server. - state.ecs_mut().write_resource::().0 = settings.start_time; + state.ecs_mut().write_resource::().0 = settings.world.start_time; // Register trackers sys::sentinel::UpdateTrackers::register(state.ecs_mut()); @@ -547,14 +549,26 @@ impl Server { let connection_handler = ConnectionHandler::new(network, &runtime); - // Initiate real-time world simulation + // Init rtsim, loading it from disk if possible #[cfg(feature = "worldgen")] { - rtsim::init(&mut state, &world, index.as_index_ref()); + match rtsim::RtSim::new( + &settings.world, + index.as_index_ref(), + &world, + data_dir.to_owned(), + ) { + Ok(rtsim) => { + state.ecs_mut().insert(rtsim.state().data().time_of_day); + state.ecs_mut().insert(rtsim); + }, + Err(err) => { + error!("Failed to load rtsim: {}", err); + return Err(Error::RtsimError(err)); + }, + } weather::init(&mut state, &world); } - #[cfg(not(feature = "worldgen"))] - rtsim::init(&mut state); let server_constants = ServerConstants { day_cycle_coefficient: 1440.0 / settings.day_length, @@ -684,6 +698,20 @@ impl Server { let before_state_tick = Instant::now(); + fn on_block_update(ecs: &specs::World, changes: Vec) { + // When a resource block updates, inform rtsim + if changes + .iter() + .any(|c| c.old.get_rtsim_resource() != c.new.get_rtsim_resource()) + { + ecs.write_resource::().hook_block_update( + &ecs.read_resource::>(), + ecs.read_resource::().as_index_ref(), + changes, + ); + } + } + // 4) Tick the server's LocalState. // 5) Fetch any generated `TerrainChunk`s and insert them into the terrain. // in sys/terrain.rs @@ -703,6 +731,7 @@ impl Server { false, Some(&mut state_tick_metrics), &self.server_constants, + on_block_update, ); let before_handle_events = Instant::now(); @@ -726,7 +755,7 @@ impl Server { self.state.update_region_map(); // NOTE: apply_terrain_changes sends the *new* value since it is not being // synchronized during the tick. - self.state.apply_terrain_changes(); + self.state.apply_terrain_changes(on_block_update); let before_sync = Instant::now(); @@ -781,7 +810,7 @@ impl Server { ( &self.state.ecs().entities(), &self.state.ecs().read_storage::(), - !&self.state.ecs().read_storage::(), + !&self.state.ecs().read_storage::(), self.state.ecs().read_storage::().maybe(), ) .join() @@ -804,21 +833,26 @@ impl Server { .collect::>() }; - for entity in to_delete { - // Assimilate entities that are part of the real-time world simulation - if let Some(rtsim_entity) = self - .state - .ecs() - .read_storage::() - .get(entity) - .copied() - { - self.state - .ecs() - .write_resource::() - .assimilate_entity(rtsim_entity.0); - } + { + let mut rtsim = self.state.ecs().write_resource::(); + let rtsim_entities = self.state.ecs().read_storage::(); + let rtsim_vehicles = self.state.ecs().read_storage::(); + // Assimilate entities that are part of the real-time world simulation + for entity in &to_delete { + #[cfg(feature = "worldgen")] + if let Some(rtsim_entity) = rtsim_entities.get(*entity) { + rtsim.hook_rtsim_entity_unload(*rtsim_entity); + } + #[cfg(feature = "worldgen")] + if let Some(rtsim_vehicle) = rtsim_vehicles.get(*entity) { + rtsim.hook_rtsim_vehicle_unload(*rtsim_vehicle); + } + } + } + + // Actually perform entity deletion + for entity in to_delete { if let Err(e) = self.state.delete_entity_recorded(entity) { error!(?e, "Failed to delete agent outside the terrain"); } @@ -969,6 +1003,10 @@ impl Server { let mut chunk_generator = ecs.write_resource::(); let client = ecs.read_storage::(); let mut terrain = ecs.write_resource::(); + #[cfg(feature = "worldgen")] + let rtsim = ecs.read_resource::(); + #[cfg(not(feature = "worldgen"))] + let rtsim = (); // Cancel all pending chunks. chunk_generator.cancel_all(); @@ -984,6 +1022,7 @@ impl Server { pos, &slow_jobs, Arc::clone(world), + &rtsim, index.clone(), ( *ecs.read_resource::(), @@ -1147,11 +1186,16 @@ impl Server { pub fn generate_chunk(&mut self, entity: EcsEntity, key: Vec2) { let ecs = self.state.ecs(); let slow_jobs = ecs.read_resource::(); + #[cfg(feature = "worldgen")] + let rtsim = ecs.read_resource::(); + #[cfg(not(feature = "worldgen"))] + let rtsim = (); ecs.write_resource::().generate_chunk( Some(entity), key, &slow_jobs, Arc::clone(&self.world), + &rtsim, self.index.clone(), ( *ecs.read_resource::(), @@ -1384,6 +1428,12 @@ impl Drop for Server { info!("Unloading terrain persistence..."); terrain_persistence.unload_all() }); + + #[cfg(feature = "worldgen")] + { + debug!("Saving rtsim state..."); + self.state.ecs().write_resource::().save(true); + } } } diff --git a/server/src/persistence/character.rs b/server/src/persistence/character.rs index 5a1b99bc1a..19a51b00f1 100644 --- a/server/src/persistence/character.rs +++ b/server/src/persistence/character.rs @@ -139,7 +139,7 @@ pub fn load_character_data( )?; let (body_data, character_data) = stmt.query_row( - &[requesting_player_uuid.clone(), char_id.to_string()], + &[requesting_player_uuid.clone(), char_id.0.to_string()], |row| { let character_data = Character { character_id: row.get(0)?, @@ -168,7 +168,7 @@ pub fn load_character_data( warn!( "Error reading waypoint from database for character ID {}, error: {}", - char_id, e + char_id.0, e ); (None, None) }, @@ -187,9 +187,9 @@ pub fn load_character_data( )?; let skill_group_data = stmt - .query_map(&[char_id], |row| { + .query_map(&[char_id.0], |row| { Ok(SkillGroup { - entity_id: char_id, + entity_id: char_id.0, skill_group_kind: row.get(0)?, earned_exp: row.get(1)?, spent_exp: row.get(2)?, @@ -212,7 +212,7 @@ pub fn load_character_data( )?; let db_pets = stmt - .query_map(&[char_id], |row| { + .query_map(&[char_id.0], |row| { Ok(Pet { database_id: row.get(0)?, name: row.get(1)?, @@ -240,7 +240,7 @@ pub fn load_character_data( } else { warn!( "Failed to deserialize pet_id: {} for character_id {}", - db_pet.database_id, char_id + db_pet.database_id, char_id.0 ); None } @@ -254,9 +254,9 @@ pub fn load_character_data( WHERE entity_id = ?1", )?; - let ability_set_data = stmt.query_row(&[char_id], |row| { + let ability_set_data = stmt.query_row(&[char_id.0], |row| { Ok(AbilitySets { - entity_id: char_id, + entity_id: char_id.0, ability_sets: row.get(0)?, }) })?; @@ -329,7 +329,7 @@ pub fn load_character_list(player_uuid_: &str, connection: &Connection) -> Chara FROM body WHERE body_id = ?1", )?; - let db_body = stmt.query_row(&[char.id], |row| { + let db_body = stmt.query_row(&[char.id.map(|c| c.0)], |row| { Ok(Body { body_id: row.get(0)?, variant: row.get(1)?, @@ -342,7 +342,7 @@ pub fn load_character_list(player_uuid_: &str, connection: &Connection) -> Chara let loadout_container_id = get_pseudo_container_id( connection, - character_data.character_id, + CharacterId(character_data.character_id), LOADOUT_PSEUDO_CONTAINER_POSITION, )?; @@ -470,7 +470,8 @@ pub fn create_character( ])?; drop(stmt); - let db_skill_groups = convert_skill_groups_to_database(character_id, skill_set.skill_groups()); + let db_skill_groups = + convert_skill_groups_to_database(CharacterId(character_id), skill_set.skill_groups()); let mut stmt = transaction.prepare_cached( " @@ -495,7 +496,8 @@ pub fn create_character( } drop(stmt); - let ability_sets = convert_active_abilities_to_database(character_id, &active_abilities); + let ability_sets = + convert_active_abilities_to_database(CharacterId(character_id), &active_abilities); let mut stmt = transaction.prepare_cached( " @@ -547,7 +549,7 @@ pub fn create_character( } drop(stmt); - load_character_list(uuid, transaction).map(|list| (character_id, list)) + load_character_list(uuid, transaction).map(|list| (CharacterId(character_id), list)) } pub fn edit_character( @@ -570,7 +572,7 @@ pub fn edit_character( warn!( "Character edit rejected due to failed validation - Character ID: {} \ Alias: {}", - character_id, character_alias + character_id.0, character_alias ); return Err(PersistenceError::CharacterDataError); } else { @@ -587,14 +589,14 @@ pub fn edit_character( stmt.execute(&[ &body_variant.to_string(), &body_data, - &character_id as &dyn ToSql, + &character_id.0 as &dyn ToSql, ])?; drop(stmt); let mut stmt = transaction.prepare_cached("UPDATE character SET alias = ?1 WHERE character_id = ?2")?; - stmt.execute(&[&character_alias, &character_id as &dyn ToSql])?; + stmt.execute(&[&character_alias, &character_id.0 as &dyn ToSql])?; drop(stmt); char_list.map(|list| (character_id, list)) @@ -616,10 +618,13 @@ pub fn delete_character( AND player_uuid = ?2", )?; - let result = stmt.query_row(&[&char_id as &dyn ToSql, &requesting_player_uuid], |row| { - let y: i64 = row.get(0)?; - Ok(y) - })?; + let result = stmt.query_row( + &[&char_id.0 as &dyn ToSql, &requesting_player_uuid], + |row| { + let y: i64 = row.get(0)?; + Ok(y) + }, + )?; drop(stmt); if result != 1 { @@ -636,7 +641,7 @@ pub fn delete_character( WHERE entity_id = ?1", )?; - stmt.execute(&[&char_id])?; + stmt.execute(&[&char_id.0])?; drop(stmt); let pet_ids = get_pet_ids(char_id, transaction)? @@ -655,7 +660,7 @@ pub fn delete_character( WHERE entity_id = ?1", )?; - stmt.execute(&[&char_id])?; + stmt.execute(&[&char_id.0])?; drop(stmt); // Delete character @@ -666,7 +671,7 @@ pub fn delete_character( WHERE character_id = ?1", )?; - stmt.execute(&[&char_id])?; + stmt.execute(&[&char_id.0])?; drop(stmt); // Delete body @@ -677,7 +682,7 @@ pub fn delete_character( WHERE body_id = ?1", )?; - stmt.execute(&[&char_id])?; + stmt.execute(&[&char_id.0])?; drop(stmt); // Delete all items, recursively walking all containers starting from the @@ -701,14 +706,14 @@ pub fn delete_character( WHERE EXISTS (SELECT 1 FROM parents WHERE parents.item_id = item.item_id)", )?; - let deleted_item_count = stmt.execute(&[&char_id])?; + let deleted_item_count = stmt.execute(&[&char_id.0])?; drop(stmt); if deleted_item_count < 3 { return Err(PersistenceError::OtherError(format!( "Error deleting from item table for char_id {} (expected at least 3 deletions, found \ {})", - char_id, deleted_item_count + char_id.0, deleted_item_count ))); } @@ -822,7 +827,7 @@ fn get_pseudo_container_id( #[allow(clippy::needless_question_mark)] let res = stmt.query_row( &[ - character_id.to_string(), + character_id.0.to_string(), pseudo_container_position.to_string(), ], |row| Ok(row.get(0)?), @@ -850,7 +855,7 @@ fn update_pets( pets: Vec, transaction: &mut Transaction, ) -> Result<(), PersistenceError> { - debug!("Updating {} pets for character {}", pets.len(), char_id); + debug!("Updating {} pets for character {}", pets.len(), char_id.0); let db_pets = get_pet_ids(char_id, transaction)?; if !db_pets.is_empty() { @@ -907,7 +912,7 @@ fn update_pets( VALUES (?1, ?2, ?3)", )?; - stmt.execute(&[&pet_entity_id as &dyn ToSql, &char_id, &stats.name])?; + stmt.execute(&[&pet_entity_id as &dyn ToSql, &char_id.0, &stats.name])?; drop(stmt); pet.get_database_id() @@ -917,7 +922,10 @@ fn update_pets( Ok(()) } -fn get_pet_ids(char_id: i64, transaction: &mut Transaction) -> Result, PersistenceError> { +fn get_pet_ids( + char_id: CharacterId, + transaction: &mut Transaction, +) -> Result, PersistenceError> { #[rustfmt::skip] let mut stmt = transaction.prepare_cached(" SELECT pet_id @@ -927,7 +935,7 @@ fn get_pet_ids(char_id: i64, transaction: &mut Transaction) -> Result, #[allow(clippy::needless_question_mark)] let db_pets = stmt - .query_map(&[&char_id], |row| Ok(row.get(0)?))? + .query_map(&[&char_id.0], |row| Ok(row.get(0)?))? .map(|x| x.unwrap()) .collect::>(); drop(stmt); @@ -948,7 +956,10 @@ fn delete_pets( let delete_count = stmt.execute(&[&pet_ids])?; drop(stmt); - debug!("Deleted {} pets for character id {}", delete_count, char_id); + debug!( + "Deleted {} pets for character id {}", + delete_count, char_id.0 + ); #[rustfmt::skip] let mut stmt = transaction.prepare_cached(" @@ -960,7 +971,7 @@ fn delete_pets( let delete_count = stmt.execute(&[&pet_ids])?; debug!( "Deleted {} pet bodies for character id {}", - delete_count, char_id + delete_count, char_id.0 ); Ok(()) @@ -996,7 +1007,7 @@ pub fn update( })?; // Next, delete any slots we aren't upserting. - trace!("Deleting items for character_id {}", char_id); + trace!("Deleting items for character_id {}", char_id.0); let mut existing_item_ids: Vec<_> = vec![ Value::from(pseudo_containers.inventory_container_id), Value::from(pseudo_containers.loadout_container_id), @@ -1040,7 +1051,7 @@ pub fn update( trace!( "Upserting items {:?} for character_id {}", upserted_items, - char_id + char_id.0 ); // When moving inventory items around, foreign key constraints on @@ -1111,12 +1122,12 @@ pub fn update( ", )?; - let waypoint_count = stmt.execute(&[&db_waypoint as &dyn ToSql, &char_id])?; + let waypoint_count = stmt.execute(&[&db_waypoint as &dyn ToSql, &char_id.0])?; if waypoint_count != 1 { return Err(PersistenceError::OtherError(format!( "Error updating character table for char_id {}", - char_id + char_id.0 ))); } @@ -1132,13 +1143,13 @@ pub fn update( let ability_sets_count = stmt.execute(&[ &ability_sets.ability_sets as &dyn ToSql, - &char_id as &dyn ToSql, + &char_id.0 as &dyn ToSql, ])?; if ability_sets_count != 1 { return Err(PersistenceError::OtherError(format!( "Error updating ability_set table for char_id {}", - char_id, + char_id.0, ))); } diff --git a/server/src/persistence/character/conversions.rs b/server/src/persistence/character/conversions.rs index 61a7549cb9..6629cd7a9a 100644 --- a/server/src/persistence/character/conversions.rs +++ b/server/src/persistence/character/conversions.rs @@ -608,7 +608,7 @@ pub fn convert_body_from_database( pub fn convert_character_from_database(character: &Character) -> common::character::Character { common::character::Character { - id: Some(character.character_id), + id: Some(CharacterId(character.character_id)), alias: String::from(&character.alias), } } @@ -704,7 +704,7 @@ pub fn convert_skill_groups_to_database<'a, I: Iterator AbilitySets { let ability_sets = json_models::active_abilities_to_db_model(active_abilities); AbilitySets { - entity_id, + entity_id: entity_id.0, ability_sets: serde_json::to_string(&ability_sets).unwrap_or_default(), } } diff --git a/server/src/persistence/character_loader.rs b/server/src/persistence/character_loader.rs index 0420cb9caf..4b1419767d 100644 --- a/server/src/persistence/character_loader.rs +++ b/server/src/persistence/character_loader.rs @@ -139,7 +139,7 @@ impl CharacterLoader { if result.is_err() { error!( ?result, - "Error loading character data for character_id: {}", character_id + "Error loading character data for character_id: {}", character_id.0 ); } CharacterScreenResponseKind::CharacterData(Box::new(result)) diff --git a/server/src/persistence/character_updater.rs b/server/src/persistence/character_updater.rs index 13d5ab76f4..29289e117d 100644 --- a/server/src/persistence/character_updater.rs +++ b/server/src/persistence/character_updater.rs @@ -242,7 +242,7 @@ impl CharacterUpdater { warn!( "Ignoring request to add pending logout update for character ID {} as there is a \ disconnection of all clients in progress", - update_data.0 + update_data.0.0 ); return; } @@ -251,7 +251,7 @@ impl CharacterUpdater { warn!( "Ignoring request to add pending logout update for character ID {} as there is \ already a pending delete for this character", - update_data.0 + update_data.0.0 ); return; } diff --git a/server/src/presence.rs b/server/src/presence.rs index 99c7c10c4c..cb6deb51eb 100644 --- a/server/src/presence.rs +++ b/server/src/presence.rs @@ -1,34 +1,8 @@ -use common_net::msg::PresenceKind; use hashbrown::HashSet; use serde::{Deserialize, Serialize}; -use specs::{Component, NullStorage}; -use std::time::{Duration, Instant}; +use specs::{Component, VecStorage}; use vek::*; -#[derive(Debug)] -pub struct Presence { - pub terrain_view_distance: ViewDistance, - pub entity_view_distance: ViewDistance, - pub kind: PresenceKind, - pub lossy_terrain_compression: bool, -} - -impl Presence { - pub fn new(view_distances: common::ViewDistances, kind: PresenceKind) -> Self { - let now = Instant::now(); - Self { - terrain_view_distance: ViewDistance::new(view_distances.terrain, now), - entity_view_distance: ViewDistance::new(view_distances.entity, now), - kind, - lossy_terrain_compression: false, - } - } -} - -impl Component for Presence { - type Storage = specs::DenseVecStorage; -} - // Distance from fuzzy_chunk before snapping to current chunk pub const CHUNK_FUZZ: u32 = 2; // Distance out of the range of a region before removing it from subscriptions @@ -46,93 +20,10 @@ impl Component for RegionSubscription { } #[derive(Copy, Clone, Debug, Default, Serialize, Deserialize)] -pub struct RepositionOnChunkLoad; +pub struct RepositionOnChunkLoad { + pub needs_ground: bool, +} impl Component for RepositionOnChunkLoad { - type Storage = NullStorage; -} - -#[derive(PartialEq, Debug, Clone, Copy)] -enum Direction { - Up, - Down, -} - -/// Distance from the [Presence] from which the world is loaded and information -/// is synced to clients. -/// -/// We limit the frequency that changes in the view distance change direction -/// (e.g. shifting from increasing the value to decreasing it). This is useful -/// since we want to avoid rapid cycles of shrinking and expanding of the view -/// distance. -#[derive(Debug)] -pub struct ViewDistance { - direction: Direction, - last_direction_change_time: Instant, - target: Option, - current: u32, -} - -impl ViewDistance { - /// Minimum time allowed between changes in direction of value adjustments. - const TIME_PER_DIR_CHANGE: Duration = Duration::from_millis(300); - - pub fn new(start_value: u32, now: Instant) -> Self { - Self { - direction: Direction::Up, - last_direction_change_time: now.checked_sub(Self::TIME_PER_DIR_CHANGE).unwrap_or(now), - target: None, - current: start_value, - } - } - - /// Returns the current value. - pub fn current(&self) -> u32 { self.current } - - /// Applies deferred change based on the whether the time to apply it has - /// been reached. - pub fn update(&mut self, now: Instant) { - if let Some(target_val) = self.target { - if now.saturating_duration_since(self.last_direction_change_time) - > Self::TIME_PER_DIR_CHANGE - { - self.last_direction_change_time = now; - self.current = target_val; - self.target = None; - } - } - } - - /// Sets the target value. - /// - /// If this hasn't been changed recently or it is in the same direction as - /// the previous change it will be applied immediately. Otherwise, it - /// will be deferred to a later time (limiting the frequency of changes - /// in the change direction). - pub fn set_target(&mut self, new_target: u32, now: Instant) { - use core::cmp::Ordering; - let new_direction = match new_target.cmp(&self.current) { - Ordering::Equal => return, // No change needed. - Ordering::Less => Direction::Down, - Ordering::Greater => Direction::Up, - }; - - // Change is in the same direction as before so we can just apply it. - if new_direction == self.direction { - self.current = new_target; - self.target = None; - // If it has already been a while since the last direction change we can - // directly apply the request and switch the direction. - } else if now.saturating_duration_since(self.last_direction_change_time) - > Self::TIME_PER_DIR_CHANGE - { - self.direction = new_direction; - self.last_direction_change_time = now; - self.current = new_target; - self.target = None; - // Otherwise, we need to defer the request. - } else { - self.target = Some(new_target); - } - } + type Storage = VecStorage; } diff --git a/server/src/rtsim/chunks.rs b/server/src/rtsim/chunks.rs deleted file mode 100644 index 481c8cab71..0000000000 --- a/server/src/rtsim/chunks.rs +++ /dev/null @@ -1,34 +0,0 @@ -use super::*; -use ::world::util::Grid; - -pub struct Chunks { - chunks: Grid, - pub chunks_to_load: Vec>, - pub chunks_to_unload: Vec>, -} - -impl Chunks { - pub fn new(size: Vec2) -> Self { - Chunks { - chunks: Grid::populate_from(size.map(|e| e as i32), |_| Chunk { is_loaded: false }), - chunks_to_load: Vec::new(), - chunks_to_unload: Vec::new(), - } - } - - pub fn chunk(&self, key: Vec2) -> Option<&Chunk> { self.chunks.get(key) } - - pub fn size(&self) -> Vec2 { self.chunks.size().map(|e| e as u32) } - - pub fn chunk_mut(&mut self, key: Vec2) -> Option<&mut Chunk> { self.chunks.get_mut(key) } - - pub fn chunk_at(&self, pos: Vec2) -> Option<&Chunk> { - self.chunks.get(pos.map2(TerrainChunk::RECT_SIZE, |e, sz| { - (e.floor() as i32).div_euclid(sz as i32) - })) - } -} - -pub struct Chunk { - pub is_loaded: bool, -} diff --git a/server/src/rtsim/entity.rs b/server/src/rtsim/entity.rs deleted file mode 100644 index 57d5ecfcc0..0000000000 --- a/server/src/rtsim/entity.rs +++ /dev/null @@ -1,1074 +0,0 @@ -use super::*; -use common::{ - resources::Time, - rtsim::{Memory, MemoryItem}, - store::Id, - terrain::TerrainGrid, - trade, LoadoutBuilder, -}; -use enumset::*; -use rand_distr::{Distribution, Normal}; -use std::f32::consts::PI; -use tracing::warn; -use world::{ - civ::{Site, Track}, - util::RandomPerm, - IndexRef, World, -}; - -pub struct Entity { - pub is_loaded: bool, - pub pos: Vec3, - pub seed: u32, - pub last_time_ticked: f64, - pub controller: RtSimController, - pub kind: RtSimEntityKind, - pub brain: Brain, -} - -#[derive(Clone, Copy, strum::EnumIter, PartialEq, Eq)] -pub enum RtSimEntityKind { - Wanderer, - Cultist, - Villager, - TownGuard, - Merchant, - Blacksmith, - Chef, - Alchemist, - Prisoner, -} - -const BIRD_MEDIUM_ROSTER: &[comp::bird_medium::Species] = &[ - // Disallows flightless birds - comp::bird_medium::Species::Duck, - comp::bird_medium::Species::Goose, - comp::bird_medium::Species::Parrot, - comp::bird_medium::Species::Eagle, -]; - -const BIRD_LARGE_ROSTER: &[comp::bird_large::Species] = &[ - // Wyverns not included until proper introduction - comp::bird_large::Species::Phoenix, - comp::bird_large::Species::Cockatrice, - comp::bird_large::Species::Roc, -]; - -const PERM_SPECIES: u32 = 0; -const PERM_BODY: u32 = 1; -const PERM_LOADOUT: u32 = 2; -const PERM_LEVEL: u32 = 3; -const PERM_GENUS: u32 = 4; -const PERM_TRADE: u32 = 5; - -impl Entity { - pub fn rng(&self, perm: u32) -> impl Rng { RandomPerm::new(self.seed + perm) } - - pub fn loadout_rng(&self) -> impl Rng { self.rng(PERM_LOADOUT) } - - pub fn get_body(&self) -> comp::Body { - match self.kind { - RtSimEntityKind::Wanderer => { - match self.rng(PERM_GENUS).gen::() { - // we want 5% airships, 45% birds, 50% humans - x if x < 0.05 => { - comp::ship::Body::random_airship_with(&mut self.rng(PERM_BODY)).into() - }, - x if x < 0.45 => { - let species = *BIRD_MEDIUM_ROSTER - .choose(&mut self.rng(PERM_SPECIES)) - .unwrap(); - comp::bird_medium::Body::random_with(&mut self.rng(PERM_BODY), &species) - .into() - }, - x if x < 0.50 => { - let species = *BIRD_LARGE_ROSTER - .choose(&mut self.rng(PERM_SPECIES)) - .unwrap(); - comp::bird_large::Body::random_with(&mut self.rng(PERM_BODY), &species) - .into() - }, - _ => { - let species = *comp::humanoid::ALL_SPECIES - .choose(&mut self.rng(PERM_SPECIES)) - .unwrap(); - comp::humanoid::Body::random_with(&mut self.rng(PERM_BODY), &species).into() - }, - } - }, - RtSimEntityKind::Cultist - | RtSimEntityKind::Villager - | RtSimEntityKind::TownGuard - | RtSimEntityKind::Chef - | RtSimEntityKind::Alchemist - | RtSimEntityKind::Blacksmith - | RtSimEntityKind::Prisoner - | RtSimEntityKind::Merchant => { - let species = *comp::humanoid::ALL_SPECIES - .choose(&mut self.rng(PERM_SPECIES)) - .unwrap(); - comp::humanoid::Body::random_with(&mut self.rng(PERM_BODY), &species).into() - }, - } - } - - pub fn get_trade_info( - &self, - world: &World, - index: &world::IndexOwned, - ) -> Option { - let site = match self.kind { - /* - // Travelling merchants (don't work for some reason currently) - RtSimEntityKind::Wanderer if self.rng(PERM_TRADE).gen_bool(0.5) => { - match self.brain.route { - Travel::Path { target_id, .. } => Some(target_id), - _ => None, - } - }, - */ - RtSimEntityKind::Merchant => self.brain.begin_site(), - _ => None, - }?; - - let site = world.civs().sites[site].site_tmp?; - index.sites[site].trade_information(site.id()) - } - - pub fn get_entity_config(&self) -> &str { - match self.get_body() { - comp::Body::Humanoid(_) => { - let rank = match self.rng(PERM_LEVEL).gen_range::(0..=20) { - 0..=2 => TravelerRank::Rank0, - 3..=9 => TravelerRank::Rank1, - 10..=17 => TravelerRank::Rank2, - 18.. => TravelerRank::Rank3, - }; - humanoid_config(self.kind, rank) - }, - comp::Body::BirdMedium(b) => bird_medium_config(b), - comp::Body::BirdLarge(b) => bird_large_config(b), - _ => unimplemented!(), - } - } - - /// Escape hatch for runtime creation of loadout not covered by entity - /// config. - // NOTE: Signature is part of interface of EntityInfo - pub fn get_adhoc_loadout( - &self, - ) -> fn(LoadoutBuilder, Option<&trade::SiteInformation>) -> LoadoutBuilder { - let kind = self.kind; - - if let RtSimEntityKind::Merchant = kind { - |l, trade| l.with_creator(world::site::settlement::merchant_loadout, trade) - } else { - |l, _| l - } - } - - pub fn tick(&mut self, time: &Time, terrain: &TerrainGrid, world: &World, index: &IndexRef) { - self.brain.route = match self.brain.route.clone() { - Travel::Lost => { - match self.get_body() { - comp::Body::Humanoid(_) => { - if let Some(nearest_site_id) = world - .civs() - .sites - .iter() - .filter(|s| s.1.is_settlement() || s.1.is_castle()) - .min_by_key(|(_, site)| { - let wpos = site.center.map2(TerrainChunk::RECT_SIZE, |e, sz| { - e * sz as i32 + sz as i32 / 2 - }); - wpos.map(|e| e as f32).distance_squared(self.pos.xy()) as u32 - }) - .map(|(id, _)| id) - { - // The path choosing code works best when Humanoids can assume they are - // in a town that has at least one path. If the Human isn't in a town - // with at least one path, we need to get them to a town that does. - let nearest_site = &world.civs().sites[nearest_site_id]; - let site_wpos = - nearest_site.center.map2(TerrainChunk::RECT_SIZE, |e, sz| { - e * sz as i32 + sz as i32 / 2 - }); - let dist = - site_wpos.map(|e| e as f32).distance_squared(self.pos.xy()) as u32; - if dist < 64_u32.pow(2) { - Travel::InSite { - site_id: nearest_site_id, - } - } else { - Travel::Direct { - target_id: nearest_site_id, - } - } - } else { - // Somehow no nearest site could be found - // Logically this should never happen, but if it does the rtsim entity - // will just sit tight - warn!("Nearest site could not be found"); - Travel::Lost - } - }, - comp::Body::Ship(_) => { - if let Some((target_id, site)) = world - .civs() - .sites - .iter() - .filter(|s| match self.get_body() { - comp::Body::Ship(_) => s.1.is_settlement(), - _ => s.1.is_dungeon(), - }) - .filter(|_| thread_rng().gen_range(0i32..4) == 0) - .min_by_key(|(_, site)| { - let wpos = site.center.map2(TerrainChunk::RECT_SIZE, |e, sz| { - e * sz as i32 + sz as i32 / 2 - }); - let dist = - wpos.map(|e| e as f32).distance_squared(self.pos.xy()) as u32; - dist + if dist < 96_u32.pow(2) { 100_000_000 } else { 0 } - }) - { - let mut rng = thread_rng(); - if let (Ok(normalpos), Ok(normaloff)) = - (Normal::new(0.0, 64.0), Normal::new(0.0, 256.0)) - { - let mut path = Vec::>::default(); - let target_site_pos = - site.center.map2(TerrainChunk::RECT_SIZE, |e, sz| { - (e * sz as i32 + sz as i32 / 2) as f32 - }); - let offset_site_pos = - target_site_pos.map(|v| v + normalpos.sample(&mut rng)); - let offset_dir = (offset_site_pos - self.pos.xy()).normalized(); - let dist = (offset_site_pos - self.pos.xy()).magnitude(); - let midpoint = self.pos.xy() + offset_dir * (dist / 2.0); - let perp_dir = offset_dir.rotated_z(PI / 2.0); - let offset = normaloff.sample(&mut rng); - let inbetween_pos = midpoint + (perp_dir * offset); - - path.push(inbetween_pos.map(|e| e as i32)); - path.push(target_site_pos.map(|e| e as i32)); - - Travel::CustomPath { - target_id, - path, - progress: 0, - } - } else { - Travel::Direct { target_id } - } - } else { - Travel::Lost - } - }, - _ => { - if let Some(target_id) = world - .civs() - .sites - .iter() - .filter(|s| match self.get_body() { - comp::Body::Ship(_) => s.1.is_settlement(), - _ => s.1.is_dungeon(), - }) - .filter(|_| thread_rng().gen_range(0i32..4) == 0) - .min_by_key(|(_, site)| { - let wpos = site.center.map2(TerrainChunk::RECT_SIZE, |e, sz| { - e * sz as i32 + sz as i32 / 2 - }); - let dist = - wpos.map(|e| e as f32).distance_squared(self.pos.xy()) as u32; - dist + if dist < 96_u32.pow(2) { 100_000 } else { 0 } - }) - .map(|(id, _)| id) - { - Travel::Direct { target_id } - } else { - Travel::Lost - } - }, - } - }, - Travel::InSite { site_id } => { - if !self.get_body().is_humanoid() { - // Non humanoids don't care if they start at a site - Travel::Lost - } else if let Some(target_id) = world - .civs() - .neighbors(site_id) - .filter(|sid| { - let site = world.civs().sites.get(*sid); - let wpos = site.center.map2(TerrainChunk::RECT_SIZE, |e, sz| { - e * sz as i32 + sz as i32 / 2 - }); - let dist = wpos.map(|e| e as f32).distance_squared(self.pos.xy()) as u32; - dist > 96_u32.pow(2) - }) - .filter(|sid| { - if let Some(last_visited) = self.brain.last_visited { - *sid != last_visited - } else { - true - } - }) - .choose(&mut thread_rng()) - { - if let Some(track_id) = world.civs().track_between(site_id, target_id) { - self.brain.last_visited = Some(site_id); - Travel::Path { - target_id, - track_id, - progress: 0, - reversed: false, - } - } else { - // This should never trigger, since neighbors returns a list of sites for - // which a track exists going from the current town. - warn!("Could not get track after selecting from neighbor list"); - self.brain.last_visited = Some(site_id); - Travel::Direct { target_id } - } - } else if let Some(target_id) = world - .civs() - .sites - .iter() - .filter(|s| s.1.is_settlement() | s.1.is_castle()) - .filter(|_| thread_rng().gen_range(0i32..4) == 0) - .min_by_key(|(_, site)| { - let wpos = site.center.map2(TerrainChunk::RECT_SIZE, |e, sz| { - e * sz as i32 + sz as i32 / 2 - }); - let dist = wpos.map(|e| e as f32).distance_squared(self.pos.xy()) as u32; - dist + if dist < 96_u32.pow(2) { 100_000 } else { 0 } - }) - .map(|(id, _)| id) - { - // This code should only trigger when no paths out of the current town exist. - // The traveller will attempt to directly travel to another town - self.brain.last_visited = Some(site_id); - Travel::Direct { target_id } - } else { - // No paths we're picked, so stay in town. This will cause direct travel on the - // next tick. - self.brain.last_visited = Some(site_id); - Travel::InSite { site_id } - } - }, - Travel::Direct { target_id } => { - let site = &world.civs().sites[target_id]; - let destination_name = site - .site_tmp - .map_or("".to_string(), |id| index.sites[id].name().to_string()); - - let wpos = site.center.map2(TerrainChunk::RECT_SIZE, |e, sz| { - e * sz as i32 + sz as i32 / 2 - }); - let dist = wpos.map(|e| e as f32).distance_squared(self.pos.xy()) as u32; - - if dist < 64_u32.pow(2) { - Travel::InSite { site_id: target_id } - } else { - let travel_to = self.pos.xy() - + Vec3::from( - (wpos.map(|e| e as f32 + 0.5) - self.pos.xy()) - .try_normalized() - .unwrap_or_else(Vec2::zero), - ) * 64.0; - let travel_to_alt = world - .sim() - .get_alt_approx(travel_to.map(|e| e as i32)) - .unwrap_or(0.0) as i32; - let travel_to = terrain - .find_space(Vec3::new( - travel_to.x as i32, - travel_to.y as i32, - travel_to_alt, - )) - .map(|e| e as f32) - + Vec3::new(0.5, 0.5, 0.0); - - self.controller.travel_to = Some((travel_to, destination_name)); - self.controller.speed_factor = 0.70; - Travel::Direct { target_id } - } - }, - Travel::CustomPath { - target_id, - path, - progress, - } => { - let site = &world.civs().sites[target_id]; - let destination_name = site - .site_tmp - .map_or("".to_string(), |id| index.sites[id].name().to_string()); - - if let Some(wpos) = &path.get(progress) { - let dist = wpos.map(|e| e as f32).distance_squared(self.pos.xy()) as u32; - if dist < 16_u32.pow(2) { - if progress + 1 < path.len() { - Travel::CustomPath { - target_id, - path, - progress: progress + 1, - } - } else { - Travel::InSite { site_id: target_id } - } - } else { - let travel_to = self.pos.xy() - + Vec3::from( - (wpos.map(|e| e as f32 + 0.5) - self.pos.xy()) - .try_normalized() - .unwrap_or_else(Vec2::zero), - ) * 64.0; - let travel_to_alt = world - .sim() - .get_alt_approx(travel_to.map(|e| e as i32)) - .unwrap_or(0.0) as i32; - let travel_to = terrain - .find_space(Vec3::new( - travel_to.x as i32, - travel_to.y as i32, - travel_to_alt, - )) - .map(|e| e as f32) - + Vec3::new(0.5, 0.5, 0.0); - - self.controller.travel_to = Some((travel_to, destination_name)); - self.controller.speed_factor = 0.70; - Travel::CustomPath { - target_id, - path, - progress, - } - } - } else { - Travel::Direct { target_id } - } - }, - Travel::Path { - target_id, - track_id, - progress, - reversed, - } => { - let track = &world.civs().tracks.get(track_id); - let site = &world.civs().sites[target_id]; - let destination_name = site - .site_tmp - .map_or("".to_string(), |id| index.sites[id].name().to_string()); - let nth = if reversed { - track.path().len() - progress - 1 - } else { - progress - }; - - if let Some(sim_pos) = track.path().iter().nth(nth) { - let chunkpos = sim_pos.map2(TerrainChunk::RECT_SIZE, |e, sz| { - e * sz as i32 + sz as i32 / 2 - }); - let wpos = if let Some(pathdata) = world.sim().get_nearest_path(chunkpos) { - pathdata.1.map(|e| e as i32) - } else { - chunkpos - }; - let dist = wpos.map(|e| e as f32).distance_squared(self.pos.xy()) as u32; - - match dist { - d if d < 16_u32.pow(2) => { - if progress + 1 >= track.path().len() { - Travel::Direct { target_id } - } else { - Travel::Path { - target_id, - track_id, - progress: progress + 1, - reversed, - } - } - }, - d if d > 256_u32.pow(2) => { - if !reversed && progress == 0 { - Travel::Path { - target_id, - track_id, - progress: 0, - reversed: true, - } - } else { - Travel::Lost - } - }, - _ => { - let travel_to = self.pos.xy() - + Vec3::from( - (wpos.map(|e| e as f32 + 0.5) - self.pos.xy()) - .try_normalized() - .unwrap_or_else(Vec2::zero), - ) * 64.0; - let travel_to_alt = world - .sim() - .get_alt_approx(travel_to.map(|e| e as i32)) - .unwrap_or(0.0) - as i32; - let travel_to = terrain - .find_space(Vec3::new( - travel_to.x as i32, - travel_to.y as i32, - travel_to_alt, - )) - .map(|e| e as f32) - + Vec3::new(0.5, 0.5, 0.0); - self.controller.travel_to = Some((travel_to, destination_name)); - self.controller.speed_factor = 0.70; - Travel::Path { - target_id, - track_id, - progress, - reversed, - } - }, - } - } else { - // This code should never trigger. If we've gone outside the bounds of the - // tracks vec then a logic bug has occured. I actually had - // an off by one error that caused this to trigger and - // resulted in travellers getting stuck in towns. - warn!("Progress out of bounds while following track"); - Travel::Lost - } - }, - Travel::DirectRaid { - target_id, - home_id, - raid_complete, - time_to_move, - } => { - // Destination site is home if raid is complete, else it is target site - let dest_site = if raid_complete { - &world.civs().sites[home_id] - } else { - &world.civs().sites[target_id] - }; - let destination_name = dest_site - .site_tmp - .map_or("".to_string(), |id| index.sites[id].name().to_string()); - - let wpos = dest_site.center.map2(TerrainChunk::RECT_SIZE, |e, sz| { - e * sz as i32 + sz as i32 / 2 - }); - let dist = wpos.map(|e| e as f32).distance_squared(self.pos.xy()) as u32; - - // Once at site, stay for a bit, then move to other site - if dist < 128_u32.pow(2) { - // If time_to_move is not set yet, use current time, ceiling to nearest multiple - // of 100, and then add another 100. - let time_to_move = if time_to_move.is_none() { - // Time increment is how long raiders stay at a site about. Is longer for - // home site and shorter for target site. - let time_increment = if raid_complete { 600.0 } else { 60.0 }; - Some((time.0 / time_increment).ceil() * time_increment + time_increment) - } else { - time_to_move - }; - - // If the time has come to move, flip raid bool - if time_to_move.map_or(false, |t| time.0 > t) { - Travel::DirectRaid { - target_id, - home_id, - raid_complete: !raid_complete, - time_to_move: None, - } - } else { - let theta = (time.0 / 30.0).floor() as f32 * self.seed as f32; - // Otherwise wander around site (or "plunder" if target site) - let travel_to = - wpos.map(|e| e as f32) + Vec2::new(theta.cos(), theta.sin()) * 100.0; - let travel_to_alt = world - .sim() - .get_alt_approx(travel_to.map(|e| e as i32)) - .unwrap_or(0.0) as i32; - let travel_to = terrain - .find_space(Vec3::new( - travel_to.x as i32, - travel_to.y as i32, - travel_to_alt, - )) - .map(|e| e as f32) - + Vec3::new(0.5, 0.5, 0.0); - - self.controller.travel_to = Some((travel_to, destination_name)); - self.controller.speed_factor = 0.75; - Travel::DirectRaid { - target_id, - home_id, - raid_complete, - time_to_move, - } - } - } else { - let travel_to = self.pos.xy() - + Vec3::from( - (wpos.map(|e| e as f32 + 0.5) - self.pos.xy()) - .try_normalized() - .unwrap_or_else(Vec2::zero), - ) * 64.0; - let travel_to_alt = world - .sim() - .get_alt_approx(travel_to.map(|e| e as i32)) - .unwrap_or(0.0) as i32; - let travel_to = terrain - .find_space(Vec3::new( - travel_to.x as i32, - travel_to.y as i32, - travel_to_alt, - )) - .map(|e| e as f32) - + Vec3::new(0.5, 0.5, 0.0); - - self.controller.travel_to = Some((travel_to, destination_name)); - self.controller.speed_factor = 0.90; - Travel::DirectRaid { - target_id, - home_id, - raid_complete, - time_to_move, - } - } - }, - Travel::Idle => Travel::Idle, - }; - - // Forget old memories - self.brain - .memories - .retain(|memory| memory.time_to_forget > time.0); - } -} - -#[derive(Clone, Debug)] -pub enum Travel { - // The initial state all entities start in, and a fallback for when a state has stopped making - // sense. Non humanoids will always revert to this state after reaching their goal since the - // current site they are in doesn't change their behavior. - Lost, - // When an rtsim entity reaches a site it will switch to this state to restart their - // pathfinding from the beginning. Useful when the entity needs to know its current site to - // decide their next target. - InSite { - site_id: Id, - }, - // Move directly to a target site. Used by birds mostly, but also by humands who cannot find a - // path. - Direct { - target_id: Id, - }, - // Follow a custom path to reach the destination. Airships define a custom path to reduce the - // chance of collisions. - CustomPath { - target_id: Id, - path: Vec>, - progress: usize, - }, - // Follow a track defined in the track_map to reach a site. Humanoids do this whenever - // possible. - Path { - target_id: Id, - track_id: Id, - progress: usize, - reversed: bool, - }, - // Move directly towards a target site, then head back to a home territory - DirectRaid { - target_id: Id, - home_id: Id, - raid_complete: bool, - time_to_move: Option, - }, - // For testing purposes - Idle, -} - -// Based on https://en.wikipedia.org/wiki/Big_Five_personality_traits -pub struct PersonalityBase { - openness: u8, - conscientiousness: u8, - extraversion: u8, - agreeableness: u8, - neuroticism: u8, -} - -impl PersonalityBase { - /* All thresholds here are arbitrary "seems right" values. The goal is for - * most NPCs to have some kind of distinguishing trait - something - * interesting about them. We want to avoid Joe Averages. But we also - * don't want everyone to be completely weird. - */ - pub fn to_personality(&self) -> Personality { - let will_ambush = self.agreeableness < Personality::LOW_THRESHOLD - && self.conscientiousness < Personality::LOW_THRESHOLD; - let mut chat_traits: EnumSet = EnumSet::new(); - if self.openness > Personality::HIGH_THRESHOLD { - chat_traits.insert(PersonalityTrait::Open); - if self.neuroticism < Personality::MID { - chat_traits.insert(PersonalityTrait::Adventurous); - } - } else if self.openness < Personality::LOW_THRESHOLD { - chat_traits.insert(PersonalityTrait::Closed); - } - if self.conscientiousness > Personality::HIGH_THRESHOLD { - chat_traits.insert(PersonalityTrait::Conscientious); - if self.agreeableness < Personality::LOW_THRESHOLD { - chat_traits.insert(PersonalityTrait::Busybody); - } - } else if self.conscientiousness < Personality::LOW_THRESHOLD { - chat_traits.insert(PersonalityTrait::Unconscientious); - } - if self.extraversion > Personality::HIGH_THRESHOLD { - chat_traits.insert(PersonalityTrait::Extroverted); - } else if self.extraversion < Personality::LOW_THRESHOLD { - chat_traits.insert(PersonalityTrait::Introverted); - } - if self.agreeableness > Personality::HIGH_THRESHOLD { - chat_traits.insert(PersonalityTrait::Agreeable); - if self.extraversion > Personality::MID { - chat_traits.insert(PersonalityTrait::Sociable); - } - } else if self.agreeableness < Personality::LOW_THRESHOLD { - chat_traits.insert(PersonalityTrait::Disagreeable); - } - if self.neuroticism > Personality::HIGH_THRESHOLD { - chat_traits.insert(PersonalityTrait::Neurotic); - if self.openness > Personality::LITTLE_HIGH { - chat_traits.insert(PersonalityTrait::Seeker); - } - if self.agreeableness > Personality::LITTLE_HIGH { - chat_traits.insert(PersonalityTrait::Worried); - } - if self.extraversion < Personality::LITTLE_LOW { - chat_traits.insert(PersonalityTrait::SadLoner); - } - } else if self.neuroticism < Personality::LOW_THRESHOLD { - chat_traits.insert(PersonalityTrait::Stable); - } - Personality { - personality_traits: chat_traits, - will_ambush, - } - } -} - -pub struct Personality { - pub personality_traits: EnumSet, - pub will_ambush: bool, -} - -#[derive(EnumSetType)] -pub enum PersonalityTrait { - Open, - Adventurous, - Closed, - Conscientious, - Busybody, - Unconscientious, - Extroverted, - Introverted, - Agreeable, - Sociable, - Disagreeable, - Neurotic, - Seeker, - Worried, - SadLoner, - Stable, -} - -impl Personality { - pub const HIGH_THRESHOLD: u8 = Self::MAX - Self::LOW_THRESHOLD; - pub const LITTLE_HIGH: u8 = Self::MID + (Self::MAX - Self::MIN) / 20; - pub const LITTLE_LOW: u8 = Self::MID - (Self::MAX - Self::MIN) / 20; - pub const LOW_THRESHOLD: u8 = (Self::MAX - Self::MIN) / 5 * 2 + Self::MIN; - const MAX: u8 = 100; - pub const MID: u8 = (Self::MAX - Self::MIN) / 2; - const MIN: u8 = 0; - - pub fn random_chat_trait(&self, rng: &mut impl Rng) -> Option { - self.personality_traits.into_iter().choose(rng) - } - - pub fn random_trait_value_bounded(rng: &mut impl Rng, min: u8, max: u8) -> u8 { - let max_third = max / 3; - let min_third = min / 3; - rng.gen_range(min_third..=max_third) - + rng.gen_range(min_third..=max_third) - + rng.gen_range((min - 2 * min_third)..=(max - 2 * max_third)) - } - - pub fn random_trait_value(rng: &mut impl Rng) -> u8 { - Self::random_trait_value_bounded(rng, Self::MIN, Self::MAX) - } - - pub fn random(rng: &mut impl Rng) -> Personality { - let mut random_value = - || rng.gen_range(0..=33) + rng.gen_range(0..=34) + rng.gen_range(0..=33); - let base = PersonalityBase { - openness: random_value(), - conscientiousness: random_value(), - extraversion: random_value(), - agreeableness: random_value(), - neuroticism: random_value(), - }; - base.to_personality() - } -} - -pub struct Brain { - pub begin: Option>, - pub tgt: Option>, - pub route: Travel, - pub last_visited: Option>, - pub memories: Vec, - pub personality: Personality, -} - -impl Brain { - pub fn idle(rng: &mut impl Rng) -> Self { - Self { - begin: None, - tgt: None, - route: Travel::Idle, - last_visited: None, - memories: Vec::new(), - personality: Personality::random(rng), - } - } - - pub fn raid(home_id: Id, target_id: Id, rng: &mut impl Rng) -> Self { - Self { - begin: None, - tgt: None, - route: Travel::DirectRaid { - target_id, - home_id, - raid_complete: false, - time_to_move: None, - }, - last_visited: None, - memories: Vec::new(), - personality: Personality::random(rng), - } - } - - pub fn villager(home_id: Id, rng: &mut impl Rng) -> Self { - Self { - begin: Some(home_id), - tgt: None, - route: Travel::Idle, - last_visited: None, - memories: Vec::new(), - personality: Personality::random(rng), - } - } - - pub fn merchant(home_id: Id, rng: &mut impl Rng) -> Self { - // Merchants are generally extraverted and agreeable - let extraversion_bias = (Personality::MAX - Personality::MIN) / 10 * 3; - let extraversion = - Personality::random_trait_value_bounded(rng, extraversion_bias, Personality::MAX); - let agreeableness_bias = extraversion_bias / 2; - let agreeableness = - Personality::random_trait_value_bounded(rng, agreeableness_bias, Personality::MAX); - let personality_base = PersonalityBase { - openness: Personality::random_trait_value(rng), - conscientiousness: Personality::random_trait_value(rng), - extraversion, - agreeableness, - neuroticism: Personality::random_trait_value(rng), - }; - Self { - begin: Some(home_id), - tgt: None, - route: Travel::Idle, - last_visited: None, - memories: Vec::new(), - personality: personality_base.to_personality(), - } - } - - pub fn town_guard(home_id: Id, rng: &mut impl Rng) -> Self { - Self { - begin: Some(home_id), - tgt: None, - route: Travel::Idle, - last_visited: None, - memories: Vec::new(), - personality: Personality::random(rng), - } - } - - pub fn begin_site(&self) -> Option> { self.begin } - - pub fn add_memory(&mut self, memory: Memory) { self.memories.push(memory); } - - pub fn forget_enemy(&mut self, to_forget: &str) { - self.memories.retain(|memory| { - !matches!( - &memory.item, - MemoryItem::CharacterFight {name, ..} if name == to_forget) - }) - } - - pub fn remembers_mood(&self) -> bool { - self.memories - .iter() - .any(|memory| matches!(&memory.item, MemoryItem::Mood { .. })) - } - - pub fn set_mood(&mut self, memory: Memory) { - if let MemoryItem::Mood { .. } = memory.item { - if self.remembers_mood() { - while let Some(position) = self - .memories - .iter() - .position(|mem| matches!(&mem.item, MemoryItem::Mood { .. })) - { - self.memories.remove(position); - } - } - self.add_memory(memory); - }; - } - - pub fn get_mood(&self) -> Option<&Memory> { - self.memories - .iter() - .find(|memory| matches!(&memory.item, MemoryItem::Mood { .. })) - } - - pub fn remembers_character(&self, name_to_remember: &str) -> bool { - self.memories.iter().any(|memory| { - matches!( - &memory.item, - MemoryItem::CharacterInteraction { name, .. } if name == name_to_remember) - }) - } - - pub fn remembers_fight_with_character(&self, name_to_remember: &str) -> bool { - self.memories.iter().any(|memory| { - matches!( - &memory.item, - MemoryItem::CharacterFight { name, .. } if name == name_to_remember) - }) - } -} - -#[derive(strum::EnumIter)] -enum TravelerRank { - Rank0, - Rank1, - Rank2, - Rank3, -} - -fn humanoid_config(kind: RtSimEntityKind, rank: TravelerRank) -> &'static str { - match kind { - RtSimEntityKind::Cultist => "common.entity.dungeon.tier-5.cultist", - RtSimEntityKind::Wanderer => match rank { - TravelerRank::Rank0 => "common.entity.world.traveler0", - TravelerRank::Rank1 => "common.entity.world.traveler1", - TravelerRank::Rank2 => "common.entity.world.traveler2", - TravelerRank::Rank3 => "common.entity.world.traveler3", - }, - RtSimEntityKind::Villager => "common.entity.village.villager", - RtSimEntityKind::TownGuard => "common.entity.village.guard", - RtSimEntityKind::Merchant => "common.entity.village.merchant", - RtSimEntityKind::Blacksmith => "common.entity.village.blacksmith", - RtSimEntityKind::Chef => "common.entity.village.chef", - RtSimEntityKind::Alchemist => "common.entity.village.alchemist", - RtSimEntityKind::Prisoner => "common.entity.dungeon.sea_chapel.prisoner", - } -} - -fn bird_medium_config(body: comp::bird_medium::Body) -> &'static str { - match body.species { - comp::bird_medium::Species::Duck => "common.entity.wild.peaceful.duck", - comp::bird_medium::Species::Chicken => "common.entity.wild.peaceful.chicken", - comp::bird_medium::Species::Goose => "common.entity.wild.peaceful.goose", - comp::bird_medium::Species::Peacock => "common.entity.wild.peaceful.peacock", - comp::bird_medium::Species::Eagle => "common.entity.wild.peaceful.eagle", - comp::bird_medium::Species::SnowyOwl => "common.entity.wild.peaceful.snowy_owl", - comp::bird_medium::Species::HornedOwl => "common.entity.wild.peaceful.horned_owl", - comp::bird_medium::Species::Parrot => "common.entity.wild.peaceful.parrot", - _ => unimplemented!(), - } -} - -fn bird_large_config(body: comp::bird_large::Body) -> &'static str { - match body.species { - comp::bird_large::Species::Phoenix => "common.entity.wild.peaceful.phoenix", - comp::bird_large::Species::Cockatrice => "common.entity.wild.aggressive.cockatrice", - comp::bird_large::Species::Roc => "common.entity.wild.aggressive.roc", - // Wildcard match used here as there is an array above - // which limits what species are used - _ => unimplemented!(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use common::generation::EntityInfo; - use strum::IntoEnumIterator; - - // Brief, Incomplete and Mostly Wrong Test that all entity configs do exist. - // - // NOTE: Doesn't checks for ships, because we don't produce entity configs - // for them yet. - #[test] - fn test_entity_configs() { - let dummy_pos = Vec3::new(0.0, 0.0, 0.0); - let mut dummy_rng = thread_rng(); - // Bird Large test - for bird_large_species in BIRD_LARGE_ROSTER { - let female_body = comp::bird_large::Body { - species: *bird_large_species, - body_type: comp::bird_large::BodyType::Female, - }; - let male_body = comp::bird_large::Body { - species: *bird_large_species, - body_type: comp::bird_large::BodyType::Male, - }; - - let female_config = bird_large_config(female_body); - drop(EntityInfo::at(dummy_pos).with_asset_expect(female_config, &mut dummy_rng)); - let male_config = bird_large_config(male_body); - drop(EntityInfo::at(dummy_pos).with_asset_expect(male_config, &mut dummy_rng)); - } - // Bird Medium test - for bird_med_species in BIRD_MEDIUM_ROSTER { - let female_body = comp::bird_medium::Body { - species: *bird_med_species, - body_type: comp::bird_medium::BodyType::Female, - }; - let male_body = comp::bird_medium::Body { - species: *bird_med_species, - body_type: comp::bird_medium::BodyType::Male, - }; - - let female_config = bird_medium_config(female_body); - drop(EntityInfo::at(dummy_pos).with_asset_expect(female_config, &mut dummy_rng)); - let male_config = bird_medium_config(male_body); - drop(EntityInfo::at(dummy_pos).with_asset_expect(male_config, &mut dummy_rng)); - } - // Humanoid test - for kind in RtSimEntityKind::iter() { - for rank in TravelerRank::iter() { - let config = humanoid_config(kind, rank); - drop(EntityInfo::at(dummy_pos).with_asset_expect(config, &mut dummy_rng)); - } - } - } -} diff --git a/server/src/rtsim/event.rs b/server/src/rtsim/event.rs new file mode 100644 index 0000000000..0a15a3a2de --- /dev/null +++ b/server/src/rtsim/event.rs @@ -0,0 +1,9 @@ +use common_state::BlockDiff; +use rtsim::Event; + +#[derive(Clone)] +pub struct OnBlockChange { + pub changes: Vec, +} + +impl Event for OnBlockChange {} diff --git a/server/src/rtsim/load_chunks.rs b/server/src/rtsim/load_chunks.rs deleted file mode 100644 index 301037cdae..0000000000 --- a/server/src/rtsim/load_chunks.rs +++ /dev/null @@ -1,20 +0,0 @@ -use super::*; -use common::event::{EventBus, ServerEvent}; -use common_ecs::{Job, Origin, Phase, System}; -use specs::{Read, WriteExpect}; - -#[derive(Default)] -pub struct Sys; -impl<'a> System<'a> for Sys { - type SystemData = (Read<'a, EventBus>, WriteExpect<'a, RtSim>); - - const NAME: &'static str = "rtsim::load_chunks"; - const ORIGIN: Origin = Origin::Server; - const PHASE: Phase = Phase::Create; - - fn run(_job: &mut Job, (_server_event_bus, mut rtsim): Self::SystemData) { - for _chunk in std::mem::take(&mut rtsim.chunks.chunks_to_load) { - // TODO - } - } -} diff --git a/server/src/rtsim/mod.rs b/server/src/rtsim/mod.rs index 21a6fc0365..2f07882f26 100644 --- a/server/src/rtsim/mod.rs +++ b/server/src/rtsim/mod.rs @@ -1,414 +1,284 @@ -#![allow(dead_code)] // TODO: Remove this when rtsim is fleshed out +pub mod event; +pub mod rule; +pub mod tick; -mod chunks; -pub(crate) mod entity; -mod load_chunks; -mod tick; -mod unload_chunks; - -use crate::rtsim::entity::{Personality, Travel}; - -use self::chunks::Chunks; +use atomicwrites::{AtomicFile, OverwriteBehavior}; use common::{ - comp, - rtsim::{Memory, RtSimController, RtSimEntity, RtSimId}, - terrain::TerrainChunk, - vol::RectRasterableVol, + grid::Grid, + rtsim::{Actor, ChunkResource, RtSimEntity, RtSimVehicle, WorldSettings}, }; -use common_ecs::{dispatch, System}; -use common_state::State; -use rand::prelude::*; -use slab::Slab; -use specs::{DispatcherBuilder, WorldExt}; +use common_ecs::dispatch; +use common_state::BlockDiff; +use crossbeam_channel::{unbounded, Receiver, Sender}; +use enum_map::EnumMap; +use rtsim::{ + data::{npc::SimulationMode, Data, ReadError}, + event::{OnDeath, OnSetup}, + RtState, +}; +use specs::DispatcherBuilder; +use std::{ + error::Error, + fs::{self, File}, + io, + path::PathBuf, + thread::{self, JoinHandle}, + time::Instant, +}; +use tracing::{debug, error, info, trace, warn}; use vek::*; - -pub use self::entity::{Brain, Entity, RtSimEntityKind}; +use world::{IndexRef, World}; pub struct RtSim { - tick: u64, - chunks: Chunks, - entities: Slab, + file_path: PathBuf, + last_saved: Option, + state: RtState, + save_thread: Option<(Sender, JoinHandle<()>)>, } impl RtSim { - pub fn new(world_chunk_size: Vec2) -> Self { - Self { - tick: 0, - chunks: Chunks::new(world_chunk_size), - entities: Slab::new(), - } + pub fn new( + settings: &WorldSettings, + index: IndexRef, + world: &World, + data_dir: PathBuf, + ) -> Result { + let file_path = Self::get_file_path(data_dir); + + info!("Looking for rtsim data at {}...", file_path.display()); + let data = 'load: { + if std::env::var("RTSIM_NOLOAD").map_or(true, |v| v != "1") { + match File::open(&file_path) { + Ok(file) => { + info!("Rtsim data found. Attempting to load..."); + + let ignore_version = std::env::var("RTSIM_IGNORE_VERSION").is_ok(); + + match Data::from_reader(io::BufReader::new(file)) { + Err(ReadError::VersionMismatch(_)) if !ignore_version => { + warn!( + "Rtsim data version mismatch (implying a breaking change), \ + rtsim data will be purged" + ); + }, + Ok(data) | Err(ReadError::VersionMismatch(data)) => { + info!("Rtsim data loaded."); + if data.should_purge { + warn!( + "The should_purge flag was set on the rtsim data, \ + generating afresh" + ); + } else { + break 'load *data; + } + }, + Err(ReadError::Load(err)) => { + error!("Rtsim data failed to load: {}", err); + info!("Old rtsim data will now be moved to a backup file"); + let mut i = 0; + loop { + let mut backup_path = file_path.clone(); + backup_path.set_extension(if i == 0 { + "ron_backup".to_string() + } else { + format!("ron_backup_{}", i) + }); + if !backup_path.exists() { + fs::rename(&file_path, &backup_path)?; + warn!( + "Failed rtsim data was moved to {}", + backup_path.display() + ); + info!("A fresh rtsim data will now be generated."); + break; + } else { + info!( + "Backup file {} already exists, trying another name...", + backup_path.display() + ); + } + i += 1; + } + }, + } + }, + Err(e) if e.kind() == io::ErrorKind::NotFound => { + info!("No rtsim data found. Generating from world...") + }, + Err(e) => return Err(e.into()), + } + } else { + warn!( + "'RTSIM_NOLOAD' is set, skipping loading of rtsim state (old state will be \ + overwritten)." + ); + } + + let data = Data::generate(settings, world, index); + info!("Rtsim data generated."); + data + }; + + let mut this = Self { + last_saved: None, + state: RtState::new(data).with_resource(ChunkStates(Grid::populate_from( + world.sim().get_size().as_(), + |_| None, + ))), + file_path, + save_thread: None, + }; + + rule::start_rules(&mut this.state); + + this.state.emit(OnSetup, world, index); + + Ok(this) } - pub fn hook_load_chunk(&mut self, key: Vec2) { - if let Some(chunk) = self.chunks.chunk_mut(key) { - if !chunk.is_loaded { - chunk.is_loaded = true; - self.chunks.chunks_to_load.push(key); - } + fn get_file_path(mut data_dir: PathBuf) -> PathBuf { + let mut path = std::env::var("VELOREN_RTSIM") + .map(PathBuf::from) + .unwrap_or_else(|_| { + data_dir.push("rtsim"); + data_dir + }); + path.push("data.dat"); + path + } + + pub fn hook_load_chunk(&mut self, key: Vec2, max_res: EnumMap) { + if let Some(chunk_state) = self.state.get_resource_mut::().0.get_mut(key) { + *chunk_state = Some(LoadedChunkState { max_res }); } } pub fn hook_unload_chunk(&mut self, key: Vec2) { - if let Some(chunk) = self.chunks.chunk_mut(key) { - if chunk.is_loaded { - chunk.is_loaded = false; - self.chunks.chunks_to_unload.push(key); + if let Some(chunk_state) = self.state.get_resource_mut::().0.get_mut(key) { + *chunk_state = None; + } + } + + // Note that this hook only needs to be invoked if the block change results in a + // change to the rtsim resource produced by [`Block::get_rtsim_resource`]. + pub fn hook_block_update(&mut self, world: &World, index: IndexRef, changes: Vec) { + self.state + .emit(event::OnBlockChange { changes }, world, index); + } + + pub fn hook_rtsim_entity_unload(&mut self, entity: RtSimEntity) { + if let Some(npc) = self.state.get_data_mut().npcs.get_mut(entity.0) { + npc.mode = SimulationMode::Simulated; + } + } + + pub fn hook_rtsim_vehicle_unload(&mut self, entity: RtSimVehicle) { + if let Some(vehicle) = self.state.get_data_mut().npcs.vehicles.get_mut(entity.0) { + vehicle.mode = SimulationMode::Simulated; + } + } + + pub fn hook_rtsim_actor_death( + &mut self, + world: &World, + index: IndexRef, + actor: Actor, + wpos: Option>, + killer: Option, + ) { + self.state.emit( + OnDeath { + wpos, + actor, + killer, + }, + world, + index, + ); + } + + pub fn save(&mut self, wait_until_finished: bool) { + debug!("Saving rtsim data..."); + + // Create the save thread if it doesn't already exist + // We're not using the slow job pool here for two reasons: + // 1) The thread is mostly blocked on IO, not compute + // 2) We need to synchronise saves to ensure monotonicity, which slow jobs + // aren't designed to allow + let (tx, _) = self.save_thread.get_or_insert_with(|| { + trace!("Starting rtsim data save thread..."); + let (tx, rx) = unbounded(); + let file_path = self.file_path.clone(); + (tx, thread::spawn(move || save_thread(file_path, rx))) + }); + + // Send rtsim data to the save thread + if let Err(err) = tx.send(self.state.data().clone()) { + error!("Failed to perform rtsim save: {}", err); + } + + // If we need to wait until the save thread has done its work (due to, for + // example, server shutdown) then do that. + if wait_until_finished { + if let Some((tx, handle)) = self.save_thread.take() { + drop(tx); + info!("Waiting for rtsim save thread to finish..."); + handle.join().expect("Save thread failed to join"); + info!("Rtsim save thread finished."); } } + + self.last_saved = Some(Instant::now()); } - pub fn assimilate_entity(&mut self, entity: RtSimId) { - // tracing::info!("Assimilated rtsim entity {}", entity); - self.entities.get_mut(entity).map(|e| e.is_loaded = false); + // TODO: Clean up this API a bit + pub fn get_chunk_resources(&self, key: Vec2) -> EnumMap { + self.state.data().nature.get_chunk_resources(key) } - pub fn reify_entity(&mut self, entity: RtSimId) { - // tracing::info!("Reified rtsim entity {}", entity); - self.entities.get_mut(entity).map(|e| e.is_loaded = true); + pub fn state(&self) -> &RtState { &self.state } + + pub fn set_should_purge(&mut self, should_purge: bool) { + self.state.data_mut().should_purge = should_purge; } +} - pub fn update_entity(&mut self, entity: RtSimId, pos: Vec3) { - self.entities.get_mut(entity).map(|e| e.pos = pos); - } - - pub fn destroy_entity(&mut self, entity: RtSimId) { - // tracing::info!("Destroyed rtsim entity {}", entity); - self.entities.remove(entity); - } - - pub fn get_entity(&self, entity: RtSimId) -> Option<&Entity> { self.entities.get(entity) } - - pub fn insert_entity_memory(&mut self, entity: RtSimId, memory: Memory) { - self.entities - .get_mut(entity) - .map(|entity| entity.brain.add_memory(memory)); - } - - pub fn forget_entity_enemy(&mut self, entity: RtSimId, name: &str) { - if let Some(entity) = self.entities.get_mut(entity) { - entity.brain.forget_enemy(name); +fn save_thread(file_path: PathBuf, rx: Receiver) { + while let Ok(data) = rx.recv() { + if let Err(e) = file_path + .parent() + .map(|dir| { + fs::create_dir_all(dir)?; + // We write to a temporary file and then rename to avoid corruption. + Ok(dir.join(&file_path)) + }) + .unwrap_or_else(|| Ok(file_path.clone())) + .map(|file_path| AtomicFile::new(file_path, OverwriteBehavior::AllowOverwrite)) + .map_err(|e: io::Error| Box::new(e) as Box) + .and_then(|file| { + debug!("Writing rtsim data to file..."); + file.write(move |file| -> Result<(), rtsim::data::WriteError> { + data.write_to(io::BufWriter::new(file))?; + // file.flush()?; + Ok(()) + })?; + drop(file); + debug!("Rtsim data saved."); + Ok(()) + }) + { + error!("Saving rtsim data failed: {}", e); } } +} - pub fn set_entity_mood(&mut self, entity: RtSimId, memory: Memory) { - self.entities - .get_mut(entity) - .map(|entity| entity.brain.set_mood(memory)); - } +pub struct ChunkStates(pub Grid>); + +pub struct LoadedChunkState { + // The maximum possible number of each resource in this chunk + pub max_res: EnumMap, } pub fn add_server_systems(dispatch_builder: &mut DispatcherBuilder) { - dispatch::(dispatch_builder, &[]); - dispatch::(dispatch_builder, &[&unload_chunks::Sys::sys_name()]); - dispatch::(dispatch_builder, &[ - &load_chunks::Sys::sys_name(), - &unload_chunks::Sys::sys_name(), - ]); -} - -pub fn init( - state: &mut State, - #[cfg(feature = "worldgen")] world: &world::World, - #[cfg(feature = "worldgen")] index: world::IndexRef, -) { - #[cfg(feature = "worldgen")] - let mut rtsim = RtSim::new(world.sim().get_size()); - #[cfg(not(feature = "worldgen"))] - let mut rtsim = RtSim::new(Vec2::new(40, 40)); - - // TODO: Determine number of rtsim entities based on things like initial site - // populations rather than world size - #[cfg(feature = "worldgen")] - { - for _ in 0..world.sim().get_size().product() / 400 { - let pos = rtsim - .chunks - .size() - .map2(TerrainChunk::RECT_SIZE, |sz, chunk_sz| { - thread_rng().gen_range(0..sz * chunk_sz) as i32 - }); - - rtsim.entities.insert(Entity { - is_loaded: false, - pos: Vec3::from(pos.map(|e| e as f32)), - seed: thread_rng().gen(), - controller: RtSimController::default(), - last_time_ticked: 0.0, - kind: RtSimEntityKind::Wanderer, - brain: Brain { - begin: None, - tgt: None, - route: Travel::Lost, - last_visited: None, - memories: Vec::new(), - personality: Personality::random(&mut thread_rng()), - }, - }); - } - for (site_id, site) in world - .civs() - .sites - .iter() - .filter_map(|(site_id, site)| site.site_tmp.map(|id| (site_id, &index.sites[id]))) - { - use world::site::SiteKind; - match &site.kind { - #[allow(clippy::single_match)] - SiteKind::Dungeon(dungeon) => match dungeon.dungeon_difficulty() { - Some(5) => { - let pos = site.get_origin(); - if let Some(nearest_village) = world - .civs() - .sites - .iter() - .filter(|(_, site)| site.is_settlement()) - .min_by_key(|(_, site)| { - let wpos = site.center * TerrainChunk::RECT_SIZE.map(|e| e as i32); - wpos.map(|e| e as f32) - .distance_squared(pos.map(|x| x as f32)) - as u32 - }) - .map(|(id, _)| id) - { - for _ in 0..25 { - rtsim.entities.insert(Entity { - is_loaded: false, - pos: Vec3::from(pos.map(|e| e as f32)), - seed: thread_rng().gen(), - controller: RtSimController::default(), - last_time_ticked: 0.0, - kind: RtSimEntityKind::Cultist, - brain: Brain::raid(site_id, nearest_village, &mut thread_rng()), - }); - } - } - }, - _ => {}, - }, - SiteKind::Refactor(site2) => { - // villagers - for _ in 0..site.economy.population().min(site2.plots().len() as f32) as usize { - rtsim.entities.insert(Entity { - is_loaded: false, - pos: site2 - .plots() - .choose(&mut thread_rng()) - .map_or(site.get_origin(), |plot| { - site2.tile_center_wpos(plot.root_tile()) - }) - .with_z(0) - .map(|e| e as f32), - seed: thread_rng().gen(), - controller: RtSimController::default(), - last_time_ticked: 0.0, - kind: RtSimEntityKind::Villager, - brain: Brain::villager(site_id, &mut thread_rng()), - }); - } - - // guards - for _ in 0..site2.plazas().len() { - rtsim.entities.insert(Entity { - is_loaded: false, - pos: site2 - .plazas() - .choose(&mut thread_rng()) - .map_or(site.get_origin(), |p| { - site2.tile_center_wpos(site2.plot(p).root_tile()) - + Vec2::new( - thread_rng().gen_range(-8..9), - thread_rng().gen_range(-8..9), - ) - }) - .with_z(0) - .map(|e| e as f32), - seed: thread_rng().gen(), - controller: RtSimController::default(), - last_time_ticked: 0.0, - kind: RtSimEntityKind::TownGuard, - brain: Brain::town_guard(site_id, &mut thread_rng()), - }); - } - - // merchants - for _ in 0..site2.plazas().len() { - rtsim.entities.insert(Entity { - is_loaded: false, - pos: site2 - .plazas() - .choose(&mut thread_rng()) - .map_or(site.get_origin(), |p| { - site2.tile_center_wpos(site2.plot(p).root_tile()) - + Vec2::new( - thread_rng().gen_range(-8..9), - thread_rng().gen_range(-8..9), - ) - }) - .with_z(0) - .map(|e| e as f32), - seed: thread_rng().gen(), - controller: RtSimController::default(), - last_time_ticked: 0.0, - kind: RtSimEntityKind::Merchant, - brain: Brain::merchant(site_id, &mut thread_rng()), - }); - } - }, - SiteKind::CliffTown(site2) => { - for _ in 0..(site2.plazas().len() as f32 * 1.5) as usize { - rtsim.entities.insert(Entity { - is_loaded: false, - pos: site2 - .plazas() - .choose(&mut thread_rng()) - .map_or(site.get_origin(), |p| { - site2.tile_center_wpos(site2.plot(p).root_tile()) - + Vec2::new( - thread_rng().gen_range(-8..9), - thread_rng().gen_range(-8..9), - ) - }) - .with_z(0) - .map(|e| e as f32), - seed: thread_rng().gen(), - controller: RtSimController::default(), - last_time_ticked: 0.0, - kind: RtSimEntityKind::Merchant, - brain: Brain::merchant(site_id, &mut thread_rng()), - }); - } - }, - SiteKind::SavannahPit(site2) => { - for _ in 0..4 { - rtsim.entities.insert(Entity { - is_loaded: false, - pos: site2 - .plots() - .filter(|plot| { - matches!(plot.kind(), world::site2::PlotKind::SavannahPit(_)) - }) - .choose(&mut thread_rng()) - .map_or(site.get_origin(), |plot| { - site2.tile_center_wpos( - plot.root_tile() - + Vec2::new( - thread_rng().gen_range(-5..5), - thread_rng().gen_range(-5..5), - ), - ) - }) - .with_z(0) - .map(|e| e as f32), - seed: thread_rng().gen(), - controller: RtSimController::default(), - last_time_ticked: 0.0, - kind: RtSimEntityKind::Merchant, - brain: Brain::merchant(site_id, &mut thread_rng()), - }); - } - }, - SiteKind::DesertCity(site2) => { - // villagers - for _ in 0..(site2.plazas().len() as f32 * 1.5) as usize { - rtsim.entities.insert(Entity { - is_loaded: false, - pos: site2 - .plots() - .choose(&mut thread_rng()) - .map_or(site.get_origin(), |plot| { - site2.tile_center_wpos(plot.root_tile()) - }) - .with_z(0) - .map(|e| e as f32), - seed: thread_rng().gen(), - controller: RtSimController::default(), - last_time_ticked: 0.0, - kind: RtSimEntityKind::Villager, - brain: Brain::villager(site_id, &mut thread_rng()), - }); - } - - // guards - for _ in 0..site2.plazas().len() { - rtsim.entities.insert(Entity { - is_loaded: false, - pos: site2 - .plazas() - .choose(&mut thread_rng()) - .map_or(site.get_origin(), |p| { - site2.tile_center_wpos(site2.plot(p).root_tile()) - + Vec2::new( - thread_rng().gen_range(-8..9), - thread_rng().gen_range(-8..9), - ) - }) - .with_z(0) - .map(|e| e as f32), - seed: thread_rng().gen(), - controller: RtSimController::default(), - last_time_ticked: 0.0, - kind: RtSimEntityKind::TownGuard, - brain: Brain::town_guard(site_id, &mut thread_rng()), - }); - } - - // merchants - for _ in 0..site2.plazas().len() { - rtsim.entities.insert(Entity { - is_loaded: false, - pos: site2 - .plazas() - .choose(&mut thread_rng()) - .map_or(site.get_origin(), |p| { - site2.tile_center_wpos(site2.plot(p).root_tile()) - + Vec2::new( - thread_rng().gen_range(-8..9), - thread_rng().gen_range(-8..9), - ) - }) - .with_z(0) - .map(|e| e as f32), - seed: thread_rng().gen(), - controller: RtSimController::default(), - last_time_ticked: 0.0, - kind: RtSimEntityKind::Merchant, - brain: Brain::merchant(site_id, &mut thread_rng()), - }); - } - }, - SiteKind::ChapelSite(site2) => { - // prisoners - for _ in 0..10 { - rtsim.entities.insert(Entity { - is_loaded: false, - pos: site2 - .plots() - .filter(|plot| { - matches!(plot.kind(), world::site2::PlotKind::SeaChapel(_)) - }) - .choose(&mut thread_rng()) - .map_or(site.get_origin(), |plot| { - site2.tile_center_wpos(Vec2::new( - plot.root_tile().x, - plot.root_tile().y + 4, - )) - }) - .with_z(0) - .map(|e| e as f32), - seed: thread_rng().gen(), - controller: RtSimController::default(), - last_time_ticked: 0.0, - kind: RtSimEntityKind::Prisoner, - brain: Brain::villager(site_id, &mut thread_rng()), - }); - } - }, - _ => {}, - } - } - } - - state.ecs_mut().insert(rtsim); - state.ecs_mut().register::(); - tracing::info!("Initiated real-time world simulation"); + dispatch::(dispatch_builder, &[]); } diff --git a/server/src/rtsim/rule.rs b/server/src/rtsim/rule.rs new file mode 100644 index 0000000000..2c499c1c60 --- /dev/null +++ b/server/src/rtsim/rule.rs @@ -0,0 +1,9 @@ +pub mod deplete_resources; + +use rtsim::RtState; +use tracing::info; + +pub fn start_rules(rtstate: &mut RtState) { + info!("Starting server rtsim rules..."); + rtstate.start_rule::(); +} diff --git a/server/src/rtsim/rule/deplete_resources.rs b/server/src/rtsim/rule/deplete_resources.rs new file mode 100644 index 0000000000..01e05e25d3 --- /dev/null +++ b/server/src/rtsim/rule/deplete_resources.rs @@ -0,0 +1,44 @@ +use crate::rtsim::{event::OnBlockChange, ChunkStates}; +use common::terrain::CoordinateConversions; +use rtsim::{RtState, Rule, RuleError}; + +pub struct DepleteResources; + +impl Rule for DepleteResources { + fn start(rtstate: &mut RtState) -> Result { + rtstate.bind::(|ctx| { + let chunk_states = ctx.state.resource::(); + let mut data = ctx.state.data_mut(); + for change in &ctx.event.changes { + let key = change.wpos.xy().wpos_to_cpos(); + if let Some(Some(chunk_state)) = chunk_states.0.get(key) { + let mut chunk_res = data.nature.get_chunk_resources(key); + // Remove resources + if let Some(res) = change.old.get_rtsim_resource() { + if chunk_state.max_res[res] > 0 { + chunk_res[res] = (chunk_res[res] * chunk_state.max_res[res] as f32 + - 1.0) + .round() + .max(0.0) + / chunk_state.max_res[res] as f32; + } + } + // Replenish resources + if let Some(res) = change.new.get_rtsim_resource() { + if chunk_state.max_res[res] > 0 { + chunk_res[res] = (chunk_res[res] * chunk_state.max_res[res] as f32 + + 1.0) + .round() + .max(0.0) + / chunk_state.max_res[res] as f32; + } + } + + data.nature.set_chunk_resources(key, chunk_res); + } + } + }); + + Ok(Self) + } +} diff --git a/server/src/rtsim/tick.rs b/server/src/rtsim/tick.rs index 6b6571904f..31956ca1c6 100644 --- a/server/src/rtsim/tick.rs +++ b/server/src/rtsim/tick.rs @@ -3,30 +3,199 @@ use super::*; use crate::sys::terrain::NpcData; use common::{ - comp, - event::{EventBus, ServerEvent}, + comp::{self, Body, Presence, PresenceKind}, + event::{EventBus, NpcBuilder, ServerEvent}, generation::{BodyBuilder, EntityConfig, EntityInfo}, - resources::{DeltaTime, Time}, - terrain::TerrainGrid, + resources::{DeltaTime, Time, TimeOfDay}, + rtsim::{Actor, RtSimEntity, RtSimVehicle}, + slowjob::SlowJobPool, + terrain::CoordinateConversions, + trade::{Good, SiteInformation}, + LoadoutBuilder, }; use common_ecs::{Job, Origin, Phase, System}; +use rtsim::data::{ + npc::{Profession, SimulationMode}, + Npc, Sites, +}; use specs::{Join, Read, ReadExpect, ReadStorage, WriteExpect, WriteStorage}; -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; +use tracing::error; +use world::site::settlement::trader_loadout; + +fn humanoid_config(profession: &Profession) -> &'static str { + match profession { + Profession::Farmer => "common.entity.village.farmer", + Profession::Hunter => "common.entity.village.hunter", + Profession::Herbalist => "common.entity.village.herbalist", + Profession::Captain => "common.entity.village.captain", + Profession::Merchant => "common.entity.village.merchant", + Profession::Guard => "common.entity.village.guard", + Profession::Adventurer(rank) => match rank { + 0 => "common.entity.world.traveler0", + 1 => "common.entity.world.traveler1", + 2 => "common.entity.world.traveler2", + 3 => "common.entity.world.traveler3", + _ => { + error!( + "Tried to get configuration for invalid adventurer rank {}", + rank + ); + "common.entity.world.traveler3" + }, + }, + Profession::Blacksmith => "common.entity.village.blacksmith", + Profession::Chef => "common.entity.village.chef", + Profession::Alchemist => "common.entity.village.alchemist", + Profession::Pirate => "common.entity.spot.pirate", + Profession::Cultist => "common.entity.dungeon.tier-5.cultist", + } +} + +fn loadout_default(loadout: LoadoutBuilder, _economy: Option<&SiteInformation>) -> LoadoutBuilder { + loadout +} + +fn merchant_loadout( + loadout_builder: LoadoutBuilder, + economy: Option<&SiteInformation>, +) -> LoadoutBuilder { + trader_loadout(loadout_builder, economy, |_| true) +} + +fn farmer_loadout( + loadout_builder: LoadoutBuilder, + economy: Option<&SiteInformation>, +) -> LoadoutBuilder { + trader_loadout(loadout_builder, economy, |good| matches!(good, Good::Food)) +} + +fn herbalist_loadout( + loadout_builder: LoadoutBuilder, + economy: Option<&SiteInformation>, +) -> LoadoutBuilder { + trader_loadout(loadout_builder, economy, |good| { + matches!(good, Good::Ingredients) + }) +} + +fn chef_loadout( + loadout_builder: LoadoutBuilder, + economy: Option<&SiteInformation>, +) -> LoadoutBuilder { + trader_loadout(loadout_builder, economy, |good| matches!(good, Good::Food)) +} + +fn blacksmith_loadout( + loadout_builder: LoadoutBuilder, + economy: Option<&SiteInformation>, +) -> LoadoutBuilder { + trader_loadout(loadout_builder, economy, |good| { + matches!(good, Good::Tools | Good::Armor) + }) +} + +fn alchemist_loadout( + loadout_builder: LoadoutBuilder, + economy: Option<&SiteInformation>, +) -> LoadoutBuilder { + trader_loadout(loadout_builder, economy, |good| { + matches!(good, Good::Potions) + }) +} + +fn profession_extra_loadout( + profession: Option<&Profession>, +) -> fn(LoadoutBuilder, Option<&SiteInformation>) -> LoadoutBuilder { + match profession { + Some(Profession::Merchant) => merchant_loadout, + Some(Profession::Farmer) => farmer_loadout, + Some(Profession::Herbalist) => herbalist_loadout, + Some(Profession::Chef) => chef_loadout, + Some(Profession::Blacksmith) => blacksmith_loadout, + Some(Profession::Alchemist) => alchemist_loadout, + _ => loadout_default, + } +} + +fn profession_agent_mark(profession: Option<&Profession>) -> Option { + match profession { + Some( + Profession::Merchant + | Profession::Farmer + | Profession::Herbalist + | Profession::Chef + | Profession::Blacksmith + | Profession::Alchemist, + ) => Some(comp::agent::Mark::Merchant), + Some(Profession::Guard) => Some(comp::agent::Mark::Guard), + _ => None, + } +} + +fn get_npc_entity_info(npc: &Npc, sites: &Sites, index: IndexRef) -> EntityInfo { + let pos = comp::Pos(npc.wpos); + + let mut rng = npc.rng(Npc::PERM_ENTITY_CONFIG); + if let Some(ref profession) = npc.profession { + let economy = npc.home.and_then(|home| { + let site = sites.get(home)?.world_site?; + index.sites.get(site).trade_information(site.id()) + }); + + let config_asset = humanoid_config(profession); + + let entity_config = EntityConfig::from_asset_expect_owned(config_asset) + .with_body(BodyBuilder::Exact(npc.body)); + EntityInfo::at(pos.0) + .with_entity_config(entity_config, Some(config_asset), &mut rng) + .with_alignment(if matches!(profession, Profession::Cultist) { + comp::Alignment::Enemy + } else { + comp::Alignment::Npc + }) + .with_economy(economy.as_ref()) + .with_lazy_loadout(profession_extra_loadout(npc.profession.as_ref())) + .with_alias(npc.get_name()) + .with_agent_mark(profession_agent_mark(npc.profession.as_ref())) + } else { + let config_asset = match npc.body { + Body::BirdLarge(body) => match body.species { + comp::bird_large::Species::Phoenix => "common.entity.wild.peaceful.phoenix", + comp::bird_large::Species::Cockatrice => "common.entity.wild.aggressive.cockatrice", + comp::bird_large::Species::Roc => "common.entity.wild.aggressive.roc", + // Wildcard match used here as there is an array above + // which limits what species are used + _ => unimplemented!(), + }, + _ => unimplemented!(), + }; + let entity_config = EntityConfig::from_asset_expect_owned(config_asset) + .with_body(BodyBuilder::Exact(npc.body)); + + EntityInfo::at(pos.0) + .with_entity_config(entity_config, Some(config_asset), &mut rng) + .with_alignment(comp::Alignment::Wild) + } +} #[derive(Default)] pub struct Sys; impl<'a> System<'a> for Sys { type SystemData = ( - Read<'a, Time>, Read<'a, DeltaTime>, + Read<'a, Time>, + Read<'a, TimeOfDay>, Read<'a, EventBus>, WriteExpect<'a, RtSim>, - ReadExpect<'a, TerrainGrid>, ReadExpect<'a, Arc>, ReadExpect<'a, world::IndexOwned>, + ReadExpect<'a, SlowJobPool>, ReadStorage<'a, comp::Pos>, ReadStorage<'a, RtSimEntity>, + ReadStorage<'a, RtSimVehicle>, WriteStorage<'a, comp::Agent>, + ReadStorage<'a, Presence>, ); const NAME: &'static str = "rtsim::tick"; @@ -36,114 +205,136 @@ impl<'a> System<'a> for Sys { fn run( _job: &mut Job, ( + dt, time, - _dt, + time_of_day, server_event_bus, mut rtsim, - terrain, world, index, + slow_jobs, positions, rtsim_entities, + rtsim_vehicles, mut agents, + presences, ): Self::SystemData, ) { + let mut emitter = server_event_bus.emitter(); let rtsim = &mut *rtsim; - rtsim.tick += 1; - // Update unloaded rtsim entities, in groups at a time - const TICK_STAGGER: usize = 30; - let entities_per_iteration = rtsim.entities.len() / TICK_STAGGER; - let mut to_reify = Vec::new(); - for (id, entity) in rtsim - .entities - .iter_mut() - .skip((rtsim.tick as usize % TICK_STAGGER) * entities_per_iteration) - .take(entities_per_iteration) - .filter(|(_, e)| !e.is_loaded) + // Set up rtsim inputs { - // Calculating dt ourselves because the dt provided to this fn was since the - // last frame, not since the last iteration that these entities acted - let dt = (time.0 - entity.last_time_ticked) as f32; - entity.last_time_ticked = time.0; + let mut data = rtsim.state.data_mut(); - if rtsim - .chunks - .chunk_at(entity.pos.xy()) - .map(|c| c.is_loaded) - .unwrap_or(false) - { - to_reify.push(id); - } else { - // Simulate behaviour - if let Some(travel_to) = &entity.controller.travel_to { - // Move towards target at approximate character speed - entity.pos += Vec3::from( - (travel_to.0.xy() - entity.pos.xy()) - .try_normalized() - .unwrap_or_else(Vec2::zero) - * entity.get_body().max_speed_approx() - * entity.controller.speed_factor, - ) * dt; - } + // Update time of day + data.time_of_day = *time_of_day; - if let Some(alt) = world - .sim() - .get_alt_approx(entity.pos.xy().map(|e| e.floor() as i32)) - { - entity.pos.z = alt; + // Update character map (i.e: so that rtsim knows where players are) + // TODO: Other entities too like animals? Or do we now care about that? + data.npcs.character_map.clear(); + for (presence, wpos) in (&presences, &positions).join() { + if let PresenceKind::Character(character) = &presence.kind { + let chunk_pos = wpos.0.xy().as_().wpos_to_cpos(); + data.npcs + .character_map + .entry(chunk_pos) + .or_default() + .push((*character, wpos.0)); } } - entity.tick(&time, &terrain, &world, &index.as_index_ref()); } - // Tick entity AI each time if it's loaded - for (_, entity) in rtsim.entities.iter_mut().filter(|(_, e)| e.is_loaded) { - entity.last_time_ticked = time.0; - entity.tick(&time, &terrain, &world, &index.as_index_ref()); + // Tick rtsim + rtsim + .state + .tick(&world, index.as_index_ref(), *time_of_day, *time, dt.0); + + // Perform a save if required + if rtsim + .last_saved + .map_or(true, |ls| ls.elapsed() > Duration::from_secs(60)) + { + // TODO: Use slow jobs + let _ = slow_jobs; + rtsim.save(/* &slow_jobs, */ false); } - let mut server_emitter = server_event_bus.emitter(); - for id in to_reify { - rtsim.reify_entity(id); - let entity = &rtsim.entities[id]; - let rtsim_entity = Some(RtSimEntity(id)); + let chunk_states = rtsim.state.resource::(); + let data = &mut *rtsim.state.data_mut(); - let body = entity.get_body(); - let spawn_pos = terrain - .find_space(entity.pos.map(|e| e.floor() as i32)) - .map(|e| e as f32) - + Vec3::new(0.5, 0.5, body.flying_height()); + // Load in vehicles + for (vehicle_id, vehicle) in data.npcs.vehicles.iter_mut() { + let chunk = vehicle.wpos.xy().as_::().wpos_to_cpos(); - let pos = comp::Pos(spawn_pos); + if matches!(vehicle.mode, SimulationMode::Simulated) + && chunk_states.0.get(chunk).map_or(false, |c| c.is_some()) + { + vehicle.mode = SimulationMode::Loaded; - let event = if let comp::Body::Ship(ship) = body { - ServerEvent::CreateShip { - pos, - ship, - mountable: false, - agent: Some(comp::Agent::from_body(&body)), - rtsim_entity, - } - } else { - let entity_config_path = entity.get_entity_config(); - let mut loadout_rng = entity.loadout_rng(); - let ad_hoc_loadout = entity.get_adhoc_loadout(); - // Body is rewritten so that body parameters - // are consistent between reifications - let entity_config = EntityConfig::from_asset_expect_owned(entity_config_path) - .with_body(BodyBuilder::Exact(body)); + let mut actor_info = |actor: Actor| { + let npc_id = actor.npc()?; + let npc = data.npcs.npcs.get_mut(npc_id)?; + if matches!(npc.mode, SimulationMode::Simulated) { + npc.mode = SimulationMode::Loaded; + let entity_info = + get_npc_entity_info(npc, &data.sites, index.as_index_ref()); - let mut entity_info = EntityInfo::at(pos.0) - .with_entity_config(entity_config, Some(entity_config_path), &mut loadout_rng) - .with_lazy_loadout(ad_hoc_loadout); - // Merchants can be traded with - if let Some(economy) = entity.get_trade_info(&world, &index) { - entity_info = entity_info - .with_agent_mark(comp::agent::Mark::Merchant) - .with_economy(&economy); - } - match NpcData::from_entity_info(entity_info) { + Some(match NpcData::from_entity_info(entity_info) { + NpcData::Data { + pos: _, + stats, + skill_set, + health, + poise, + inventory, + agent, + body, + alignment, + scale, + loot, + } => NpcBuilder::new(stats, body, alignment) + .with_skill_set(skill_set) + .with_health(health) + .with_poise(poise) + .with_inventory(inventory) + .with_agent(agent) + .with_scale(scale) + .with_loot(loot) + .with_rtsim(RtSimEntity(npc_id)), + // EntityConfig can't represent Waypoints at all + // as of now, and if someone will try to spawn + // rtsim waypoint it is definitely error. + NpcData::Waypoint(_) => unimplemented!(), + }) + } else { + error!("Npc is loaded but vehicle is unloaded"); + None + } + }; + + emitter.emit(ServerEvent::CreateShip { + pos: comp::Pos(vehicle.wpos), + ship: vehicle.body, + rtsim_entity: Some(RtSimVehicle(vehicle_id)), + driver: vehicle.driver.and_then(&mut actor_info), + }); + } + } + + // Load in NPCs + for (npc_id, npc) in data.npcs.npcs.iter_mut() { + let chunk = npc.wpos.xy().as_::().wpos_to_cpos(); + + // Load the NPC into the world if it's in a loaded chunk and is not already + // loaded + if matches!(npc.mode, SimulationMode::Simulated) + && chunk_states.0.get(chunk).map_or(false, |c| c.is_some()) + { + npc.mode = SimulationMode::Loaded; + let entity_info = get_npc_entity_info(npc, &data.sites, index.as_index_ref()); + + emitter.emit(match NpcData::from_entity_info(entity_info) { NpcData::Data { pos, stats, @@ -158,38 +349,56 @@ impl<'a> System<'a> for Sys { loot, } => ServerEvent::CreateNpc { pos, - stats, - skill_set, - health, - poise, - inventory, - agent, - body, - alignment, - scale, - anchor: None, - loot, - rtsim_entity, - projectile: None, + npc: NpcBuilder::new(stats, body, alignment) + .with_skill_set(skill_set) + .with_health(health) + .with_poise(poise) + .with_inventory(inventory) + .with_agent(agent) + .with_scale(scale) + .with_loot(loot) + .with_rtsim(RtSimEntity(npc_id)), }, // EntityConfig can't represent Waypoints at all // as of now, and if someone will try to spawn // rtsim waypoint it is definitely error. NpcData::Waypoint(_) => unimplemented!(), - } - }; - server_emitter.emit(event); + }); + } } - // Update rtsim with real entity data - for (pos, rtsim_entity, agent) in (&positions, &rtsim_entities, &mut agents).join() { - rtsim - .entities + // Synchronise rtsim NPC with entity data + for (pos, rtsim_vehicle) in (&positions, &rtsim_vehicles).join() { + data.npcs + .vehicles + .get_mut(rtsim_vehicle.0) + .filter(|npc| matches!(npc.mode, SimulationMode::Loaded)) + .map(|vehicle| { + // Update rtsim NPC state + vehicle.wpos = pos.0; + }); + } + + // Synchronise rtsim NPC with entity data + for (pos, rtsim_entity, agent) in + (&positions, &rtsim_entities, (&mut agents).maybe()).join() + { + data.npcs .get_mut(rtsim_entity.0) - .filter(|e| e.is_loaded) - .map(|entity| { - entity.pos = pos.0; - agent.rtsim_controller = entity.controller.clone(); + .filter(|npc| matches!(npc.mode, SimulationMode::Loaded)) + .map(|npc| { + // Update rtsim NPC state + npc.wpos = pos.0; + + // Update entity state + if let Some(agent) = agent { + agent.rtsim_controller.personality = npc.personality; + agent.rtsim_controller.activity = npc.controller.activity; + agent + .rtsim_controller + .actions + .extend(std::mem::take(&mut npc.controller.actions)); + } }); } } diff --git a/server/src/rtsim/unload_chunks.rs b/server/src/rtsim/unload_chunks.rs deleted file mode 100644 index 5433164dc3..0000000000 --- a/server/src/rtsim/unload_chunks.rs +++ /dev/null @@ -1,43 +0,0 @@ -use super::*; -use common::{ - comp::Pos, - event::{EventBus, ServerEvent}, - terrain::TerrainGrid, -}; -use common_ecs::{Job, Origin, Phase, System}; -use specs::{Entities, Read, ReadExpect, ReadStorage, WriteExpect}; - -#[derive(Default)] -pub struct Sys; -impl<'a> System<'a> for Sys { - type SystemData = ( - Read<'a, EventBus>, - WriteExpect<'a, RtSim>, - ReadExpect<'a, TerrainGrid>, - Entities<'a>, - ReadStorage<'a, RtSimEntity>, - ReadStorage<'a, Pos>, - ); - - const NAME: &'static str = "rtsim::unload_chunks"; - const ORIGIN: Origin = Origin::Server; - const PHASE: Phase = Phase::Create; - - fn run( - _job: &mut Job, - ( - _server_event_bus, - mut rtsim, - _terrain_grid, - _entities, - _rtsim_entities, - _positions, - ): Self::SystemData, - ) { - let chunks = std::mem::take(&mut rtsim.chunks.chunks_to_unload); - - for _chunk in chunks { - // TODO - } - } -} diff --git a/server/src/settings.rs b/server/src/settings.rs index 304a95b24e..a01e420e09 100644 --- a/server/src/settings.rs +++ b/server/src/settings.rs @@ -17,6 +17,7 @@ use chrono::Utc; use common::{ calendar::{Calendar, CalendarEvent}, resources::BattleMode, + rtsim::WorldSettings, }; use core::time::Duration; use portpicker::pick_unused_port; @@ -184,6 +185,9 @@ pub struct Settings { pub gameplay: GameplaySettings, #[serde(default)] pub moderation: ModerationSettings, + + #[serde(default)] + pub world: WorldSettings, } impl Default for Settings { @@ -213,6 +217,7 @@ impl Default for Settings { experimental_terrain_persistence: false, gameplay: GameplaySettings::default(), moderation: ModerationSettings::default(), + world: WorldSettings::default(), } } } @@ -292,7 +297,6 @@ impl Settings { }, server_name: "Singleplayer".to_owned(), max_players: 100, - start_time: 9.0 * 3600.0, max_view_distance: None, client_timeout: Duration::from_secs(180), ..load // Fill in remaining fields from server_settings.ron. diff --git a/server/src/state_ext.rs b/server/src/state_ext.rs index 94acc0df25..850a2b19e7 100644 --- a/server/src/state_ext.rs +++ b/server/src/state_ext.rs @@ -4,7 +4,8 @@ use crate::{ events::{self, update_map_markers}, persistence::PersistedComponents, pet::restore_pet, - presence::{Presence, RepositionOnChunkLoad}, + presence::RepositionOnChunkLoad, + rtsim::RtSim, settings::Settings, sys::sentinel::DeletedEntities, wiring, BattleModeBuffer, SpawnPoint, @@ -18,18 +19,19 @@ use common::{ self, item::{ItemKind, MaterialStatManifest}, skills::{GeneralSkill, Skill}, - ChatType, Group, Inventory, Item, Player, Poise, + ChatType, Group, Inventory, Item, Player, Poise, Presence, PresenceKind, }, effect::Effect, link::{Link, LinkHandle}, mounting::Mounting, resources::{Secs, Time, TimeOfDay}, + rtsim::{Actor, RtSimEntity}, slowjob::SlowJobPool, uid::{Uid, UidAllocator}, LoadoutBuilder, ViewDistances, }; use common_net::{ - msg::{CharacterInfo, PlayerListUpdate, PresenceKind, ServerGeneral}, + msg::{CharacterInfo, PlayerListUpdate, ServerGeneral}, sync::WorldSyncExt, }; use common_state::State; @@ -64,7 +66,6 @@ pub trait StateExt { pos: comp::Pos, ship: comp::ship::Body, make_collider: F, - mountable: bool, ) -> EcsEntityBuilder; /// Build a projectile fn create_projectile( @@ -139,6 +140,8 @@ pub trait StateExt { &mut self, entity: EcsEntity, ) -> Result<(), specs::error::WrongGeneration>; + /// Get the given entity as an [`Actor`], if it is one. + fn entity_as_actor(&self, entity: EcsEntity) -> Option; } impl StateExt for State { @@ -286,6 +289,7 @@ impl StateExt for State { .with(poise) .with(comp::Alignment::Npc) .with(comp::CharacterState::default()) + .with(comp::CharacterActivity::default()) .with(inventory) .with(comp::Buffs::default()) .with(comp::Combo::default()) @@ -336,7 +340,6 @@ impl StateExt for State { pos: comp::Pos, ship: comp::ship::Body, make_collider: F, - mountable: bool, ) -> EcsEntityBuilder { let body = comp::Body::Ship(ship); let builder = self @@ -349,10 +352,10 @@ impl StateExt for State { .with(body.density()) .with(make_collider(ship)) .with(body) - .with(comp::Scale(comp::ship::AIRSHIP_SCALE)) .with(comp::Controller::default()) .with(Inventory::with_empty()) .with(comp::CharacterState::default()) + .with(comp::CharacterActivity::default()) // TODO: some of these are required in order for the character_behavior system to // recognize a possesed airship; that system should be refactored to use `.maybe()` .with(comp::Energy::new(ship.into(), 0)) @@ -361,9 +364,6 @@ impl StateExt for State { .with(comp::ActiveAbilities::default()) .with(comp::Combo::default()); - if mountable { - // TODO: Re-add mounting check - } builder } @@ -497,6 +497,10 @@ impl StateExt for State { { let ecs = self.ecs(); let slow_jobs = ecs.write_resource::(); + #[cfg(feature = "worldgen")] + let rtsim = ecs.read_resource::(); + #[cfg(not(feature = "worldgen"))] + let rtsim = (); let mut chunk_generator = ecs.write_resource::(); let chunk_pos = self.terrain().pos_key(pos.0.map(|e| e as i32)); @@ -517,7 +521,7 @@ impl StateExt for State { #[cfg(feature = "worldgen")] { let time = (*ecs.read_resource::(), (*ecs.read_resource::()).clone()); - chunk_generator.generate_chunk(None, chunk_key, &slow_jobs, Arc::clone(world), index.clone(), time); + chunk_generator.generate_chunk(None, chunk_key, &slow_jobs, Arc::clone(world), &rtsim, index.clone(), time); } }); } @@ -559,6 +563,7 @@ impl StateExt for State { z_max: 1.75, }); self.write_component_ignore_entity_dead(entity, comp::CharacterState::default()); + self.write_component_ignore_entity_dead(entity, comp::CharacterActivity::default()); self.write_component_ignore_entity_dead(entity, comp::Alignment::Owned(player_uid)); self.write_component_ignore_entity_dead(entity, comp::Buffs::default()); self.write_component_ignore_entity_dead(entity, comp::Auras::default()); @@ -654,7 +659,9 @@ impl StateExt for State { ); if let Some(waypoint) = waypoint { - self.write_component_ignore_entity_dead(entity, RepositionOnChunkLoad); + self.write_component_ignore_entity_dead(entity, RepositionOnChunkLoad { + needs_ground: true, + }); self.write_component_ignore_entity_dead(entity, waypoint); self.write_component_ignore_entity_dead(entity, comp::Pos(waypoint.get_pos())); self.write_component_ignore_entity_dead(entity, comp::Vel(Vec3::zero())); @@ -791,7 +798,11 @@ impl StateExt for State { (*ecs.read_resource::()) .retrieve_entity_internal(sender.0) .map_or(false, |e| { - self.validate_chat_msg(e, &msg.chat_type, &msg.message) + self.validate_chat_msg( + e, + &msg.chat_type, + msg.content().as_plain().unwrap_or_default(), + ) }) }) { match &msg.chat_type { @@ -821,7 +832,6 @@ impl StateExt for State { } }, comp::ChatType::Kill(kill_source, uid) => { - let comp::chat::GenericChatMsg { message, .. } = msg; let clients = ecs.read_storage::(); let clients_count = clients.count(); // Avoid chat spam, send kill message only to group or nearby players if a @@ -864,7 +874,7 @@ impl StateExt for State { } else { self.notify_players(ServerGeneral::server_msg( comp::ChatType::Kill(kill_source.clone(), *uid), - message, + msg.into_content(), )) } }, @@ -894,7 +904,7 @@ impl StateExt for State { } } }, - comp::ChatType::Npc(uid, _r) => { + comp::ChatType::Npc(uid) => { let entity_opt = (*ecs.read_resource::()).retrieve_entity_internal(uid.0); @@ -907,7 +917,7 @@ impl StateExt for State { } } }, - comp::ChatType::NpcSay(uid, _r) => { + comp::ChatType::NpcSay(uid) => { let entity_opt = (*ecs.read_resource::()).retrieve_entity_internal(uid.0); @@ -920,7 +930,7 @@ impl StateExt for State { } } }, - comp::ChatType::NpcTell(from, to, _r) => { + comp::ChatType::NpcTell(from, to) => { for (client, uid) in (&ecs.read_storage::(), &ecs.read_storage::()).join() { @@ -944,12 +954,10 @@ impl StateExt for State { comp::ChatType::Group(from, g) => { if group_info.is_none() { // group not found, reply with command error - let reply = comp::ChatMsg { - chat_type: comp::ChatType::CommandError, - message: "You are using group chat but do not belong to a group. Use \ - /world or /region to change chat." - .into(), - }; + let reply = comp::ChatType::CommandError.into_plain_msg( + "You are using group chat but do not belong to a group. Use /world or \ + /region to change chat.", + ); if let Some((client, _)) = (&ecs.read_storage::(), &ecs.read_storage::()) @@ -1100,6 +1108,26 @@ impl StateExt for State { } res } + + fn entity_as_actor(&self, entity: EcsEntity) -> Option { + if let Some(rtsim_entity) = self + .ecs() + .read_storage::() + .get(entity) + .copied() + { + Some(Actor::Npc(rtsim_entity.0)) + } else if let Some(PresenceKind::Character(character)) = self + .ecs() + .read_storage::() + .get(entity) + .map(|p| p.kind) + { + Some(Actor::Character(character)) + } else { + None + } + } } fn send_to_group(g: &Group, ecs: &specs::World, msg: &comp::ChatMsg) { diff --git a/server/src/sys/agent.rs b/server/src/sys/agent.rs index f2789c6aca..5151fbea23 100644 --- a/server/src/sys/agent.rs +++ b/server/src/sys/agent.rs @@ -1,12 +1,10 @@ pub mod behavior_tree; pub use server_agent::{action_nodes, attack, consts, data, util}; +use vek::Vec3; -use crate::{ - rtsim::RtSim, - sys::agent::{ - behavior_tree::{BehaviorData, BehaviorTree}, - data::{AgentData, ReadData}, - }, +use crate::sys::agent::{ + behavior_tree::{BehaviorData, BehaviorTree}, + data::{AgentData, ReadData}, }; use common::{ comp::{ @@ -15,13 +13,12 @@ use common::{ }, event::{EventBus, ServerEvent}, path::TraversalConfig, - rtsim::RtSimEvent, }; use common_base::prof_span; use common_ecs::{Job, Origin, ParMode, Phase, System}; use rand::thread_rng; use rayon::iter::ParallelIterator; -use specs::{Join, ParJoin, Read, WriteExpect, WriteStorage}; +use specs::{saveload::MarkerAllocator, Join, ParJoin, Read, WriteStorage}; /// This system will allow NPCs to modify their controller #[derive(Default)] @@ -32,7 +29,6 @@ impl<'a> System<'a> for Sys { Read<'a, EventBus>, WriteStorage<'a, Agent>, WriteStorage<'a, Controller>, - WriteExpect<'a, RtSim>, ); const NAME: &'static str = "agent"; @@ -41,9 +37,8 @@ impl<'a> System<'a> for Sys { fn run( job: &mut Job, - (read_data, event_bus, mut agents, mut controllers, mut rtsim): Self::SystemData, + (read_data, event_bus, mut agents, mut controllers): Self::SystemData, ) { - let rtsim = &mut *rtsim; job.cpu_stats.measure(ParMode::Rayon); ( @@ -71,7 +66,9 @@ impl<'a> System<'a> for Sys { &mut controllers, read_data.light_emitter.maybe(), read_data.groups.maybe(), + read_data.rtsim_entities.maybe(), !&read_data.is_mounts, + read_data.is_riders.maybe(), ) .par_join() .for_each_init( @@ -93,11 +90,24 @@ impl<'a> System<'a> for Sys { controller, light_emitter, group, + rtsim_entity, _, + is_rider, )| { let mut event_emitter = event_bus.emitter(); let mut rng = thread_rng(); + // The entity that is moving, if riding it's the mount, otherwise it's itself + let moving_entity = is_rider + .and_then(|is_rider| { + read_data + .uid_allocator + .retrieve_entity_internal(is_rider.mount.into()) + }) + .unwrap_or(entity); + + let moving_body = read_data.bodies.get(moving_entity); + // Hack, replace with better system when groups are more sophisticated // Override alignment if in a group unless entity is owned already let alignment = if matches!( @@ -140,8 +150,22 @@ impl<'a> System<'a> for Sys { Some(CharacterState::GlideWield(_) | CharacterState::Glide(_)) ) && physics_state.on_ground.is_none(); - if let Some(pid) = agent.position_pid_controller.as_mut() { + if let Some((kp, ki, kd)) = moving_body.and_then(comp::agent::pid_coefficients) + { + if agent + .position_pid_controller + .as_ref() + .map_or(false, |pid| (pid.kp, pid.ki, pid.kd) != (kp, ki, kd)) + { + agent.position_pid_controller = None; + } + let pid = agent.position_pid_controller.get_or_insert_with(|| { + fn pure_z(sp: Vec3, pv: Vec3) -> f32 { (sp - pv).z } + comp::PidController::new(kp, ki, kd, pos.0, 0.0, pure_z) + }); pid.add_measurement(read_data.time.0, pos.0); + } else { + agent.position_pid_controller = None; } // This controls how picky NPCs are about their pathfinding. @@ -150,23 +174,19 @@ impl<'a> System<'a> for Sys { // (especially since they would otherwise get stuck on // obstacles that smaller entities would not). let node_tolerance = scale * 1.5; - let slow_factor = body.map_or(0.0, |b| b.base_accel() / 250.0).min(1.0); + let slow_factor = moving_body.map_or(0.0, |b| b.base_accel() / 250.0).min(1.0); let traversal_config = TraversalConfig { node_tolerance, slow_factor, on_ground: physics_state.on_ground.is_some(), in_liquid: physics_state.in_liquid().is_some(), min_tgt_dist: 1.0, - can_climb: body.map_or(false, Body::can_climb), - can_fly: body.map_or(false, |b| b.fly_thrust().is_some()), + can_climb: moving_body.map_or(false, Body::can_climb), + can_fly: moving_body.map_or(false, |b| b.fly_thrust().is_some()), }; let health_fraction = health.map_or(1.0, Health::fraction); - let rtsim_entity = read_data - .rtsim_entities - .get(entity) - .and_then(|rtsim_ent| rtsim.get_entity(rtsim_ent.0)); - if traversal_config.can_fly && matches!(body, Some(Body::Ship(_))) { + if traversal_config.can_fly && matches!(moving_body, Some(Body::Ship(_))) { // hack (kinda): Never turn off flight airships // since it results in stuttering and falling back to the ground. // @@ -178,6 +198,7 @@ impl<'a> System<'a> for Sys { // Package all this agent's data into a convenient struct let data = AgentData { entity: &entity, + rtsim_entity, uid, pos, vel, @@ -226,7 +247,6 @@ impl<'a> System<'a> for Sys { // inputs. let mut behavior_data = BehaviorData { agent, - rtsim_entity, agent_data: data, read_data: &read_data, event_emitter: &mut event_emitter, @@ -240,23 +260,5 @@ impl<'a> System<'a> for Sys { debug_assert!(controller.inputs.look_dir.map(|e| !e.is_nan()).reduce_and()); }, ); - for (agent, rtsim_entity) in (&mut agents, &read_data.rtsim_entities).join() { - // Entity must be loaded in as it has an agent component :) - // React to all events in the controller - for event in core::mem::take(&mut agent.rtsim_controller.events) { - match event { - RtSimEvent::AddMemory(memory) => { - rtsim.insert_entity_memory(rtsim_entity.0, memory.clone()); - }, - RtSimEvent::ForgetEnemy(name) => { - rtsim.forget_entity_enemy(rtsim_entity.0, &name); - }, - RtSimEvent::SetMood(memory) => { - rtsim.set_entity_mood(rtsim_entity.0, memory.clone()); - }, - RtSimEvent::PrintMemories => {}, - } - } - } } } diff --git a/server/src/sys/agent/behavior_tree.rs b/server/src/sys/agent/behavior_tree.rs index 6a3ebb37a0..2b4dc59299 100644 --- a/server/src/sys/agent/behavior_tree.rs +++ b/server/src/sys/agent/behavior_tree.rs @@ -1,4 +1,3 @@ -use crate::rtsim::Entity as RtSimEntity; use common::{ comp::{ agent::{ @@ -10,6 +9,7 @@ use common::{ }, event::{Emitter, ServerEvent}, path::TraversalConfig, + rtsim::{NpcAction, RtSimEntity}, }; use rand::{prelude::ThreadRng, thread_rng, Rng}; use specs::{ @@ -40,8 +40,6 @@ mod interaction; pub struct BehaviorData<'a, 'b, 'c> { pub agent: &'a mut Agent, pub agent_data: AgentData<'a>, - // TODO: Move rtsim back into AgentData after rtsim2 when it has a separate crate - pub rtsim_entity: Option<&'a RtSimEntity>, pub read_data: &'a ReadData<'a>, pub event_emitter: &'a mut Emitter<'c, ServerEvent>, pub controller: &'a mut Controller, @@ -162,7 +160,11 @@ impl BehaviorTree { /// Idle BehaviorTree pub fn idle() -> Self { Self { - tree: vec![set_owner_if_no_target, handle_timed_events], + tree: vec![ + set_owner_if_no_target, + handle_rtsim_actions, + handle_timed_events, + ], } } @@ -277,6 +279,7 @@ fn target_if_attacked(bdata: &mut BehaviorData) -> bool { } // Remember this attack if we're an RtSim entity + /* if let Some(attacker_stats) = bdata.rtsim_entity.and(bdata.read_data.stats.get(attacker)) { @@ -284,6 +287,7 @@ fn target_if_attacked(bdata: &mut BehaviorData) -> bool { .agent .add_fight_to_memory(&attacker_stats.name, bdata.read_data.time.0); } + */ } } } @@ -316,9 +320,11 @@ fn untarget_if_dead(bdata: &mut BehaviorData) -> bool { if let Some(tgt_health) = bdata.read_data.healths.get(target) { // If target is dead, forget them if tgt_health.is_dead { + /* if let Some(tgt_stats) = bdata.rtsim_entity.and(bdata.read_data.stats.get(target)) { bdata.agent.forget_enemy(&tgt_stats.name); } + */ bdata.agent.target = None; return true; } @@ -431,6 +437,7 @@ fn attack_if_owner_hurt(bdata: &mut BehaviorData) -> bool { bdata.agent, bdata.read_data, bdata.controller, + bdata.event_emitter, bdata.rng, ); return true; @@ -456,12 +463,62 @@ fn set_owner_if_no_target(bdata: &mut BehaviorData) -> bool { false, owner_pos, )); + // Always become aware of our owner no matter what + bdata.agent.awareness.set_maximally_aware(); } } } false } +/// Handle action requests from rtsim, such as talking to NPCs or attacking +fn handle_rtsim_actions(bdata: &mut BehaviorData) -> bool { + if let Some(action) = bdata.agent.rtsim_controller.actions.pop_front() { + match action { + NpcAction::Say(target, msg) => { + if bdata.agent.allowed_to_speak() { + // Aim the speech toward a target + if let Some(target) = target.and_then(|tgt| bdata.read_data.lookup_actor(tgt)) { + bdata.agent.target = Some(Target::new( + target, + false, + bdata.read_data.time.0, + false, + bdata.read_data.positions.get(target).map(|p| p.0), + )); + // We're always aware of someone we're talking to + bdata.agent.awareness.set_maximally_aware(); + // Start a timer so that we eventually stop interacting + bdata + .agent + .timer + .start(bdata.read_data.time.0, TimerAction::Interact); + bdata.controller.push_action(ControlAction::Stand); + } + + bdata.controller.push_utterance(UtteranceKind::Greeting); + bdata.agent_data.chat_npc(msg, bdata.event_emitter); + } + }, + NpcAction::Attack(target) => { + if let Some(target) = bdata.read_data.lookup_actor(target) { + bdata.agent.target = Some(Target::new( + target, + true, + bdata.read_data.time.0, + false, + bdata.read_data.positions.get(target).map(|p| p.0), + )); + bdata.agent.awareness.set_maximally_aware(); + } + }, + } + true + } else { + false + } +} + /// Handle timed events, like looking at the player we are talking to fn handle_timed_events(bdata: &mut BehaviorData) -> bool { let timeout = if bdata.agent.behavior.is(BehaviorState::TRADING) { @@ -496,13 +553,14 @@ fn handle_timed_events(bdata: &mut BehaviorData) -> bool { bdata.controller, bdata.read_data, bdata.event_emitter, - will_ambush(bdata.rtsim_entity, &bdata.agent_data), + AgentData::is_enemy, ); } else { bdata.agent_data.handle_sounds_heard( bdata.agent, bdata.controller, bdata.read_data, + bdata.event_emitter, bdata.rng, ); } @@ -524,7 +582,14 @@ fn update_last_known_pos(bdata: &mut BehaviorData) -> bool { let target = target_info.target; if let Some(target_pos) = read_data.positions.get(target) { - if agent_data.detects_other(agent, controller, &target, target_pos, read_data) { + if agent_data.detects_other( + agent, + controller, + &target, + target_pos, + read_data.scales.get(target), + read_data, + ) { let updated_pos = Some(target_pos.0); let Target { @@ -584,9 +649,10 @@ fn update_target_awareness(bdata: &mut BehaviorData) -> bool { let target = agent.target.map(|t| t.target); let tgt_pos = target.and_then(|t| read_data.positions.get(t)); + let tgt_scale = target.and_then(|t| read_data.scales.get(t)); if let (Some(target), Some(tgt_pos)) = (target, tgt_pos) { - if agent_data.can_see_entity(agent, controller, target, tgt_pos, read_data) { + if agent_data.can_see_entity(agent, controller, target, tgt_pos, tgt_scale, read_data) { agent.awareness.change_by(1.75 * read_data.dt.0); } else if agent_data.can_sense_directly_near(tgt_pos) { agent.awareness.change_by(0.25); @@ -639,7 +705,6 @@ fn do_combat(bdata: &mut BehaviorData) -> bool { let BehaviorData { agent, agent_data, - rtsim_entity, read_data, event_emitter, controller, @@ -718,12 +783,12 @@ fn do_combat(bdata: &mut BehaviorData) -> bool { [ActionStateBehaviorTreeTimers::TimerBehaviorTree as usize] = 0.0; agent.target = None; agent.flee_from_pos = None; - agent_data.idle(agent, controller, read_data, rng); + agent_data.idle(agent, controller, read_data, event_emitter, rng); } } else if is_dead(target, read_data) { agent_data.exclaim_relief_about_enemy_dead(agent, event_emitter); agent.target = None; - agent_data.idle(agent, controller, read_data, rng); + agent_data.idle(agent, controller, read_data, event_emitter, rng); } else if is_invulnerable(target, read_data) || stop_pursuing( dist_sqrd, @@ -735,7 +800,7 @@ fn do_combat(bdata: &mut BehaviorData) -> bool { ) { agent.target = None; - agent_data.idle(agent, controller, read_data, rng); + agent_data.idle(agent, controller, read_data, event_emitter, rng); } else { let is_time_to_retarget = read_data.time.0 - selected_at > RETARGETING_THRESHOLD_SECONDS; @@ -746,15 +811,17 @@ fn do_combat(bdata: &mut BehaviorData) -> bool { controller, read_data, event_emitter, - will_ambush(*rtsim_entity, agent_data), + AgentData::is_enemy, ); } if aggro_on { let target_data = TargetData::new(tgt_pos, target, read_data); - let tgt_name = read_data.stats.get(target).map(|stats| stats.name.clone()); + // let tgt_name = read_data.stats.get(target).map(|stats| stats.name.clone()); - tgt_name.map(|tgt_name| agent.add_fight_to_memory(&tgt_name, read_data.time.0)); + // TODO: Reimplement in rtsim2 + // tgt_name.map(|tgt_name| agent.add_fight_to_memory(&tgt_name, + // read_data.time.0)); agent_data.attack(agent, controller, &target_data, read_data, rng); } else { agent_data.menacing( @@ -764,9 +831,11 @@ fn do_combat(bdata: &mut BehaviorData) -> bool { read_data, event_emitter, rng, - remembers_fight_with(*rtsim_entity, read_data, target), + remembers_fight_with(agent_data.rtsim_entity, read_data, target), ); - remember_fight(*rtsim_entity, read_data, agent, target); + // TODO: Reimplement in rtsim2 + // remember_fight(agent_data.rtsim_entity, read_data, agent, + // target); } } } @@ -774,38 +843,33 @@ fn do_combat(bdata: &mut BehaviorData) -> bool { false } -fn will_ambush(rtsim_entity: Option<&RtSimEntity>, agent_data: &AgentData) -> bool { - agent_data - .health - .map_or(false, |h| h.current() / h.maximum() > 0.7) - && rtsim_entity.map_or(false, |re| re.brain.personality.will_ambush) -} - fn remembers_fight_with( - rtsim_entity: Option<&RtSimEntity>, - read_data: &ReadData, - other: EcsEntity, + _rtsim_entity: Option<&RtSimEntity>, + _read_data: &ReadData, + _other: EcsEntity, ) -> bool { - let name = || read_data.stats.get(other).map(|stats| stats.name.clone()); + // TODO: implement for rtsim2 + // let name = || read_data.stats.get(other).map(|stats| stats.name.clone()); - rtsim_entity.map_or(false, |rtsim_entity| { - name().map_or(false, |name| { - rtsim_entity.brain.remembers_fight_with_character(&name) - }) - }) + // rtsim_entity.map_or(false, |rtsim_entity| { + // name().map_or(false, |name| { + // rtsim_entity.brain.remembers_fight_with_character(&name) + // }) + // }) + false } -/// Remember target. -fn remember_fight( - rtsim_entity: Option<&RtSimEntity>, - read_data: &ReadData, - agent: &mut Agent, - target: EcsEntity, -) { - rtsim_entity.is_some().then(|| { - read_data - .stats - .get(target) - .map(|stats| agent.add_fight_to_memory(&stats.name, read_data.time.0)) - }); -} +// /// Remember target. +// fn remember_fight( +// rtsim_entity: Option<&RtSimEntity>, +// read_data: &ReadData, +// agent: &mut Agent, +// target: EcsEntity, +// ) { +// rtsim_entity.is_some().then(|| { +// read_data +// .stats +// .get(target) +// .map(|stats| agent.add_fight_to_memory(&stats.name, +// read_data.time.0)) }); +// } diff --git a/server/src/sys/agent/behavior_tree/interaction.rs b/server/src/sys/agent/behavior_tree/interaction.rs index 9d1088287f..d9f3e23bad 100644 --- a/server/src/sys/agent/behavior_tree/interaction.rs +++ b/server/src/sys/agent/behavior_tree/interaction.rs @@ -2,23 +2,21 @@ use common::{ comp::{ agent::{AgentEvent, Target, TimerAction}, compass::{Direction, Distance}, - dialogue::{MoodContext, MoodState, Subject}, + dialogue::Subject, inventory::item::{ItemTag, MaterialStatManifest}, invite::{InviteKind, InviteResponse}, tool::AbilityMap, - BehaviorState, ControlAction, Item, TradingBehavior, UnresolvedChatMsg, UtteranceKind, + BehaviorState, Content, ControlAction, Item, TradingBehavior, UnresolvedChatMsg, + UtteranceKind, }, event::ServerEvent, - rtsim::{Memory, MemoryItem, RtSimEvent}, + rtsim::PersonalityTrait, trade::{TradeAction, TradePhase, TradeResult}, }; use rand::{thread_rng, Rng}; use specs::saveload::Marker; -use crate::{ - rtsim::entity::{PersonalityTrait, RtSimEntityKind}, - sys::agent::util::get_entity_by_id, -}; +use crate::sys::agent::util::get_entity_by_id; use super::{BehaviorData, BehaviorTree}; @@ -100,297 +98,153 @@ pub fn handle_inbox_talk(bdata: &mut BehaviorData) -> bool { false, target_pos, )); + // We're always aware of someone we're talking to + agent.awareness.set_maximally_aware(); - if agent_data.look_toward(controller, read_data, target) { - controller.push_action(ControlAction::Stand); - controller.push_action(ControlAction::Talk); - controller.push_utterance(UtteranceKind::Greeting); + controller.push_action(ControlAction::Stand); + controller.push_action(ControlAction::Talk); + controller.push_utterance(UtteranceKind::Greeting); - match subject { - Subject::Regular => { - if let Some(rtsim_entity) = &bdata.rtsim_entity { - if matches!(rtsim_entity.kind, RtSimEntityKind::Prisoner) { - agent_data.chat_npc("npc-speech-prisoner", event_emitter); - } else if let ( - Some((_travel_to, destination_name)), - Some(rtsim_entity), - ) = - (&agent.rtsim_controller.travel_to, &&bdata.rtsim_entity) + match subject { + Subject::Regular => { + if let Some(tgt_stats) = read_data.stats.get(target) { + if let Some(destination_name) = &agent.rtsim_controller.heading_to { + let personality = &agent.rtsim_controller.personality; + let standard_response_msg = || -> String { + if personality.will_ambush() { + format!( + "I'm heading to {}! Want to come along? We'll make \ + great travel buddies, hehe.", + destination_name + ) + } else if personality.is(PersonalityTrait::Extroverted) { + format!( + "I'm heading to {}! Want to come along?", + destination_name + ) + } else if personality.is(PersonalityTrait::Disagreeable) { + "Hrm.".to_string() + } else { + "Hello!".to_string() + } + }; + let msg = if false + /* TODO: Remembers character */ { - let personality = &rtsim_entity.brain.personality; - let standard_response_msg = || -> String { - if personality.will_ambush { + if personality.will_ambush() { + "Just follow me a bit more, hehe.".to_string() + } else if personality.is(PersonalityTrait::Extroverted) { + if personality.is(PersonalityTrait::Extroverted) { format!( - "I'm heading to {}! Want to come along? We'll \ - make great travel buddies, hehe.", - destination_name + "Greetings fair {}! It has been far too long \ + since last I saw you. I'm going to {} right now.", + &tgt_stats.name, destination_name ) - } else if personality - .personality_traits - .contains(PersonalityTrait::Extroverted) - { + } else if personality.is(PersonalityTrait::Disagreeable) { + "Oh. It's you again.".to_string() + } else { format!( - "I'm heading to {}! Want to come along?", - destination_name + "Hi again {}! Unfortunately I'm in a hurry right \ + now. See you!", + &tgt_stats.name ) - } else if personality - .personality_traits - .contains(PersonalityTrait::Disagreeable) - { - "Hrm.".to_string() - } else { - "Hello!".to_string() - } - }; - let msg = if let Some(tgt_stats) = read_data.stats.get(target) { - agent.rtsim_controller.events.push(RtSimEvent::AddMemory( - Memory { - item: MemoryItem::CharacterInteraction { - name: tgt_stats.name.clone(), - }, - time_to_forget: read_data.time.0 + 600.0, - }, - )); - if rtsim_entity.brain.remembers_character(&tgt_stats.name) { - if personality.will_ambush { - "Just follow me a bit more, hehe.".to_string() - } else if personality - .personality_traits - .contains(PersonalityTrait::Extroverted) - { - format!( - "Greetings fair {}! It has been far too long \ - since last I saw you. I'm going to {} right \ - now.", - &tgt_stats.name, destination_name - ) - } else if personality - .personality_traits - .contains(PersonalityTrait::Disagreeable) - { - "Oh. It's you again.".to_string() - } else { - format!( - "Hi again {}! Unfortunately I'm in a hurry \ - right now. See you!", - &tgt_stats.name - ) - } - } else { - standard_response_msg() } } else { standard_response_msg() - }; - agent_data.chat_npc(msg, event_emitter); - } else if agent - .behavior - .can_trade(agent_data.alignment.copied(), by) - { - if !agent.behavior.is(BehaviorState::TRADING) { - controller.push_initiate_invite(by, InviteKind::Trade); - agent_data.chat_npc( - "npc-speech-merchant_advertisement", - event_emitter, - ); - } else { - let default_msg = "npc-speech-merchant_busy"; - let msg = &bdata.rtsim_entity.map_or(default_msg, |e| { - if e.brain - .personality - .personality_traits - .contains(PersonalityTrait::Disagreeable) - { - "npc-speech-merchant_busy_rude" - } else { - default_msg - } - }); - agent_data.chat_npc(msg, event_emitter); } } else { - let mut rng = thread_rng(); - if let Some(extreme_trait) = &bdata.rtsim_entity.and_then(|e| { - e.brain.personality.random_chat_trait(&mut rng) - }) { - let msg = match extreme_trait { - PersonalityTrait::Open => "npc-speech-villager_open", - PersonalityTrait::Adventurous => { - "npc-speech-villager_adventurous" - }, - PersonalityTrait::Closed => { - "npc-speech-villager_closed" - }, - PersonalityTrait::Conscientious => { - "npc-speech-villager_conscientious" - }, - PersonalityTrait::Busybody => { - "npc-speech-villager_busybody" - }, - PersonalityTrait::Unconscientious => { - "npc-speech-villager_unconscientious" - }, - PersonalityTrait::Extroverted => { - "npc-speech-villager_extroverted" - }, - PersonalityTrait::Introverted => { - "npc-speech-villager_introverted" - }, - PersonalityTrait::Agreeable => { - "npc-speech-villager_agreeable" - }, - PersonalityTrait::Sociable => { - "npc-speech-villager_sociable" - }, - PersonalityTrait::Disagreeable => { - "npc-speech-villager_disagreeable" - }, - PersonalityTrait::Neurotic => { - "npc-speech-villager_neurotic" - }, - PersonalityTrait::Seeker => { - "npc-speech-villager_seeker" - }, - PersonalityTrait::SadLoner => { - "npc-speech-villager_sad_loner" - }, - PersonalityTrait::Worried => { - "npc-speech-villager_worried" - }, - PersonalityTrait::Stable => { - "npc-speech-villager_stable" - }, - }; - agent_data.chat_npc(msg, event_emitter); - } else { - agent_data.chat_npc("npc-speech-villager", event_emitter); - } - } - } - }, - Subject::Trade => { - if agent.behavior.can_trade(agent_data.alignment.copied(), by) { - if !agent.behavior.is(BehaviorState::TRADING) { - controller.push_initiate_invite(by, InviteKind::Trade); - agent_data.chat_npc_if_allowed_to_speak( - "npc-speech-merchant_advertisement", - agent, - event_emitter, - ); - } else { - agent_data.chat_npc_if_allowed_to_speak( - "npc-speech-merchant_busy", - agent, - event_emitter, - ); - } + standard_response_msg() + }; + // TODO: Localise + agent_data.chat_npc(Content::Plain(msg), event_emitter); } else { - // TODO: maybe make some travellers willing to trade with - // simpler goods like potions + let mut rng = thread_rng(); + agent_data.chat_npc( + agent + .rtsim_controller + .personality + .get_generic_comment(&mut rng), + event_emitter, + ); + } + } + }, + Subject::Trade => { + if agent.behavior.can_trade(agent_data.alignment.copied(), by) { + if !agent.behavior.is(BehaviorState::TRADING) { + controller.push_initiate_invite(by, InviteKind::Trade); agent_data.chat_npc_if_allowed_to_speak( - "npc-speech-villager_decline_trade", + Content::localized("npc-speech-merchant_advertisement"), + agent, + event_emitter, + ); + } else { + agent_data.chat_npc_if_allowed_to_speak( + Content::localized("npc-speech-merchant_busy"), agent, event_emitter, ); } - }, - Subject::Mood => { - if let Some(rtsim_entity) = &bdata.rtsim_entity { - if !rtsim_entity.brain.remembers_mood() { - // TODO: the following code will need a rework to - // implement more mood contexts - // This require that town NPCs becomes rtsim_entities to - // work fully. - match rand::random::() % 3 { - 0 => agent.rtsim_controller.events.push( - RtSimEvent::SetMood(Memory { - item: MemoryItem::Mood { - state: MoodState::Good( - MoodContext::GoodWeather, - ), - }, - time_to_forget: read_data.time.0 + 21200.0, - }), - ), - 1 => agent.rtsim_controller.events.push( - RtSimEvent::SetMood(Memory { - item: MemoryItem::Mood { - state: MoodState::Neutral( - MoodContext::EverydayLife, - ), - }, - time_to_forget: read_data.time.0 + 21200.0, - }), - ), - 2 => agent.rtsim_controller.events.push( - RtSimEvent::SetMood(Memory { - item: MemoryItem::Mood { - state: MoodState::Bad(MoodContext::GoodWeather), - }, - time_to_forget: read_data.time.0 + 86400.0, - }), - ), - _ => {}, // will never happen - } - } - if let Some(memory) = rtsim_entity.brain.get_mood() { - let msg = match &memory.item { - MemoryItem::Mood { state } => state.describe(), - _ => "".to_string(), - }; - agent_data.chat_npc(msg, event_emitter); - } - } - }, - Subject::Location(location) => { - if let Some(tgt_pos) = read_data.positions.get(target) { - let raw_dir = location.origin.as_::() - tgt_pos.0.xy(); - let dist = Distance::from_dir(raw_dir).name(); - let dir = Direction::from_dir(raw_dir).name(); + } else { + // TODO: maybe make some travellers willing to trade with + // simpler goods like potions + agent_data.chat_npc_if_allowed_to_speak( + Content::localized("npc-speech-villager_decline_trade"), + agent, + event_emitter, + ); + } + }, + Subject::Mood => { + // TODO: Reimplement in rtsim2 + }, + Subject::Location(location) => { + if let Some(tgt_pos) = read_data.positions.get(target) { + let raw_dir = location.origin.as_::() - tgt_pos.0.xy(); + let dist = Distance::from_dir(raw_dir).name(); + let dir = Direction::from_dir(raw_dir).name(); - let msg = format!( - "{} ? I think it's {} {} from here!", - location.name, dist, dir - ); - agent_data.chat_npc(msg, event_emitter); - } - }, - Subject::Person(person) => { - if let Some(src_pos) = read_data.positions.get(target) { - let msg = if let Some(person_pos) = person.origin { - let distance = - Distance::from_dir(person_pos.xy() - src_pos.0.xy()); - match distance { - Distance::NextTo | Distance::Near => { - format!( - "{} ? I think he's {} {} from here!", - person.name(), - distance.name(), - Direction::from_dir( - person_pos.xy() - src_pos.0.xy(), - ) + // TODO: Localise + let msg = format!( + "{} ? I think it's {} {} from here!", + location.name, dist, dir + ); + agent_data.chat_npc(Content::Plain(msg), event_emitter); + } + }, + Subject::Person(person) => { + if let Some(src_pos) = read_data.positions.get(target) { + // TODO: Localise + let msg = if let Some(person_pos) = person.origin { + let distance = Distance::from_dir(person_pos.xy() - src_pos.0.xy()); + match distance { + Distance::NextTo | Distance::Near => { + format!( + "{} ? I think he's {} {} from here!", + person.name(), + distance.name(), + Direction::from_dir(person_pos.xy() - src_pos.0.xy(),) .name() - ) - }, - _ => { - format!( - "{} ? I think he's gone visiting another town. \ - Come back later!", - person.name() - ) - }, - } - } else { - format!( - "{} ? Sorry, I don't know where you can find him.", - person.name() - ) - }; - agent_data.chat_npc(msg, event_emitter); - } - }, - Subject::Work => {}, - } + ) + }, + _ => { + format!( + "{} ? I think he's gone visiting another town. Come \ + back later!", + person.name() + ) + }, + } + } else { + format!( + "{} ? Sorry, I don't know where you can find him.", + person.name() + ) + }; + agent_data.chat_npc(Content::Plain(msg), event_emitter); + } + }, + Subject::Work => {}, } } } @@ -439,7 +293,7 @@ pub fn handle_inbox_trade_invite(bdata: &mut BehaviorData) -> bool { } else { controller.push_invite_response(InviteResponse::Decline); agent_data.chat_npc_if_allowed_to_speak( - "npc-speech-merchant_busy", + Content::localized("npc-speech-merchant_busy"), agent, event_emitter, ); @@ -448,7 +302,7 @@ pub fn handle_inbox_trade_invite(bdata: &mut BehaviorData) -> bool { // TODO: Provide a hint where to find the closest merchant? controller.push_invite_response(InviteResponse::Decline); agent_data.chat_npc_if_allowed_to_speak( - "npc-speech-villager_decline_trade", + Content::localized("npc-speech-villager_decline_trade"), agent, event_emitter, ); @@ -505,14 +359,14 @@ pub fn handle_inbox_finished_trade(bdata: &mut BehaviorData) -> bool { match result { TradeResult::Completed => { agent_data.chat_npc_if_allowed_to_speak( - "npc-speech-merchant_trade_successful", + Content::localized("npc-speech-merchant_trade_successful"), agent, event_emitter, ); }, _ => { agent_data.chat_npc_if_allowed_to_speak( - "npc-speech-merchant_trade_declined", + Content::localized("npc-speech-merchant_trade_declined"), agent, event_emitter, ); @@ -544,59 +398,80 @@ pub fn handle_inbox_update_pending_trade(bdata: &mut BehaviorData) -> bool { let (tradeid, pending, prices, inventories) = *boxval; if agent.behavior.is(BehaviorState::TRADING) { let who = usize::from(!agent.behavior.is(BehaviorState::TRADING_ISSUER)); + let mut message = |content: Content| { + if let Some(with) = agent + .target + .as_ref() + .and_then(|tgt_data| read_data.uids.get(tgt_data.target)) + { + event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc_tell( + *agent_data.uid, + *with, + content, + ))); + } else { + event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc_say( + *agent_data.uid, + content, + ))); + } + }; match agent.behavior.trading_behavior { TradingBehavior::RequireBalanced { .. } => { - let balance0: f32 = - prices.balance(&pending.offers, &inventories, 1 - who, true); - let balance1: f32 = prices.balance(&pending.offers, &inventories, who, false); - if balance0 >= balance1 { - // If the trade is favourable to us, only send an accept message if we're - // not already accepting (since otherwise, spam-clicking the accept button - // results in lagging and moving to the review phase of an unfavorable trade - // (although since the phase is included in the message, this shouldn't - // result in fully accepting an unfavourable trade)) - if !pending.accept_flags[who] && !pending.is_empty_trade() { - event_emitter.emit(ServerEvent::ProcessTradeAction( - *agent_data.entity, - tradeid, - TradeAction::Accept(pending.phase), - )); - tracing::trace!(?tradeid, ?balance0, ?balance1, "Accept Pending Trade"); - } - } else { - if balance1 > 0.0 { - let msg = format!( - "That only covers {:.0}% of my costs!", - (balance0 / balance1 * 100.0).floor() - ); - if let Some(tgt_data) = &agent.target { - // If talking with someone in particular, "tell" it only to them - if let Some(with) = read_data.uids.get(tgt_data.target) { - event_emitter.emit(ServerEvent::Chat( - UnresolvedChatMsg::npc_tell(*agent_data.uid, *with, msg), - )); - } else { - event_emitter.emit(ServerEvent::Chat( - UnresolvedChatMsg::npc_say(*agent_data.uid, msg), + let balance0 = prices.balance(&pending.offers, &inventories, 1 - who, true); + let balance1 = prices.balance(&pending.offers, &inventories, who, false); + match (balance0, balance1) { + // TODO: Localise + (_, None) => message(Content::Plain( + "I'm not willing to sell that item".to_string(), + )), + // TODO: Localise + (None, _) => message(Content::Plain( + "I'm not willing to buy that item".to_string(), + )), + (Some(balance0), Some(balance1)) => { + if balance0 >= balance1 { + // If the trade is favourable to us, only send an accept message if + // we're not already accepting + // (since otherwise, spam-clicking the accept button + // results in lagging and moving to the review phase of an + // unfavorable trade (although since + // the phase is included in the message, this shouldn't + // result in fully accepting an unfavourable trade)) + if !pending.accept_flags[who] && !pending.is_empty_trade() { + event_emitter.emit(ServerEvent::ProcessTradeAction( + *agent_data.entity, + tradeid, + TradeAction::Accept(pending.phase), )); + tracing::trace!( + ?tradeid, + ?balance0, + ?balance1, + "Accept Pending Trade" + ); } } else { - event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc_say( - *agent_data.uid, - msg, - ))); + if balance1 > 0.0 { + // TODO: Localise + message(Content::Plain(format!( + "That only covers {:.0}% of my costs!", + (balance0 / balance1 * 100.0).floor() + ))); + } + if pending.phase != TradePhase::Mutate { + // we got into the review phase but without balanced goods, + // decline + agent.behavior.unset(BehaviorState::TRADING); + agent.target = None; + event_emitter.emit(ServerEvent::ProcessTradeAction( + *agent_data.entity, + tradeid, + TradeAction::Decline, + )); + } } - } - if pending.phase != TradePhase::Mutate { - // we got into the review phase but without balanced goods, decline - agent.behavior.unset(BehaviorState::TRADING); - agent.target = None; - event_emitter.emit(ServerEvent::ProcessTradeAction( - *agent_data.entity, - tradeid, - TradeAction::Decline, - )); - } + }, } }, TradingBehavior::AcceptFood => { @@ -670,7 +545,7 @@ pub fn handle_inbox_cancel_interactions(bdata: &mut BehaviorData) -> bool { // in combat, speak to players that aren't the current target if !target.hostile || target.target != speaker { agent_data.chat_npc_if_allowed_to_speak( - "npc-speech-villager_busy", + Content::localized("npc-speech-villager_busy"), agent, event_emitter, ); @@ -688,13 +563,13 @@ pub fn handle_inbox_cancel_interactions(bdata: &mut BehaviorData) -> bool { if !target.hostile || target.target != speaker { if agent.behavior.can_trade(agent_data.alignment.copied(), *by) { agent_data.chat_npc_if_allowed_to_speak( - "npc-speech-merchant_busy", + Content::localized("npc-speech-merchant_busy"), agent, event_emitter, ); } else { agent_data.chat_npc_if_allowed_to_speak( - "npc-speech-villager_busy", + Content::localized("npc-speech-villager_busy"), agent, event_emitter, ); @@ -710,14 +585,14 @@ pub fn handle_inbox_cancel_interactions(bdata: &mut BehaviorData) -> bool { match result { TradeResult::Completed => { agent_data.chat_npc_if_allowed_to_speak( - "npc-speech-merchant_trade_successful", + Content::localized("npc-speech-merchant_trade_successful"), agent, event_emitter, ); }, _ => { agent_data.chat_npc_if_allowed_to_speak( - "npc-speech-merchant_trade_declined", + Content::localized("npc-speech-merchant_trade_declined"), agent, event_emitter, ); @@ -739,7 +614,7 @@ pub fn handle_inbox_cancel_interactions(bdata: &mut BehaviorData) -> bool { TradeAction::Decline, )); agent_data.chat_npc_if_allowed_to_speak( - "npc-speech-merchant_trade_cancelled_hostile", + Content::localized("npc-speech-merchant_trade_cancelled_hostile"), agent, event_emitter, ); @@ -750,6 +625,7 @@ pub fn handle_inbox_cancel_interactions(bdata: &mut BehaviorData) -> bool { if used { agent.inbox.pop_front(); } + return used; } false } diff --git a/server/src/sys/chunk_serialize.rs b/server/src/sys/chunk_serialize.rs index c12006007a..0810aa9951 100644 --- a/server/src/sys/chunk_serialize.rs +++ b/server/src/sys/chunk_serialize.rs @@ -2,10 +2,9 @@ use crate::{ chunk_serialize::{ChunkSendEntry, SerializedChunk}, client::Client, metrics::NetworkRequestMetrics, - presence::Presence, Tick, }; -use common::{event::EventBus, slowjob::SlowJobPool, terrain::TerrainGrid}; +use common::{comp::Presence, event::EventBus, slowjob::SlowJobPool, terrain::TerrainGrid}; use common_ecs::{Job, Origin, Phase, System}; use common_net::msg::{SerializedTerrainChunk, ServerGeneral}; use hashbrown::{hash_map::Entry, HashMap}; diff --git a/server/src/sys/entity_sync.rs b/server/src/sys/entity_sync.rs index c2c246e61c..c6ac05e2d9 100644 --- a/server/src/sys/entity_sync.rs +++ b/server/src/sys/entity_sync.rs @@ -1,12 +1,8 @@ use super::sentinel::{DeletedEntities, TrackedStorages, UpdateTrackers}; -use crate::{ - client::Client, - presence::{Presence, RegionSubscription}, - Tick, -}; +use crate::{client::Client, presence::RegionSubscription, Tick}; use common::{ calendar::Calendar, - comp::{Collider, ForceUpdate, InventoryUpdate, Last, Ori, Player, Pos, Vel}, + comp::{Collider, ForceUpdate, InventoryUpdate, Last, Ori, Player, Pos, Presence, Vel}, event::EventBus, outcome::Outcome, region::{Event as RegionEvent, RegionMap}, diff --git a/server/src/sys/msg/character_screen.rs b/server/src/sys/msg/character_screen.rs index 661aaabc9f..2e2677a704 100644 --- a/server/src/sys/msg/character_screen.rs +++ b/server/src/sys/msg/character_screen.rs @@ -8,11 +8,10 @@ use crate::{ character_creator, client::Client, persistence::{character_loader::CharacterLoader, character_updater::CharacterUpdater}, - presence::Presence, EditableSettings, }; use common::{ - comp::{Admin, AdminRole, ChatType, Player, UnresolvedChatMsg, Waypoint}, + comp::{Admin, AdminRole, ChatType, Player, Presence, Waypoint}, event::{EventBus, ServerEvent}, resources::Time, terrain::TerrainChunkSize, @@ -49,7 +48,7 @@ impl Sys { if !editable_settings.server_description.is_empty() { client.send(ServerGeneral::server_msg( ChatType::CommandInfo, - &*editable_settings.server_description, + editable_settings.server_description.as_str(), ))?; } @@ -63,10 +62,9 @@ impl Sys { if !client.login_msg_sent.load(Ordering::Relaxed) { if let Some(player_uid) = uids.get(entity) { - server_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg { - chat_type: ChatType::Online(*player_uid), - message: "".to_string(), - })); + server_emitter.emit(ServerEvent::Chat( + ChatType::Online(*player_uid).into_plain_msg(""), + )); client.login_msg_sent.store(true, Ordering::Relaxed); } diff --git a/server/src/sys/msg/general.rs b/server/src/sys/msg/general.rs index 515e326098..17162b3227 100644 --- a/server/src/sys/msg/general.rs +++ b/server/src/sys/msg/general.rs @@ -28,7 +28,7 @@ impl Sys { const CHAT_MODE_DEFAULT: &ChatMode = &ChatMode::default(); let mode = chat_modes.get(entity).unwrap_or(CHAT_MODE_DEFAULT); // Send chat message - server_emitter.emit(ServerEvent::Chat(mode.new_message(*from, message))); + server_emitter.emit(ServerEvent::Chat(mode.to_plain_msg(*from, message))); } else { error!("Could not send message. Missing player uid"); } diff --git a/server/src/sys/msg/in_game.rs b/server/src/sys/msg/in_game.rs index 6c50879800..4e216483d8 100644 --- a/server/src/sys/msg/in_game.rs +++ b/server/src/sys/msg/in_game.rs @@ -1,10 +1,10 @@ #[cfg(feature = "persistent_world")] use crate::TerrainPersistence; -use crate::{client::Client, presence::Presence, Settings}; +use crate::{client::Client, Settings}; use common::{ comp::{ Admin, AdminRole, CanBuild, ControlEvent, Controller, ForceUpdate, Health, Ori, Player, - Pos, SkillSet, Vel, + Pos, Presence, PresenceKind, SkillSet, Vel, }, event::{EventBus, ServerEvent}, link::Is, @@ -15,7 +15,7 @@ use common::{ vol::ReadVol, }; use common_ecs::{Job, Origin, Phase, System}; -use common_net::msg::{ClientGeneral, PresenceKind, ServerGeneral}; +use common_net::msg::{ClientGeneral, ServerGeneral}; use common_state::{BlockChange, BuildAreas}; use core::mem; use rayon::prelude::*; diff --git a/server/src/sys/msg/terrain.rs b/server/src/sys/msg/terrain.rs index 6eb01033a3..6c050d6a83 100644 --- a/server/src/sys/msg/terrain.rs +++ b/server/src/sys/msg/terrain.rs @@ -1,9 +1,9 @@ use crate::{ chunk_serialize::ChunkSendEntry, client::Client, lod::Lod, metrics::NetworkRequestMetrics, - presence::Presence, ChunkRequest, + ChunkRequest, }; use common::{ - comp::Pos, + comp::{Pos, Presence}, event::{EventBus, ServerEvent}, spiral::Spiral2d, terrain::{CoordinateConversions, TerrainChunkSize, TerrainGrid}, diff --git a/server/src/sys/persistence.rs b/server/src/sys/persistence.rs index de4eb10885..3011664ebe 100644 --- a/server/src/sys/persistence.rs +++ b/server/src/sys/persistence.rs @@ -1,13 +1,13 @@ -use crate::{persistence::character_updater, presence::Presence, sys::SysScheduler}; +use crate::{persistence::character_updater, sys::SysScheduler}; use common::{ comp::{ pet::{is_tameable, Pet}, - ActiveAbilities, Alignment, Body, Inventory, MapMarker, SkillSet, Stats, Waypoint, + ActiveAbilities, Alignment, Body, Inventory, MapMarker, Presence, PresenceKind, SkillSet, + Stats, Waypoint, }, uid::Uid, }; use common_ecs::{Job, Origin, Phase, System}; -use common_net::msg::PresenceKind; use specs::{Join, ReadStorage, Write, WriteExpect}; #[derive(Default)] diff --git a/server/src/sys/pets.rs b/server/src/sys/pets.rs index 67b7862a61..5235dc3f52 100644 --- a/server/src/sys/pets.rs +++ b/server/src/sys/pets.rs @@ -66,7 +66,7 @@ impl<'a> System<'a> for Sys { // TODO: Create a teleportation event to handle this instead of // processing the entity position move here pet_pos.0 = terrain - .find_space(owner_pos.0.map(|e| e.floor() as i32)) + .find_ground(owner_pos.0.map(|e| e.floor() as i32)) .map(|e| e as f32); } } diff --git a/server/src/sys/subscription.rs b/server/src/sys/subscription.rs index 1be6e66d28..3b20bde524 100644 --- a/server/src/sys/subscription.rs +++ b/server/src/sys/subscription.rs @@ -1,10 +1,10 @@ use super::sentinel::{DeletedEntities, TrackedStorages}; use crate::{ client::Client, - presence::{self, Presence, RegionSubscription}, + presence::{self, RegionSubscription}, }; use common::{ - comp::{Ori, Pos, Vel}, + comp::{Ori, Pos, Presence, Vel}, region::{region_in_vd, regions_in_vd, Event as RegionEvent, RegionMap}, terrain::{CoordinateConversions, TerrainChunkSize}, uid::Uid, diff --git a/server/src/sys/terrain.rs b/server/src/sys/terrain.rs index 3df4ff170e..62355c566b 100644 --- a/server/src/sys/terrain.rs +++ b/server/src/sys/terrain.rs @@ -6,20 +6,16 @@ use crate::TerrainPersistence; use world::{IndexOwned, World}; use crate::{ - chunk_generator::ChunkGenerator, - chunk_serialize::ChunkSendEntry, - client::Client, - presence::{Presence, RepositionOnChunkLoad}, - rtsim::RtSim, - settings::Settings, - ChunkRequest, Tick, + chunk_generator::ChunkGenerator, chunk_serialize::ChunkSendEntry, client::Client, + presence::RepositionOnChunkLoad, rtsim, settings::Settings, ChunkRequest, Tick, }; use common::{ calendar::Calendar, comp::{ - self, agent, bird_medium, skillset::skills, BehaviorCapability, ForceUpdate, Pos, Waypoint, + self, agent, bird_medium, skillset::skills, BehaviorCapability, ForceUpdate, Pos, Presence, + Waypoint, }, - event::{EventBus, ServerEvent}, + event::{EventBus, NpcBuilder, ServerEvent}, generation::EntityInfo, lottery::LootSpec, resources::{Time, TimeOfDay}, @@ -49,6 +45,11 @@ pub type TerrainPersistenceData<'a> = (); pub const SAFE_ZONE_RADIUS: f32 = 200.0; +#[cfg(feature = "worldgen")] +type RtSimData<'a> = WriteExpect<'a, rtsim::RtSim>; +#[cfg(not(feature = "worldgen"))] +type RtSimData<'a> = (); + /// This system will handle loading generated chunks and unloading /// unneeded chunks. /// 1. Inserts newly generated chunks into the TerrainGrid @@ -73,7 +74,7 @@ impl<'a> System<'a> for Sys { WriteExpect<'a, TerrainGrid>, Write<'a, TerrainChanges>, Write<'a, Vec>, - WriteExpect<'a, RtSim>, + RtSimData<'a>, TerrainPersistenceData<'a>, WriteStorage<'a, Pos>, ReadStorage<'a, Presence>, @@ -130,6 +131,7 @@ impl<'a> System<'a> for Sys { request.key, &slow_jobs, Arc::clone(&world), + &rtsim, index.clone(), (*time_of_day, calendar.clone()), ) @@ -174,7 +176,8 @@ impl<'a> System<'a> for Sys { terrain_changes.modified_chunks.insert(key); } else { terrain_changes.new_chunks.insert(key); - rtsim.hook_load_chunk(key); + #[cfg(feature = "worldgen")] + rtsim.hook_load_chunk(key, supplement.rtsim_max_resources); } // Handle chunk supplement @@ -208,19 +211,15 @@ impl<'a> System<'a> for Sys { } => { server_emitter.emit(ServerEvent::CreateNpc { pos, - stats, - skill_set, - health, - poise, - inventory, - agent, - body, - alignment, - scale, - anchor: Some(comp::Anchor::Chunk(key)), - loot, - rtsim_entity: None, - projectile: None, + npc: NpcBuilder::new(stats, body, alignment) + .with_skill_set(skill_set) + .with_health(health) + .with_poise(poise) + .with_inventory(inventory) + .with_agent(agent) + .with_scale(scale) + .with_anchor(comp::Anchor::Chunk(key)) + .with_loot(loot), }); }, } @@ -229,11 +228,11 @@ impl<'a> System<'a> for Sys { // TODO: Consider putting this in another system since this forces us to take // positions by write rather than read access. - let repositioned = (&entities, &mut positions, (&mut force_update).maybe(), reposition_on_load.mask()) + let repositioned = (&entities, &mut positions, (&mut force_update).maybe(), &reposition_on_load) // TODO: Consider using par_bridge() because Rayon has very poor work splitting for // sparse joins. .par_join() - .filter_map(|(entity, pos, force_update, _)| { + .filter_map(|(entity, pos, force_update, reposition)| { // NOTE: We use regular as casts rather than as_ because we want to saturate on // overflow. let entity_pos = pos.0.map(|x| x as i32); @@ -241,10 +240,11 @@ impl<'a> System<'a> for Sys { // from having just logged in), reposition them. let chunk_pos = TerrainGrid::chunk_key(entity_pos); let chunk = terrain.get_key(chunk_pos)?; - let new_pos = terrain - .try_find_space(entity_pos) - .map(|x| x.as_::()) - .unwrap_or_else(|| chunk.find_accessible_pos(entity_pos.xy(), false)); + let new_pos = if reposition.needs_ground { + terrain.try_find_ground(entity_pos) + } else { + terrain.try_find_space(entity_pos) + }.map(|x| x.as_::()).unwrap_or_else(|| chunk.find_accessible_pos(entity_pos.xy(), false)); pos.0 = new_pos; force_update.map(|force_update| force_update.update()); Some((entity, new_pos)) @@ -252,7 +252,9 @@ impl<'a> System<'a> for Sys { .collect::>(); for (entity, new_pos) in repositioned { - let _ = waypoints.insert(entity, Waypoint::new(new_pos, *time)); + if let Some(waypoint) = waypoints.get_mut(entity) { + *waypoint = Waypoint::new(new_pos, *time); + } reposition_on_load.remove(entity); } @@ -378,6 +380,7 @@ impl<'a> System<'a> for Sys { // TODO: code duplication for chunk insertion between here and state.rs terrain.remove(key).map(|chunk| { terrain_changes.removed_chunks.insert(key); + #[cfg(feature = "worldgen")] rtsim.hook_unload_chunk(key); chunk }) @@ -503,15 +506,19 @@ impl NpcData { }; let agent = has_agency.then(|| { - comp::Agent::from_body(&body) - .with_behavior( - Behavior::default() - .maybe_with_capabilities(can_speak.then_some(BehaviorCapability::SPEAK)) - .maybe_with_capabilities(trade_for_site.map(|_| BehaviorCapability::TRADE)) - .with_trade_site(trade_for_site), - ) - .with_patrol_origin(pos) - .with_no_flee_if(matches!(agent_mark, Some(agent::Mark::Guard)) || no_flee) + let mut agent = comp::Agent::from_body(&body).with_behavior( + Behavior::default() + .maybe_with_capabilities(can_speak.then_some(BehaviorCapability::SPEAK)) + .maybe_with_capabilities(trade_for_site.map(|_| BehaviorCapability::TRADE)) + .with_trade_site(trade_for_site), + ); + + // Non-humanoids get a patrol origin to stop them moving too far + if !matches!(body, comp::Body::Humanoid(_)) { + agent = agent.with_patrol_origin(pos); + } + + agent.with_no_flee_if(matches!(agent_mark, Some(agent::Mark::Guard)) || no_flee) }); let agent = if matches!(alignment, comp::Alignment::Enemy) diff --git a/server/src/sys/terrain_sync.rs b/server/src/sys/terrain_sync.rs index 78accf71f3..cfc32fe04e 100644 --- a/server/src/sys/terrain_sync.rs +++ b/server/src/sys/terrain_sync.rs @@ -1,5 +1,8 @@ -use crate::{chunk_serialize::ChunkSendEntry, client::Client, presence::Presence, Settings}; -use common::{comp::Pos, event::EventBus}; +use crate::{chunk_serialize::ChunkSendEntry, client::Client, Settings}; +use common::{ + comp::{Pos, Presence}, + event::EventBus, +}; use common_ecs::{Job, Origin, Phase, System}; use common_net::msg::{CompressedData, ServerGeneral}; use common_state::TerrainChanges; diff --git a/voxygen/anim/src/character/talk.rs b/voxygen/anim/src/character/talk.rs index f37de18162..74027cc41c 100644 --- a/voxygen/anim/src/character/talk.rs +++ b/voxygen/anim/src/character/talk.rs @@ -31,7 +31,8 @@ impl Animation for TalkAnimation { let slowb = (anim_time * 4.0 + PI / 2.0).sin(); let slowc = (anim_time * 12.0 + PI / 2.0).sin(); - next.head.orientation = Quaternion::rotation_x(slowc * 0.035 + look_dir.z * 0.7); + next.head.orientation = + Quaternion::rotation_x(slowc * 0.035 + look_dir.z.atan2(look_dir.xy().magnitude())); next.hand_l.position = Vec3::new( -s_a.hand.0 + 0.5 + slowb * 0.5, s_a.hand.1 + 5.0 + slowc * 1.0, diff --git a/voxygen/anim/src/ship/mod.rs b/voxygen/anim/src/ship/mod.rs index 81c9b33f2c..d1d0c4e679 100644 --- a/voxygen/anim/src/ship/mod.rs +++ b/voxygen/anim/src/ship/mod.rs @@ -31,7 +31,8 @@ impl Skeleton for ShipSkeleton { buf: &mut [FigureBoneData; super::MAX_BONE_COUNT], body: Self::Body, ) -> Offsets { - let scale_mat = Mat4::scaling_3d(1.0 / 11.0); + // Ships are normal scale + let scale_mat = Mat4::scaling_3d(1.0); let bone0_mat = base_mat * scale_mat * Mat4::::from(self.bone0); diff --git a/voxygen/benches/meshing_benchmark.rs b/voxygen/benches/meshing_benchmark.rs index e6af81a14e..9d6d250def 100644 --- a/voxygen/benches/meshing_benchmark.rs +++ b/voxygen/benches/meshing_benchmark.rs @@ -38,7 +38,9 @@ pub fn criterion_benchmark(c: &mut Criterion) { .map(|pos| { ( pos, - world.generate_chunk(index, pos, || false, None).unwrap(), + world + .generate_chunk(index, pos, None, || false, None) + .unwrap(), ) }) .for_each(|(key, chunk)| { diff --git a/voxygen/egui/src/lib.rs b/voxygen/egui/src/lib.rs index f83b0f59dd..25c2c699bf 100644 --- a/voxygen/egui/src/lib.rs +++ b/voxygen/egui/src/lib.rs @@ -346,8 +346,19 @@ pub fn maintain_egui_inner( ui.label("Body"); ui.label("Poise"); ui.label("Character State"); + ui.label("Character Activity"); ui.end_row(); - for (entity, body, stats, pos, _ori, vel, poise, character_state) in ( + for ( + entity, + body, + stats, + pos, + _ori, + vel, + poise, + character_state, + character_activity, + ) in ( &ecs.entities(), ecs.read_storage::().maybe(), ecs.read_storage::().maybe(), @@ -356,14 +367,18 @@ pub fn maintain_egui_inner( ecs.read_storage::().maybe(), ecs.read_storage::().maybe(), ecs.read_storage::().maybe(), + ecs.read_storage::().maybe(), ) .join() - .filter(|(_, _, _, pos, _, _, _, _)| { - client_pos.map_or(true, |client_pos| { - pos.map_or(0.0, |pos| pos.0.distance_squared(client_pos.0)) - < max_entity_distance - }) - }) + .filter( + |(_, _, _, pos, _, _, _, _, _)| { + client_pos.map_or(true, |client_pos| { + pos.map_or(0.0, |pos| { + pos.0.distance_squared(client_pos.0) + }) < max_entity_distance + }) + }, + ) { if ui.button("View").clicked() { previous_selected_entity = @@ -420,6 +435,12 @@ pub fn maintain_egui_inner( ui.label("-"); } + if let Some(character_activity) = character_activity { + ui.label(format!("{:?}", character_activity)); + } else { + ui.label("-"); + } + ui.end_row(); } }); @@ -507,11 +528,11 @@ fn selected_entity_window( buffs, auras, character_state, + character_activity, physics_state, alignment, scale, - mass, - (density, health, energy), + (mass, density, health, energy), ) in ( &ecs.entities(), ecs.read_storage::().maybe(), @@ -523,18 +544,19 @@ fn selected_entity_window( ecs.read_storage::().maybe(), ecs.read_storage::().maybe(), ecs.read_storage::().maybe(), + ecs.read_storage::().maybe(), ecs.read_storage::().maybe(), ecs.read_storage::().maybe(), ecs.read_storage::().maybe(), - ecs.read_storage::().maybe(), ( + ecs.read_storage::().maybe(), ecs.read_storage::().maybe(), ecs.read_storage::().maybe(), ecs.read_storage::().maybe(), ), ) .join() - .filter(|(e, _, _, _, _, _, _, _, _, _, _, _, _, _, (_, _, _))| e.id() == entity_id) + .filter(|(e, _, _, _, _, _, _, _, _, _, _, _, _, _, (_, _, _, _))| e.id() == entity_id) { let time = ecs.read_resource::