diff --git a/protocol/src/utils.rs b/protocol/src/utils.rs index e00640d..ea85d72 100644 --- a/protocol/src/utils.rs +++ b/protocol/src/utils.rs @@ -10,6 +10,7 @@ use tokio::io; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use crate::{ReadCwData, WriteCwData}; +use crate::packet::common::item::Kind; use crate::utils::constants::SIZE_BLOCK; use crate::utils::io_extensions::{ReadArbitrary, WriteArbitrary}; @@ -47,6 +48,13 @@ pub fn max_level_of(level: i32) -> i32 { (level..=i32::from(i16::MAX)).take_while(|&l| power_of(l) == power).last().unwrap_or(level) } +#[must_use] +pub fn max_valid_item_level(kind: Kind, player_level: i16) -> i16 { + if kind.uses_power() { max_level_of(player_level.into()) as i16 } + else if kind.uses_level() { player_level } + else { 1 } +} + #[must_use] pub fn maximum_experience_of(level: i32) -> i32 { // order of operations matches cubeworld's rounding behaviour diff --git a/protocol/src/utils/constants.rs b/protocol/src/utils/constants.rs index 5c118bd..d855c9e 100644 --- a/protocol/src/utils/constants.rs +++ b/protocol/src/utils/constants.rs @@ -63,6 +63,56 @@ pub const PLAYABLE_RACES: [Race; 16] = [ UndeadFemale ]; +pub const VANILLA_PETS: [Race; 47] = [ + Collie, + Alpaca, + AlpacaBrown, + Turtle, + Terrier, + TerrierScottish, + Cat, + Pig, + Sheep, + Bunny, + Porcupine, + SlimeGreen, + SlimePink, + SlimeYellow, + SlimeBlue, + Monkey, + Hornet, + Crow, + Chicken, + Seagull, + Parrot, + Bat, + Fly, + Midge, + Mosquito, + RunnerPlain, + RunnerLeaf, + RunnerSnow, + RunnerDesert, + Peacock, + Duckbill, + Crocodile, + Spitter, + Mole, + Biter, + Squirrel, + Raccoon, + Owl, + Penguin, + Horse, + Camel, + BeetleDark, + BeetleFire, + BeetleSnout, + BeetleLemon, + Crab, + Bumblebee +]; + pub const TWO_HANDED_WEAPONS: [Weapon; 10] = [ Longsword, Bow, diff --git a/server/src/addon/command_manager/commands/give.rs b/server/src/addon/command_manager/commands/give.rs index c391405..aded0fb 100644 --- a/server/src/addon/command_manager/commands/give.rs +++ b/server/src/addon/command_manager/commands/give.rs @@ -5,7 +5,7 @@ use protocol::packet::world_update::Pickup; use protocol::packet::WorldUpdate; use protocol::utils::constants::materials::by_item_kind; use protocol::utils::constants::rarity::*; -use protocol::utils::power_of; +use protocol::utils::{max_valid_item_level, power_of}; use tap::Pipe; use crate::addon::command_manager::{Command, CommandResult}; @@ -45,7 +45,7 @@ impl Command for super::Give { let valid_materials = by_item_kind(item.kind); if item.kind.uses_rarity() {item.rarity = LEGENDARY} else {item.rarity = NORMAL} - if item.kind.uses_level() {item.level = caller.character.read().await.level as i16} else {item.level = 1} + item.level = max_valid_item_level(item.kind, caller.character.read().await.level as i16); if !valid_materials.is_empty() {item.material = valid_materials[0]} for param in params { diff --git a/server/src/addon/events/legacy_koth.rs b/server/src/addon/events/legacy_koth.rs index 3c642c7..e89204e 100644 --- a/server/src/addon/events/legacy_koth.rs +++ b/server/src/addon/events/legacy_koth.rs @@ -4,21 +4,23 @@ use std::sync::Arc; use std::time::Duration; use config::{Config, ConfigError}; -use rand::random; -use strum::IntoEnumIterator; +use rand::{random_range, rng}; +use rand::seq::IndexedRandom; +use serde::Deserialize; use tap::Tap; use tokio::sync::RwLock; use tokio::time::sleep; use protocol::nalgebra::Point3; use protocol::packet::{ChatMessageFromServer, CreatureUpdate, WorldUpdate}; -use protocol::packet::common::{CreatureId, EulerAngles, Hitbox, Item, Race, item::{Kind, kind}}; -use protocol::packet::creature_update::Affiliation; +use protocol::packet::common::{CreatureId, EulerAngles, Hitbox, Item, Race, item::{Kind, Material, kind}}; +use protocol::packet::creature_update::{Affiliation, Occupation}; use protocol::packet::world_update::{Mission, Pickup, sound}; use protocol::packet::world_update::mission::{Objective, State}; -use protocol::utils::constants::{SIZE_BLOCK, SIZE_ZONE, SIZE_SECTOR}; +use protocol::utils::constants::{SIZE_BLOCK, SIZE_ZONE, SIZE_SECTOR, VANILLA_PETS}; use protocol::utils::constants::materials::by_item_kind; -use protocol::utils::constants::rarity::{NORMAL, RARE, EPIC, LEGENDARY}; +use protocol::utils::constants::rarity::{NORMAL, UNCOMMON, RARE, EPIC, LEGENDARY}; +use protocol::utils::max_valid_item_level; use crate::addon::events::utils::{appearance_invisible, config_fallback, config_optional, creatures_circular, is_in_zone, pick_from, NAME_OVERFLOW, RENDER_DISTANCE_CREATURE}; use crate::addon::play_sound_at_player; @@ -33,10 +35,6 @@ const TORCHES_ID: i64 = 75000; const REWARD_POINTS: i32 = 10000; const REWARD_THRESHOLDS: [i32; 4] = [20, 40, 60, 80]; -const REWARD_WEAPON_ODDS: f32 = 0.30; -const REWARD_ARMOR_ODDS: f32 = 0.30; -const REWARD_SPIRIT_ODDS: f32 = 0.35; - #[derive(Debug)] pub struct LegacyKoth { points: RwLock>, @@ -50,7 +48,9 @@ pub struct LegacyKoth { kill_king_points: i32, kill_king_xp: i32, kill_points: i32, - kill_xp: i32 + kill_xp: i32, + loot: LootWeights, + rarity: RarityWeights } impl LegacyKoth { @@ -63,6 +63,11 @@ impl LegacyKoth { let reward_frequency: i32 = config_fallback(config, "legacykoth.reward_frequency", 420_i32)?; let king_reward_frequency: i32 = config_fallback(config, "legacykoth.king_reward_frequency", 180_i32)?; + let loot: LootWeights = config_fallback(config, "legacykoth.loot", LootWeights::default())?; + let rarity: RarityWeights = config_fallback(config, "legacykoth.rarity", RarityWeights::default())?; + if loot.table().iter().all(|&(_, weight)| weight == 0) { return Err(ConfigError::Message("legacykoth.loot needs a non-zero weight".into())) } + if rarity.table().iter().all(|&(_, weight)| weight == 0) { return Err(ConfigError::Message("legacykoth.rarity needs a non-zero weight".into())) } + Ok(Self { points: RwLock::new(HashMap::new()), center, @@ -75,7 +80,9 @@ impl LegacyKoth { kill_king_points: config_fallback(config, "legacykoth.kill_king_points", 500_i32)?, kill_king_xp: config_fallback(config, "legacykoth.kill_king_xp", 20_i32)?, kill_points: config_fallback(config, "legacykoth.kill_points", 200_i32)?, - kill_xp: config_fallback(config, "legacykoth.kill_xp", 10_i32)? + kill_xp: config_fallback(config, "legacykoth.kill_xp", 10_i32)?, + loot, + rarity }) } } @@ -194,35 +201,109 @@ async fn handle_points(player: &Player, threshold: Option, reward: bool) { } async fn give_reward(player: &Player) { - let level = player.character.read().await.level as i16; - let pickup = Pickup { interactor: player.id, item: reward_item(level) }; + let (level, occupation) = { + let character = player.character.read().await; + (character.level as i16, character.occupation) + }; + let pickup = Pickup { interactor: player.id, item: reward_item(&SERVER.addons.events.legacy_koth, level, occupation) }; play_sound_at_player(player, sound::Kind::Missioncomplete, 0.62, 1.0).await; player.send_ignoring(&WorldUpdate::from(pickup)).await; } -fn reward_item(level: i16) -> Item { +#[derive(Debug, Clone, Copy)] +enum Loot { Weapon, Armor, Amulet, Ring, Leftovers, Spirit, Lamp, Pet } + +#[derive(Debug, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct LootWeights { weapon: u32, armor: u32, amulet: u32, ring: u32, leftovers: u32, spirit: u32, lamp: u32, pet: u32 } + +impl Default for LootWeights { + fn default() -> Self { + Self { weapon: 25, armor: 25, amulet: 7, ring: 7, leftovers: 4, spirit: 25, lamp: 2, pet: 5 } + } +} + +impl LootWeights { + const fn table(&self) -> [(Loot, u32); 8] { + [(Loot::Weapon, self.weapon), (Loot::Armor, self.armor), (Loot::Amulet, self.amulet), (Loot::Ring, self.ring), + (Loot::Leftovers, self.leftovers), (Loot::Spirit, self.spirit), (Loot::Lamp, self.lamp), (Loot::Pet, self.pet)] + } +} + +#[derive(Debug, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct RarityWeights { normal: u32, uncommon: u32, rare: u32, epic: u32, legendary: u32 } + +impl Default for RarityWeights { + fn default() -> Self { + Self { normal: 0, uncommon: 0, rare: 0, epic: 1, legendary: 1 } + } +} + +impl RarityWeights { + const fn table(&self) -> [(u8, u32); 5] { + [(NORMAL, self.normal), (UNCOMMON, self.uncommon), (RARE, self.rare), (EPIC, self.epic), (LEGENDARY, self.legendary)] + } +} + +fn pick_weighted(table: &[(T, u32)]) -> Option { + table.choose_weighted(&mut rng(), |(_, weight)| *weight).ok().map(|(value, _)| *value) +} + +type ClassWeapons = &'static [(kind::Weapon, &'static [Material])]; + +fn class_gear(occupation: Occupation) -> Option<(Material, ClassWeapons)> { + use kind::Weapon::{Axe, Boomerang, Bow, Bracelet, Crossbow, Dagger, Fist, Greataxe, Greatmace, Greatsword, Longsword, Mace, Shield, Staff, Sword, Wand}; + use Material::{Cotton, Gold, Iron, Linen, Silk, Silver, Wood}; + + match occupation { + Occupation::Warrior => Some((Iron, &[(Sword, &[Iron]), (Axe, &[Iron]), (Mace, &[Iron]), (Shield, &[Iron]), + (Greatsword, &[Iron]), (Greataxe, &[Iron]), (Greatmace, &[Iron, Wood])])), + Occupation::Ranger => Some((Linen, &[(Bow, &[Wood]), (Crossbow, &[Wood]), (Boomerang, &[Wood])])), + Occupation::Mage => Some((Silk, &[(Wand, &[Wood]), (Staff, &[Wood]), (Bracelet, &[Gold, Silver])])), + Occupation::Rogue => Some((Cotton, &[(Longsword, &[Iron]), (Dagger, &[Iron]), (Fist, &[Iron])])), + _ => None + } +} + +fn reward_item(lkoth: &LegacyKoth, level: i16, occupation: Occupation) -> Item { let mut item = Item::default(); - item.kind = match random::() { - roll if roll < REWARD_WEAPON_ODDS - => Kind::Weapon(pick_from(&kind::Weapon::iter().collect::>())), - roll if roll < REWARD_WEAPON_ODDS + REWARD_ARMOR_ODDS - => pick_from(&[Kind::Chest, Kind::Gloves, Kind::Boots, Kind::Shoulder]), - roll if roll < REWARD_WEAPON_ODDS + REWARD_ARMOR_ODDS + REWARD_SPIRIT_ODDS - => Kind::Resource(kind::Resource::Spirit), - _ => Kind::Pet(pick_from(&Race::iter().collect::>())) + let gear = class_gear(occupation); + let table = lkoth.loot.table().map(|(loot, weight)| match loot { + Loot::Weapon | Loot::Armor if gear.is_none() => (loot, 0), + _ => (loot, weight) + }); + + (item.kind, item.material) = match (pick_weighted(&table), gear) { + (Some(Loot::Weapon), Some((_, weapons))) => { + let (weapon, materials) = pick_from(weapons); + (Kind::Weapon(weapon), pick_from(materials)) + } + (Some(Loot::Armor), Some((armor, _))) + => (pick_from(&[Kind::Chest, Kind::Gloves, Kind::Boots, Kind::Shoulder]), armor), + (loot, _) => { + let kind = match loot { + Some(Loot::Amulet) => Kind::Amulet, + Some(Loot::Ring) => Kind::Ring, + Some(Loot::Leftovers) => Kind::Leftovers, + Some(Loot::Lamp) => Kind::Lamp, + Some(Loot::Pet) => Kind::Pet(pick_from(&VANILLA_PETS)), + _ => Kind::Resource(kind::Resource::Spirit) + }; + (kind, pick_from(by_item_kind(kind))) + } }; item.rarity = match item.kind { - Kind::Weapon(_) | Kind::Chest | Kind::Gloves | Kind::Boots | Kind::Shoulder - => pick_from(&[EPIC, LEGENDARY]), + Kind::Weapon(_) | Kind::Chest | Kind::Gloves | Kind::Boots | Kind::Shoulder | Kind::Amulet | Kind::Ring | Kind::Leftovers + => pick_weighted(&lkoth.rarity.table()).expect("validated at startup"), Kind::Resource(kind::Resource::Spirit) => RARE, _ => if item.kind.uses_rarity() { LEGENDARY } else { NORMAL } }; - item.material = pick_from(by_item_kind(item.kind)); - item.level = if item.kind.uses_level() { level } else { 1 }; - item.seed = if item.uses_seed() { random() } else { 0 }; + item.level = max_valid_item_level(item.kind, level); + item.seed = if item.uses_seed() { random_range(0..=i32::MAX) } else { 0 }; item } diff --git a/server/src/addon/events/shop.rs b/server/src/addon/events/shop.rs index d6a23a7..4b19a3c 100644 --- a/server/src/addon/events/shop.rs +++ b/server/src/addon/events/shop.rs @@ -17,7 +17,7 @@ use protocol::packet::WorldUpdate; use protocol::packet::CreatureUpdate; use protocol::utils::constants::{materials, SIZE_BLOCK, SIZE_ZONE}; use protocol::utils::constants::rarity::*; -use protocol::utils::max_level_of; +use protocol::utils::max_valid_item_level; use crate::addon::events::utils::{appearance_invisible, config_fallback, config_optional, creatures_circular, NAME_OVERFLOW}; use crate::addon::play_sound_at_player; @@ -306,7 +306,7 @@ fn item_validation(state: &mut State, item: &mut Item, player_level: i16) -> Res State::Rarity => {if item.kind.uses_rarity() { return Ok(()) } item.rarity = NORMAL; *state = State::Model;} - State::Model => {item.level = item_level(item.kind, player_level); + State::Model => {item.level = max_valid_item_level(item.kind, player_level); if item.uses_models() { return Ok(()) } item.seed = 0; *state = State::Stat;} @@ -317,12 +317,6 @@ fn item_validation(state: &mut State, item: &mut Item, player_level: i16) -> Res } } -fn item_level(kind: Kind, player_level: i16) -> i16 { - if kind.uses_power() { max_level_of(player_level.into()) as i16 } - else if kind.uses_level() { player_level } - else { 1 } -} - fn item_name(kind: Kind) -> String { let subtype = subtype_names(kind); if subtype.is_empty() { kind.to_string() } else { subtype }