From 68bae5899ef0741075063cb8d97ab30b05e5c543 Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Fri, 12 Jun 2026 20:55:29 +0200 Subject: [PATCH] feat: add stats --- assets/stats.json | 1 + pumpkin-codegen/src/main.rs | 2 + pumpkin-codegen/src/statistic.rs | 115 +++++++++ pumpkin-data/Cargo.toml | 2 + pumpkin-data/src/generated/statistic.rs | 226 ++++++++++++++++++ pumpkin-data/src/lib.rs | 5 + pumpkin-inventory/Cargo.toml | 1 + .../src/crafting/crafting_screen_handler.rs | 18 +- .../enchanting/enchanting_screen_handler.rs | 9 + .../src/furnace_like/furnace_like_slot.rs | 11 +- .../src/player/player_screen_handler.rs | 4 +- pumpkin-inventory/src/screen_handler.rs | 9 + .../src/stonecutter_screen_handler.rs | 12 +- .../src/java/client/play/award_stats.rs | 17 ++ pumpkin-protocol/src/java/client/play/mod.rs | 2 + .../src/generation/noise/aquifer_sampler.rs | 1 - pumpkin/src/block/blocks/anvil.rs | 7 + pumpkin/src/block/blocks/barrel.rs | 7 + pumpkin/src/block/blocks/beacon.rs | 8 + pumpkin/src/block/blocks/bed.rs | 7 + pumpkin/src/block/blocks/blast_furnace.rs | 7 + pumpkin/src/block/blocks/brewing_stand.rs | 7 + pumpkin/src/block/blocks/cake.rs | 14 ++ pumpkin/src/block/blocks/chests.rs | 7 + pumpkin/src/block/blocks/crafting_table.rs | 7 + pumpkin/src/block/blocks/ender_chest.rs | 9 +- pumpkin/src/block/blocks/furnace.rs | 7 + pumpkin/src/block/blocks/hopper.rs | 7 + pumpkin/src/block/blocks/jukebox.rs | 9 +- pumpkin/src/block/blocks/lectern.rs | 7 + pumpkin/src/block/blocks/note.rs | 8 + pumpkin/src/block/blocks/redstone/bell.rs | 8 + pumpkin/src/block/blocks/shulker_box.rs | 7 + pumpkin/src/block/blocks/smoker.rs | 7 + pumpkin/src/block/blocks/stonecutter.rs | 7 + pumpkin/src/entity/ai/goal/breed.rs | 14 ++ pumpkin/src/entity/item.rs | 29 ++- pumpkin/src/entity/living.rs | 210 +++++++++++++--- pumpkin/src/entity/mob/mod.rs | 5 +- pumpkin/src/entity/passive/chicken.rs | 3 +- pumpkin/src/entity/passive/cow.rs | 3 +- pumpkin/src/entity/passive/pig.rs | 3 +- pumpkin/src/entity/passive/sheep.rs | 3 +- pumpkin/src/entity/passive/villager/mod.rs | 8 + pumpkin/src/entity/player.rs | 198 ++++++++++++++- pumpkin/src/entity/player/statistics.rs | 52 ++++ .../src/entity/projectile/fishing_bobber.rs | 12 +- pumpkin/src/item/items/fishing_rod.rs | 2 +- pumpkin/src/net/java/play.rs | 70 +++++- pumpkin/src/server/mod.rs | 7 + 50 files changed, 1141 insertions(+), 60 deletions(-) create mode 100644 assets/stats.json create mode 100644 pumpkin-codegen/src/statistic.rs create mode 100644 pumpkin-data/src/generated/statistic.rs create mode 100644 pumpkin-protocol/src/java/client/play/award_stats.rs create mode 100644 pumpkin/src/entity/player/statistics.rs diff --git a/assets/stats.json b/assets/stats.json new file mode 100644 index 000000000..27672fa4b --- /dev/null +++ b/assets/stats.json @@ -0,0 +1 @@ +{"minecraft:mined":{"id":0,"registry":"minecraft:block","display_name":{"translate":"stat_type.minecraft.mined"},"entries":{}},"minecraft:crafted":{"id":1,"registry":"minecraft:item","display_name":{"translate":"stat_type.minecraft.crafted"},"entries":{}},"minecraft:used":{"id":2,"registry":"minecraft:item","display_name":{"translate":"stat_type.minecraft.used"},"entries":{}},"minecraft:broken":{"id":3,"registry":"minecraft:item","display_name":{"translate":"stat_type.minecraft.broken"},"entries":{}},"minecraft:picked_up":{"id":4,"registry":"minecraft:item","display_name":{"translate":"stat_type.minecraft.picked_up"},"entries":{}},"minecraft:dropped":{"id":5,"registry":"minecraft:item","display_name":{"translate":"stat_type.minecraft.dropped"},"entries":{}},"minecraft:killed":{"id":6,"registry":"minecraft:entity_type","display_name":{"translate":"stat_type.minecraft.killed"},"entries":{}},"minecraft:killed_by":{"id":7,"registry":"minecraft:entity_type","display_name":{"translate":"stat_type.minecraft.killed_by"},"entries":{}},"minecraft:custom":{"id":8,"registry":"minecraft:custom_stat","display_name":{"translate":"stat_type.minecraft.custom"},"entries":{"minecraft:damage_blocked_by_shield":{"id":29,"formatter":"divide_by_ten"},"minecraft:interact_with_beacon":{"id":46,"formatter":"default"},"minecraft:fall_one_cm":{"id":10,"formatter":"distance"},"minecraft:open_chest":{"id":59,"formatter":"default"},"minecraft:play_time":{"id":1,"formatter":"time"},"minecraft:walk_one_cm":{"id":6,"formatter":"distance"},"minecraft:raid_win":{"id":72,"formatter":"default"},"minecraft:damage_dealt_resisted":{"id":27,"formatter":"divide_by_ten"},"minecraft:clean_armor":{"id":42,"formatter":"default"},"minecraft:swim_one_cm":{"id":20,"formatter":"distance"},"minecraft:happy_ghast_one_cm":{"id":17,"formatter":"distance"},"minecraft:talked_to_villager":{"id":37,"formatter":"default"},"minecraft:enchant_item":{"id":55,"formatter":"default"},"minecraft:drop":{"id":24,"formatter":"default"},"minecraft:pot_flower":{"id":52,"formatter":"default"},"minecraft:open_shulker_box":{"id":61,"formatter":"default"},"minecraft:walk_on_water_one_cm":{"id":9,"formatter":"distance"},"minecraft:interact_with_campfire":{"id":66,"formatter":"default"},"minecraft:interact_with_furnace":{"id":57,"formatter":"default"},"minecraft:interact_with_lectern":{"id":65,"formatter":"default"},"minecraft:raid_trigger":{"id":71,"formatter":"default"},"minecraft:strider_one_cm":{"id":21,"formatter":"distance"},"minecraft:crouch_one_cm":{"id":7,"formatter":"distance"},"minecraft:eat_cake_slice":{"id":39,"formatter":"default"},"minecraft:play_record":{"id":56,"formatter":"default"},"minecraft:interact_with_crafting_table":{"id":58,"formatter":"default"},"minecraft:sneak_time":{"id":5,"formatter":"time"},"minecraft:boat_one_cm":{"id":15,"formatter":"distance"},"minecraft:damage_resisted":{"id":31,"formatter":"divide_by_ten"},"minecraft:deaths":{"id":32,"formatter":"default"},"minecraft:total_world_time":{"id":2,"formatter":"time"},"minecraft:pig_one_cm":{"id":16,"formatter":"distance"},"minecraft:aviate_one_cm":{"id":19,"formatter":"distance"},"minecraft:jump":{"id":23,"formatter":"default"},"minecraft:animals_bred":{"id":34,"formatter":"default"},"minecraft:inspect_hopper":{"id":48,"formatter":"default"},"minecraft:interact_with_blast_furnace":{"id":63,"formatter":"default"},"minecraft:sprint_one_cm":{"id":8,"formatter":"distance"},"minecraft:damage_taken":{"id":28,"formatter":"divide_by_ten"},"minecraft:mob_kills":{"id":33,"formatter":"default"},"minecraft:interact_with_cartography_table":{"id":67,"formatter":"default"},"minecraft:time_since_death":{"id":3,"formatter":"time"},"minecraft:minecart_one_cm":{"id":14,"formatter":"distance"},"minecraft:interact_with_grindstone":{"id":74,"formatter":"default"},"minecraft:play_noteblock":{"id":50,"formatter":"default"},"minecraft:open_barrel":{"id":62,"formatter":"default"},"minecraft:nautilus_one_cm":{"id":22,"formatter":"distance"},"minecraft:clean_shulker_box":{"id":44,"formatter":"default"},"minecraft:fly_one_cm":{"id":12,"formatter":"distance"},"minecraft:player_kills":{"id":35,"formatter":"default"},"minecraft:inspect_dropper":{"id":47,"formatter":"default"},"minecraft:interact_with_loom":{"id":68,"formatter":"default"},"minecraft:damage_dealt":{"id":25,"formatter":"divide_by_ten"},"minecraft:inspect_dispenser":{"id":49,"formatter":"default"},"minecraft:use_cauldron":{"id":41,"formatter":"default"},"minecraft:time_since_rest":{"id":4,"formatter":"time"},"minecraft:interact_with_brewingstand":{"id":45,"formatter":"default"},"minecraft:interact_with_anvil":{"id":73,"formatter":"default"},"minecraft:tune_noteblock":{"id":51,"formatter":"default"},"minecraft:bell_ring":{"id":70,"formatter":"default"},"minecraft:fill_cauldron":{"id":40,"formatter":"default"},"minecraft:fish_caught":{"id":36,"formatter":"default"},"minecraft:climb_one_cm":{"id":11,"formatter":"distance"},"minecraft:interact_with_smithing_table":{"id":76,"formatter":"default"},"minecraft:trigger_trapped_chest":{"id":53,"formatter":"default"},"minecraft:interact_with_smoker":{"id":64,"formatter":"default"},"minecraft:open_enderchest":{"id":54,"formatter":"default"},"minecraft:clean_banner":{"id":43,"formatter":"default"},"minecraft:leave_game":{"id":0,"formatter":"default"},"minecraft:sleep_in_bed":{"id":60,"formatter":"default"},"minecraft:damage_dealt_absorbed":{"id":26,"formatter":"divide_by_ten"},"minecraft:walk_under_water_one_cm":{"id":13,"formatter":"distance"},"minecraft:traded_with_villager":{"id":38,"formatter":"default"},"minecraft:interact_with_stonecutter":{"id":69,"formatter":"default"},"minecraft:horse_one_cm":{"id":18,"formatter":"distance"},"minecraft:damage_absorbed":{"id":30,"formatter":"divide_by_ten"},"minecraft:target_hit":{"id":75,"formatter":"default"}}}} \ No newline at end of file diff --git a/pumpkin-codegen/src/main.rs b/pumpkin-codegen/src/main.rs index b5b1f5d50..0386459b9 100644 --- a/pumpkin-codegen/src/main.rs +++ b/pumpkin-codegen/src/main.rs @@ -54,6 +54,7 @@ mod screen; mod sound; mod sound_category; mod spawn_egg; +mod statistic; mod structures; mod tag; mod tracked_data; @@ -95,6 +96,7 @@ pub fn main() { (scoreboard_slot::build, "scoreboard_slot.rs"), (world_event::build, "world_event.rs"), (entity_type::build, "entity_type.rs"), + (statistic::build, "statistic.rs"), (noise_parameter::build, "noise_parameter.rs"), (biome::build, "biome.rs"), (damage_type::build, "damage_type.rs"), diff --git a/pumpkin-codegen/src/statistic.rs b/pumpkin-codegen/src/statistic.rs new file mode 100644 index 000000000..87d9b0f32 --- /dev/null +++ b/pumpkin-codegen/src/statistic.rs @@ -0,0 +1,115 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use serde::Deserialize; +use indexmap::IndexMap; +use heck::ToPascalCase; + +#[derive(Deserialize)] +struct CustomStatisticEntry { + id: i32, +} + +#[derive(Deserialize)] +struct StatisticData { + id: i32, + registry: String, + entries: IndexMap, +} + +pub fn build() -> TokenStream { + let stats_json = + std::fs::read_to_string("../assets/stats.json").expect("Failed to read stats.json"); + let stats_data: IndexMap = + serde_json::from_str(&stats_json).expect("Failed to parse stats.json"); + + let mut category_variants = Vec::new(); + let mut category_from_id_arms = Vec::new(); + let mut category_from_name_arms = Vec::new(); + let mut custom_stat_variants = Vec::new(); + let mut custom_stat_from_id_arms = Vec::new(); + + for (category_name, data) in &stats_data { + let category_ident = format_ident!( + "{}", + category_name.replace("minecraft:", "").to_pascal_case() + ); + let category_id = data.id; + category_variants.push(quote! { #category_ident = #category_id }); + category_from_id_arms.push(quote! { #category_id => Some(Self::#category_ident) }); + let category_name_stripped = category_name.replace("minecraft:", ""); + category_from_name_arms.push(quote! { #category_name_stripped => Some(Self::#category_ident) }); + + if category_name == "minecraft:custom" { + for (stat_name, entry) in &data.entries { + let stat_ident = format_ident!( + "{}", + stat_name.replace("minecraft:", "").to_pascal_case() + ); + let stat_id = entry.id; + custom_stat_variants.push(quote! { #stat_ident = #stat_id }); + custom_stat_from_id_arms.push(quote! { #stat_id => Some(Self::#stat_ident) }); + } + } + } + + quote! { + use serde::{Serialize, Deserialize}; + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[repr(i32)] + pub enum StatisticCategory { + #(#category_variants,)* + } + + impl StatisticCategory { + #[must_use] + pub const fn from_i32(id: i32) -> Option { + match id { + #(#category_from_id_arms,)* + _ => None, + } + } + + #[must_use] + pub fn from_registry_key(name: &str) -> Option { + let name = name.strip_prefix("minecraft:").unwrap_or(name); + match name { + #(#category_from_name_arms,)* + _ => None, + } + } + } + + impl TryFrom for StatisticCategory { + type Error = (); + + fn try_from(value: i32) -> Result { + Self::from_i32(value).ok_or(()) + } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[repr(i32)] + pub enum CustomStatistic { + #(#custom_stat_variants,)* + } + + impl CustomStatistic { + #[must_use] + pub const fn from_i32(id: i32) -> Option { + match id { + #(#custom_stat_from_id_arms,)* + _ => None, + } + } + } + + impl TryFrom for CustomStatistic { + type Error = (); + + fn try_from(value: i32) -> Result { + Self::from_i32(value).ok_or(()) + } + } + } +} diff --git a/pumpkin-data/Cargo.toml b/pumpkin-data/Cargo.toml index 70f44c324..02cd4f25d 100644 --- a/pumpkin-data/Cargo.toml +++ b/pumpkin-data/Cargo.toml @@ -14,6 +14,7 @@ default = [ "registry", "screen", "particle", + "statistic", "sound", "recipes", "data_component", @@ -57,6 +58,7 @@ translation = [] registry = [] screen = [] particle = [] +statistic = [] sound = [] recipes = [] data_component = [] diff --git a/pumpkin-data/src/generated/statistic.rs b/pumpkin-data/src/generated/statistic.rs new file mode 100644 index 000000000..42cc3d226 --- /dev/null +++ b/pumpkin-data/src/generated/statistic.rs @@ -0,0 +1,226 @@ +/* This file is generated. Do not edit manually. */ +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[repr(i32)] +pub enum StatisticCategory { + Mined = 0i32, + Crafted = 1i32, + Used = 2i32, + Broken = 3i32, + PickedUp = 4i32, + Dropped = 5i32, + Killed = 6i32, + KilledBy = 7i32, + Custom = 8i32, +} +impl StatisticCategory { + #[must_use] + pub const fn from_i32(id: i32) -> Option { + match id { + 0i32 => Some(Self::Mined), + 1i32 => Some(Self::Crafted), + 2i32 => Some(Self::Used), + 3i32 => Some(Self::Broken), + 4i32 => Some(Self::PickedUp), + 5i32 => Some(Self::Dropped), + 6i32 => Some(Self::Killed), + 7i32 => Some(Self::KilledBy), + 8i32 => Some(Self::Custom), + _ => None, + } + } + #[must_use] + pub fn from_registry_key(name: &str) -> Option { + let name = name.strip_prefix("minecraft:").unwrap_or(name); + match name { + "mined" => Some(Self::Mined), + "crafted" => Some(Self::Crafted), + "used" => Some(Self::Used), + "broken" => Some(Self::Broken), + "picked_up" => Some(Self::PickedUp), + "dropped" => Some(Self::Dropped), + "killed" => Some(Self::Killed), + "killed_by" => Some(Self::KilledBy), + "custom" => Some(Self::Custom), + _ => None, + } + } +} +impl TryFrom for StatisticCategory { + type Error = (); + fn try_from(value: i32) -> Result { + Self::from_i32(value).ok_or(()) + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[repr(i32)] +pub enum CustomStatistic { + DamageBlockedByShield = 29i32, + InteractWithBeacon = 46i32, + FallOneCm = 10i32, + OpenChest = 59i32, + PlayTime = 1i32, + WalkOneCm = 6i32, + RaidWin = 72i32, + DamageDealtResisted = 27i32, + CleanArmor = 42i32, + SwimOneCm = 20i32, + HappyGhastOneCm = 17i32, + TalkedToVillager = 37i32, + EnchantItem = 55i32, + Drop = 24i32, + PotFlower = 52i32, + OpenShulkerBox = 61i32, + WalkOnWaterOneCm = 9i32, + InteractWithCampfire = 66i32, + InteractWithFurnace = 57i32, + InteractWithLectern = 65i32, + RaidTrigger = 71i32, + StriderOneCm = 21i32, + CrouchOneCm = 7i32, + EatCakeSlice = 39i32, + PlayRecord = 56i32, + InteractWithCraftingTable = 58i32, + SneakTime = 5i32, + BoatOneCm = 15i32, + DamageResisted = 31i32, + Deaths = 32i32, + TotalWorldTime = 2i32, + PigOneCm = 16i32, + AviateOneCm = 19i32, + Jump = 23i32, + AnimalsBred = 34i32, + InspectHopper = 48i32, + InteractWithBlastFurnace = 63i32, + SprintOneCm = 8i32, + DamageTaken = 28i32, + MobKills = 33i32, + InteractWithCartographyTable = 67i32, + TimeSinceDeath = 3i32, + MinecartOneCm = 14i32, + InteractWithGrindstone = 74i32, + PlayNoteblock = 50i32, + OpenBarrel = 62i32, + NautilusOneCm = 22i32, + CleanShulkerBox = 44i32, + FlyOneCm = 12i32, + PlayerKills = 35i32, + InspectDropper = 47i32, + InteractWithLoom = 68i32, + DamageDealt = 25i32, + InspectDispenser = 49i32, + UseCauldron = 41i32, + TimeSinceRest = 4i32, + InteractWithBrewingstand = 45i32, + InteractWithAnvil = 73i32, + TuneNoteblock = 51i32, + BellRing = 70i32, + FillCauldron = 40i32, + FishCaught = 36i32, + ClimbOneCm = 11i32, + InteractWithSmithingTable = 76i32, + TriggerTrappedChest = 53i32, + InteractWithSmoker = 64i32, + OpenEnderchest = 54i32, + CleanBanner = 43i32, + LeaveGame = 0i32, + SleepInBed = 60i32, + DamageDealtAbsorbed = 26i32, + WalkUnderWaterOneCm = 13i32, + TradedWithVillager = 38i32, + InteractWithStonecutter = 69i32, + HorseOneCm = 18i32, + DamageAbsorbed = 30i32, + TargetHit = 75i32, +} +impl CustomStatistic { + #[must_use] + pub const fn from_i32(id: i32) -> Option { + match id { + 29i32 => Some(Self::DamageBlockedByShield), + 46i32 => Some(Self::InteractWithBeacon), + 10i32 => Some(Self::FallOneCm), + 59i32 => Some(Self::OpenChest), + 1i32 => Some(Self::PlayTime), + 6i32 => Some(Self::WalkOneCm), + 72i32 => Some(Self::RaidWin), + 27i32 => Some(Self::DamageDealtResisted), + 42i32 => Some(Self::CleanArmor), + 20i32 => Some(Self::SwimOneCm), + 17i32 => Some(Self::HappyGhastOneCm), + 37i32 => Some(Self::TalkedToVillager), + 55i32 => Some(Self::EnchantItem), + 24i32 => Some(Self::Drop), + 52i32 => Some(Self::PotFlower), + 61i32 => Some(Self::OpenShulkerBox), + 9i32 => Some(Self::WalkOnWaterOneCm), + 66i32 => Some(Self::InteractWithCampfire), + 57i32 => Some(Self::InteractWithFurnace), + 65i32 => Some(Self::InteractWithLectern), + 71i32 => Some(Self::RaidTrigger), + 21i32 => Some(Self::StriderOneCm), + 7i32 => Some(Self::CrouchOneCm), + 39i32 => Some(Self::EatCakeSlice), + 56i32 => Some(Self::PlayRecord), + 58i32 => Some(Self::InteractWithCraftingTable), + 5i32 => Some(Self::SneakTime), + 15i32 => Some(Self::BoatOneCm), + 31i32 => Some(Self::DamageResisted), + 32i32 => Some(Self::Deaths), + 2i32 => Some(Self::TotalWorldTime), + 16i32 => Some(Self::PigOneCm), + 19i32 => Some(Self::AviateOneCm), + 23i32 => Some(Self::Jump), + 34i32 => Some(Self::AnimalsBred), + 48i32 => Some(Self::InspectHopper), + 63i32 => Some(Self::InteractWithBlastFurnace), + 8i32 => Some(Self::SprintOneCm), + 28i32 => Some(Self::DamageTaken), + 33i32 => Some(Self::MobKills), + 67i32 => Some(Self::InteractWithCartographyTable), + 3i32 => Some(Self::TimeSinceDeath), + 14i32 => Some(Self::MinecartOneCm), + 74i32 => Some(Self::InteractWithGrindstone), + 50i32 => Some(Self::PlayNoteblock), + 62i32 => Some(Self::OpenBarrel), + 22i32 => Some(Self::NautilusOneCm), + 44i32 => Some(Self::CleanShulkerBox), + 12i32 => Some(Self::FlyOneCm), + 35i32 => Some(Self::PlayerKills), + 47i32 => Some(Self::InspectDropper), + 68i32 => Some(Self::InteractWithLoom), + 25i32 => Some(Self::DamageDealt), + 49i32 => Some(Self::InspectDispenser), + 41i32 => Some(Self::UseCauldron), + 4i32 => Some(Self::TimeSinceRest), + 45i32 => Some(Self::InteractWithBrewingstand), + 73i32 => Some(Self::InteractWithAnvil), + 51i32 => Some(Self::TuneNoteblock), + 70i32 => Some(Self::BellRing), + 40i32 => Some(Self::FillCauldron), + 36i32 => Some(Self::FishCaught), + 11i32 => Some(Self::ClimbOneCm), + 76i32 => Some(Self::InteractWithSmithingTable), + 53i32 => Some(Self::TriggerTrappedChest), + 64i32 => Some(Self::InteractWithSmoker), + 54i32 => Some(Self::OpenEnderchest), + 43i32 => Some(Self::CleanBanner), + 0i32 => Some(Self::LeaveGame), + 60i32 => Some(Self::SleepInBed), + 26i32 => Some(Self::DamageDealtAbsorbed), + 13i32 => Some(Self::WalkUnderWaterOneCm), + 38i32 => Some(Self::TradedWithVillager), + 69i32 => Some(Self::InteractWithStonecutter), + 18i32 => Some(Self::HorseOneCm), + 30i32 => Some(Self::DamageAbsorbed), + 75i32 => Some(Self::TargetHit), + _ => None, + } + } +} +impl TryFrom for CustomStatistic { + type Error = (); + fn try_from(value: i32) -> Result { + Self::from_i32(value).ok_or(()) + } +} diff --git a/pumpkin-data/src/lib.rs b/pumpkin-data/src/lib.rs index fc31c8b05..b8f3bcdb2 100644 --- a/pumpkin-data/src/lib.rs +++ b/pumpkin-data/src/lib.rs @@ -42,6 +42,11 @@ pub mod screen; #[path = "generated/particle.rs"] pub mod particle; +#[cfg(feature = "statistic")] +#[rustfmt::skip] +#[path = "generated/statistic.rs"] +pub mod statistic; + #[cfg(feature = "sound")] #[rustfmt::skip] #[path = "generated/sound_category.rs"] diff --git a/pumpkin-inventory/Cargo.toml b/pumpkin-inventory/Cargo.toml index 14068c352..93bd8277c 100644 --- a/pumpkin-inventory/Cargo.toml +++ b/pumpkin-inventory/Cargo.toml @@ -16,6 +16,7 @@ pumpkin-data = { workspace = true, features = [ "fuels", "enchantment", "potion", + "statistic", ] } pumpkin-world.workspace = true diff --git a/pumpkin-inventory/src/crafting/crafting_screen_handler.rs b/pumpkin-inventory/src/crafting/crafting_screen_handler.rs index 142906186..4fd2540f3 100644 --- a/pumpkin-inventory/src/crafting/crafting_screen_handler.rs +++ b/pumpkin-inventory/src/crafting/crafting_screen_handler.rs @@ -32,6 +32,7 @@ use crate::slot::{BoxFuture, NormalSlot, Slot}; use pumpkin_data::item_stack::ItemStack; use pumpkin_data::recipes::{CraftingRecipeTypes, RECIPES_CRAFTING}; use pumpkin_data::screen::WindowType; +use pumpkin_data::statistic::StatisticCategory; use pumpkin_data::tag; use pumpkin_data::tag::Taggable; use pumpkin_protocol::codec::recipe::{DynamicRecipe, OwnedCraftingRecipe}; @@ -437,10 +438,17 @@ impl Slot for ResultSlot { } fn on_take_item<'a>( &'a self, - _player: &'a dyn InventoryPlayer, - _stack: &'a ItemStack, + player: &'a dyn InventoryPlayer, + stack: &'a ItemStack, ) -> BoxFuture<'a, ()> { Box::pin(async move { + player + .increment_stat( + StatisticCategory::Crafted, + stack.item.id as i32, + stack.item_count as i32, + ) + .await; for i in 0..self.inventory.size() { let slot = self.inventory.get_stack(i).await; let mut stack = slot.lock().await; @@ -640,7 +648,11 @@ impl ScreenHandler for CraftingTableScreenHandler { if stack.item_count == stack_prev.item_count { return ItemStack::EMPTY.clone(); } - slot.on_take_item(player, &stack).await; + + let mut taken_stack = stack_prev.clone(); + taken_stack.set_count(stack_prev.item_count - stack.item_count); + slot.on_take_item(player, &taken_stack).await; + if slot_index == 0 { slot.on_quick_move_crafted(stack.clone(), stack_prev.clone()) .await; diff --git a/pumpkin-inventory/src/enchanting/enchanting_screen_handler.rs b/pumpkin-inventory/src/enchanting/enchanting_screen_handler.rs index 0a0dea887..718ddff34 100644 --- a/pumpkin-inventory/src/enchanting/enchanting_screen_handler.rs +++ b/pumpkin-inventory/src/enchanting/enchanting_screen_handler.rs @@ -6,6 +6,7 @@ use pumpkin_data::data_component_impl::EnchantableImpl; use pumpkin_data::item::Item; use pumpkin_data::item_stack::ItemStack; use pumpkin_data::screen::WindowType; +use pumpkin_data::statistic::{CustomStatistic, StatisticCategory}; use pumpkin_data::tag::{Enchantment as EnchantmentTag, Taggable}; use pumpkin_util::random::RandomImpl; use pumpkin_world::inventory::Inventory; @@ -357,6 +358,14 @@ impl ScreenHandler for EnchantingTableScreenHandler { self.update_enchantments(player).await; self.send_content_updates().await; + player + .increment_stat( + StatisticCategory::Custom, + CustomStatistic::EnchantItem as i32, + 1, + ) + .await; + true }) } diff --git a/pumpkin-inventory/src/furnace_like/furnace_like_slot.rs b/pumpkin-inventory/src/furnace_like/furnace_like_slot.rs index ea25feb20..f66ba0846 100644 --- a/pumpkin-inventory/src/furnace_like/furnace_like_slot.rs +++ b/pumpkin-inventory/src/furnace_like/furnace_like_slot.rs @@ -8,7 +8,7 @@ use std::sync::{Arc, atomic::AtomicU8}; -use pumpkin_data::{fuels::is_fuel, item::Item}; +use pumpkin_data::{fuels::is_fuel, item::Item, statistic::StatisticCategory}; use pumpkin_world::{block::entities::ExperienceContainer, inventory::Inventory}; use tracing::debug; @@ -140,10 +140,17 @@ impl Slot for FurnaceOutputSlot { fn on_take_item<'a>( &'a self, player: &'a dyn InventoryPlayer, - _stack: &'a pumpkin_data::item_stack::ItemStack, + stack: &'a pumpkin_data::item_stack::ItemStack, ) -> BoxFuture<'a, ()> { Box::pin(async move { debug!("FurnaceOutputSlot: on_take_item called"); + player + .increment_stat( + StatisticCategory::Crafted, + stack.item.id as i32, + stack.item_count as i32, + ) + .await; // Extract accumulated experience and award to player let experience = self.experience_container.extract_experience(); debug!("FurnaceOutputSlot: extracted experience = {experience}"); diff --git a/pumpkin-inventory/src/player/player_screen_handler.rs b/pumpkin-inventory/src/player/player_screen_handler.rs index a0bcb2963..8e8cab953 100644 --- a/pumpkin-inventory/src/player/player_screen_handler.rs +++ b/pumpkin-inventory/src/player/player_screen_handler.rs @@ -245,7 +245,9 @@ impl ScreenHandler for PlayerScreenHandler { return ItemStack::EMPTY.clone(); } - slot.on_take_item(player, &stack).await; + let mut taken_stack = stack_prev.clone(); + taken_stack.set_count(stack_prev.item_count - stack.item_count); + slot.on_take_item(player, &taken_stack).await; if slot_index == 0 { // From crafting result slot (0) diff --git a/pumpkin-inventory/src/screen_handler.rs b/pumpkin-inventory/src/screen_handler.rs index 165e84a45..896516816 100644 --- a/pumpkin-inventory/src/screen_handler.rs +++ b/pumpkin-inventory/src/screen_handler.rs @@ -35,6 +35,7 @@ use pumpkin_data::item_stack::ItemStack; use pumpkin_data::{ data_component_impl::{EquipmentSlot, EquipmentType, EquippableImpl}, screen::WindowType, + statistic::StatisticCategory, }; use pumpkin_protocol::{ codec::item_stack_seralizer::OptionalItemStackHash, @@ -187,6 +188,14 @@ pub trait InventoryPlayer: Send + Sync { /// Awards experience points to the player (used for furnace smelting, etc.) fn award_experience(&self, amount: i32) -> PlayerFuture<'_, ()>; + + /// Increments a statistic for the player. + fn increment_stat( + &self, + category: StatisticCategory, + stat_id: i32, + amount: i32, + ) -> PlayerFuture<'_, ()>; } /// Gives a stack to the player or drops it if inventory is full. diff --git a/pumpkin-inventory/src/stonecutter_screen_handler.rs b/pumpkin-inventory/src/stonecutter_screen_handler.rs index 267297a94..3205313bc 100644 --- a/pumpkin-inventory/src/stonecutter_screen_handler.rs +++ b/pumpkin-inventory/src/stonecutter_screen_handler.rs @@ -13,6 +13,7 @@ use pumpkin_data::item::Item; use pumpkin_data::item_stack::ItemStack; use pumpkin_data::recipes::{RECIPES_STONECUTTING, StonecutterRecipe}; use pumpkin_data::screen::WindowType; +use pumpkin_data::statistic::StatisticCategory; use pumpkin_protocol::java::server::play::SlotActionType; use pumpkin_world::inventory::Inventory; use pumpkin_world::inventory::SimpleInventory; @@ -201,10 +202,17 @@ impl Slot for StonecutterOutputSlot { fn on_take_item<'a>( &'a self, - _player: &'a dyn InventoryPlayer, - _stack: &'a ItemStack, + player: &'a dyn InventoryPlayer, + stack: &'a ItemStack, ) -> BoxFuture<'a, ()> { Box::pin(async move { + player + .increment_stat( + StatisticCategory::Crafted, + stack.item.id as i32, + stack.item_count as i32, + ) + .await; let input_stack = self.input_inventory.get_stack(0).await; let mut input_lock = input_stack.lock().await; if !input_lock.is_empty() { diff --git a/pumpkin-protocol/src/java/client/play/award_stats.rs b/pumpkin-protocol/src/java/client/play/award_stats.rs new file mode 100644 index 000000000..739f178a2 --- /dev/null +++ b/pumpkin-protocol/src/java/client/play/award_stats.rs @@ -0,0 +1,17 @@ +use pumpkin_data::packet::clientbound::PLAY_AWARD_STATS; +use pumpkin_macros::java_packet; + +use crate::codec::var_int::VarInt; + +#[derive(serde::Serialize)] +#[java_packet(PLAY_AWARD_STATS)] +pub struct CAwardStats<'a> { + pub stats: &'a [Statistic], +} + +#[derive(serde::Serialize)] +pub struct Statistic { + pub category_id: VarInt, + pub statistic_id: VarInt, + pub value: VarInt, +} diff --git a/pumpkin-protocol/src/java/client/play/mod.rs b/pumpkin-protocol/src/java/client/play/mod.rs index 67068a1da..e005b096d 100644 --- a/pumpkin-protocol/src/java/client/play/mod.rs +++ b/pumpkin-protocol/src/java/client/play/mod.rs @@ -1,3 +1,5 @@ +mod award_stats; +pub use award_stats::*; mod acknowledge_block; mod actionbar; mod block_destroy_stage; diff --git a/pumpkin-world/src/generation/noise/aquifer_sampler.rs b/pumpkin-world/src/generation/noise/aquifer_sampler.rs index feff6ca62..e65d9a0f4 100644 --- a/pumpkin-world/src/generation/noise/aquifer_sampler.rs +++ b/pumpkin-world/src/generation/noise/aquifer_sampler.rs @@ -297,7 +297,6 @@ impl WorldAquiferSampler { let z = block_z + section_coords::section_to_block(offset_z as i32); let n = height_estimator.estimate_height(x, z); - println!("estimate_height({}, {}) = {}", x, z, n); let o = n + 8; let bl2 = offset_x == 0 && offset_z == 0; diff --git a/pumpkin/src/block/blocks/anvil.rs b/pumpkin/src/block/blocks/anvil.rs index 7663fe4e3..a6bb49b6e 100644 --- a/pumpkin/src/block/blocks/anvil.rs +++ b/pumpkin/src/block/blocks/anvil.rs @@ -25,6 +25,13 @@ pub struct AnvilBlock; impl BlockBehaviour for AnvilBlock { fn normal_use<'a>(&'a self, args: NormalUseArgs<'a>) -> BlockFuture<'a, BlockActionResult> { Box::pin(async move { + args.player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::InteractWithAnvil as i32, + 1, + ) + .await; args.player .open_handled_screen(&AnvilScreenFactory, Some(*args.position)) .await; diff --git a/pumpkin/src/block/blocks/barrel.rs b/pumpkin/src/block/blocks/barrel.rs index ac9060036..7e9ed1e1f 100644 --- a/pumpkin/src/block/blocks/barrel.rs +++ b/pumpkin/src/block/blocks/barrel.rs @@ -64,6 +64,13 @@ impl BlockBehaviour for BarrelBlock { if let Some(block_entity) = args.world.get_block_entity(args.position) && let Some(inventory) = block_entity.get_inventory() { + args.player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::OpenBarrel as i32, + 1, + ) + .await; args.player .open_handled_screen(&BarrelScreenFactory(inventory), Some(*args.position)) .await; diff --git a/pumpkin/src/block/blocks/beacon.rs b/pumpkin/src/block/blocks/beacon.rs index 174200f89..452b745f8 100644 --- a/pumpkin/src/block/blocks/beacon.rs +++ b/pumpkin/src/block/blocks/beacon.rs @@ -58,6 +58,14 @@ impl BlockBehaviour for BeaconBlock { return BlockActionResult::Fail; }; + args.player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::InteractWithBeacon as i32, + 1, + ) + .await; + // Open the screen using the factory args.player .open_handled_screen(&BeaconScreenFactory(inventory), Some(*args.position)) diff --git a/pumpkin/src/block/blocks/bed.rs b/pumpkin/src/block/blocks/bed.rs index db123d6cd..bb2b1c26a 100644 --- a/pumpkin/src/block/blocks/bed.rs +++ b/pumpkin/src/block/blocks/bed.rs @@ -359,6 +359,13 @@ impl BlockBehaviour for BedBlock { } args.player.sleep(bed_head_pos); + args.player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::SleepInBed as i32, + 1, + ) + .await; Self::set_occupied(true, args.world, args.block, args.position, state_id).await; BlockActionResult::SuccessServer diff --git a/pumpkin/src/block/blocks/blast_furnace.rs b/pumpkin/src/block/blocks/blast_furnace.rs index 715838b35..f8c5b7e5c 100644 --- a/pumpkin/src/block/blocks/blast_furnace.rs +++ b/pumpkin/src/block/blocks/blast_furnace.rs @@ -91,6 +91,13 @@ impl BlockBehaviour for BlastFurnaceBlock { && let Some(property_delegate) = block_entity.clone().to_property_delegate() && let Some(experience_container) = block_entity.to_experience_container() { + args.player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::InteractWithBlastFurnace as i32, + 1, + ) + .await; let blasting_furnace_screen_factory = BlastingFurnaceScreenFactory::new( inventory, property_delegate, diff --git a/pumpkin/src/block/blocks/brewing_stand.rs b/pumpkin/src/block/blocks/brewing_stand.rs index cea8f29b0..e77e6a978 100644 --- a/pumpkin/src/block/blocks/brewing_stand.rs +++ b/pumpkin/src/block/blocks/brewing_stand.rs @@ -56,6 +56,13 @@ impl BlockBehaviour for BrewingStandBlock { && let Some(inventory) = block_entity.clone().get_inventory() && let Some(pd) = block_entity.clone().to_property_delegate() { + args.player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::InteractWithBrewingstand as i32, + 1, + ) + .await; args.player .open_handled_screen(&BrewingScreenFactory(inventory, pd), Some(*args.position)) .await; diff --git a/pumpkin/src/block/blocks/cake.rs b/pumpkin/src/block/blocks/cake.rs index 32f09dd83..3b01a0ff2 100644 --- a/pumpkin/src/block/blocks/cake.rs +++ b/pumpkin/src/block/blocks/cake.rs @@ -54,6 +54,13 @@ impl CakeBlock { let mut properties = CakeLikeProperties::from_state_id(state_id, block); match properties.bites { 0..=5 => { + player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::EatCakeSlice as i32, + 1, + ) + .await; properties.bites += 1; world .set_block_state( @@ -65,6 +72,13 @@ impl CakeBlock { BlockActionResult::Consume } 6 => { + player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::EatCakeSlice as i32, + 1, + ) + .await; world .set_block_state( location, diff --git a/pumpkin/src/block/blocks/chests.rs b/pumpkin/src/block/blocks/chests.rs index cd4361eb9..88b03d927 100644 --- a/pumpkin/src/block/blocks/chests.rs +++ b/pumpkin/src/block/blocks/chests.rs @@ -129,6 +129,13 @@ async fn placed_chest_impl( } async fn normal_use_chest_impl(args: NormalUseArgs<'_>) -> BlockActionResult { + args.player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::OpenChest as i32, + 1, + ) + .await; let state = args.world.get_block_state_id(args.position); let first_chest = args.world.get_block_entity(args.position); diff --git a/pumpkin/src/block/blocks/crafting_table.rs b/pumpkin/src/block/blocks/crafting_table.rs index f585053a6..1114a43cb 100644 --- a/pumpkin/src/block/blocks/crafting_table.rs +++ b/pumpkin/src/block/blocks/crafting_table.rs @@ -18,6 +18,13 @@ pub struct CraftingTableBlock; impl BlockBehaviour for CraftingTableBlock { fn normal_use<'a>(&'a self, args: NormalUseArgs<'a>) -> BlockFuture<'a, BlockActionResult> { Box::pin(async move { + args.player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::InteractWithCraftingTable as i32, + 1, + ) + .await; args.player .open_handled_screen( &CraftingTableScreenFactory(args.server.recipe_manager.clone()), diff --git a/pumpkin/src/block/blocks/ender_chest.rs b/pumpkin/src/block/blocks/ender_chest.rs index 75260fd02..5ef64fda6 100644 --- a/pumpkin/src/block/blocks/ender_chest.rs +++ b/pumpkin/src/block/blocks/ender_chest.rs @@ -85,14 +85,19 @@ impl BlockBehaviour for EnderChestBlock { { let inventory = args.player.ender_chest_inventory(); inventory.set_tracker(block_entity.get_tracker()).await; + args.player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::OpenEnderchest as i32, + 1, + ) + .await; args.player .open_handled_screen( &EnderChestScreenFactory(inventory.clone()), Some(*args.position), ) .await; - - // TODO: player.incrementStat(Stats.OPEN_ENDERCHEST); // TODO: PiglinBrain.onGuardedBlockInteracted(serverWorld, player, true); } diff --git a/pumpkin/src/block/blocks/furnace.rs b/pumpkin/src/block/blocks/furnace.rs index 29604808d..f389f0d5a 100644 --- a/pumpkin/src/block/blocks/furnace.rs +++ b/pumpkin/src/block/blocks/furnace.rs @@ -90,6 +90,13 @@ impl BlockBehaviour for FurnaceBlock { && let Some(property_delegate) = block_entity.clone().to_property_delegate() && let Some(experience_container) = block_entity.to_experience_container() { + args.player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::InteractWithFurnace as i32, + 1, + ) + .await; let furnace_screen_factory = FurnaceScreenFactory::new(inventory, property_delegate, experience_container); args.player diff --git a/pumpkin/src/block/blocks/hopper.rs b/pumpkin/src/block/blocks/hopper.rs index 31f72f4bd..5c497a469 100644 --- a/pumpkin/src/block/blocks/hopper.rs +++ b/pumpkin/src/block/blocks/hopper.rs @@ -62,6 +62,13 @@ impl BlockBehaviour for HopperBlock { if let Some(block_entity) = args.world.get_block_entity(args.position) && let Some(inventory) = block_entity.get_inventory() { + args.player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::InspectHopper as i32, + 1, + ) + .await; args.player .open_handled_screen(&HopperBlockScreenFactory(inventory), Some(*args.position)) .await; diff --git a/pumpkin/src/block/blocks/jukebox.rs b/pumpkin/src/block/blocks/jukebox.rs index c1ccb6622..2fc421d32 100644 --- a/pumpkin/src/block/blocks/jukebox.rs +++ b/pumpkin/src/block/blocks/jukebox.rs @@ -168,8 +168,15 @@ impl BlockBehaviour for JukeboxBlock { // Start playing the music (client-side audio) Self::start_playing(args.position, world, jukebox_song.get_id()); + args.player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::PlayRecord as i32, + 1, + ) + .await; + // TODO: world.emitGameEvent(GameEvent.BLOCK_CHANGE, pos, ...) - // TODO: player.incrementStat(Stats.PLAY_RECORD) BlockActionResult::Success }) diff --git a/pumpkin/src/block/blocks/lectern.rs b/pumpkin/src/block/blocks/lectern.rs index 8e5ec4980..7ee5f9537 100644 --- a/pumpkin/src/block/blocks/lectern.rs +++ b/pumpkin/src/block/blocks/lectern.rs @@ -60,6 +60,13 @@ impl BlockBehaviour for LecternBlock { fn normal_use<'a>(&'a self, args: NormalUseArgs<'a>) -> BlockFuture<'a, BlockActionResult> { Box::pin(async move { + args.player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::InteractWithLectern as i32, + 1, + ) + .await; if let Some(block_entity) = args.world.get_block_entity(args.position) && let Some(lectern_entity) = block_entity.as_any().downcast_ref::() diff --git a/pumpkin/src/block/blocks/note.rs b/pumpkin/src/block/blocks/note.rs index 1309b6ca7..a33a90492 100644 --- a/pumpkin/src/block/blocks/note.rs +++ b/pumpkin/src/block/blocks/note.rs @@ -95,6 +95,14 @@ impl BlockBehaviour for NoteBlock { .await; Self::play_note(¬e_props, args.world, args.position).await; + args.player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::TuneNoteblock as i32, + 1, + ) + .await; + BlockActionResult::Success }) } diff --git a/pumpkin/src/block/blocks/redstone/bell.rs b/pumpkin/src/block/blocks/redstone/bell.rs index 1d3b9e80c..af0d97588 100644 --- a/pumpkin/src/block/blocks/redstone/bell.rs +++ b/pumpkin/src/block/blocks/redstone/bell.rs @@ -139,6 +139,14 @@ impl BlockBehaviour for BellBlock { args.hit.face.to_horizontal_facing(), ); + args.player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::BellRing as i32, + 1, + ) + .await; + BlockActionResult::Success }) } diff --git a/pumpkin/src/block/blocks/shulker_box.rs b/pumpkin/src/block/blocks/shulker_box.rs index d3bbf6d04..1d89c706f 100644 --- a/pumpkin/src/block/blocks/shulker_box.rs +++ b/pumpkin/src/block/blocks/shulker_box.rs @@ -88,6 +88,13 @@ impl BlockBehaviour for ShulkerBoxBlock { if let Some(block_entity) = args.world.get_block_entity(args.position) && let Some(inventory) = block_entity.get_inventory() { + args.player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::OpenShulkerBox as i32, + 1, + ) + .await; args.player .open_handled_screen(&ShulkerBoxScreenFactory(inventory), Some(*args.position)) .await; diff --git a/pumpkin/src/block/blocks/smoker.rs b/pumpkin/src/block/blocks/smoker.rs index f3c1a845a..79076571f 100644 --- a/pumpkin/src/block/blocks/smoker.rs +++ b/pumpkin/src/block/blocks/smoker.rs @@ -89,6 +89,13 @@ impl BlockBehaviour for SmokerBlock { && let Some(property_delegate) = block_entity.clone().to_property_delegate() && let Some(experience_container) = block_entity.to_experience_container() { + args.player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::InteractWithSmoker as i32, + 1, + ) + .await; let smoker_screen_factory = SmokerScreenFactory::new(inventory, property_delegate, experience_container); args.player diff --git a/pumpkin/src/block/blocks/stonecutter.rs b/pumpkin/src/block/blocks/stonecutter.rs index ed943c92f..4276aa272 100644 --- a/pumpkin/src/block/blocks/stonecutter.rs +++ b/pumpkin/src/block/blocks/stonecutter.rs @@ -19,6 +19,13 @@ pub struct StonecutterBlock; impl BlockBehaviour for StonecutterBlock { fn normal_use<'a>(&'a self, args: NormalUseArgs<'a>) -> BlockFuture<'a, BlockActionResult> { Box::pin(async move { + args.player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::InteractWithStonecutter as i32, + 1, + ) + .await; args.player .open_handled_screen(&StonecutterScreenFactory, Some(*args.position)) .await; diff --git a/pumpkin/src/entity/ai/goal/breed.rs b/pumpkin/src/entity/ai/goal/breed.rs index 8d5a93b7f..2b1e2d40e 100644 --- a/pumpkin/src/entity/ai/goal/breed.rs +++ b/pumpkin/src/entity/ai/goal/breed.rs @@ -65,6 +65,20 @@ impl BreedGoal { let entity = mob.get_entity(); let world = entity.world.load(); + if let Some(player) = mob_entity + .breeder + .load() + .and_then(|uuid| world.get_player_by_uuid(uuid)) + { + player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::AnimalsBred as i32, + 1, + ) + .await; + } + mob_entity.reset_love_ticks(); mob_entity .breeding_cooldown diff --git a/pumpkin/src/entity/item.rs b/pumpkin/src/entity/item.rs index e6343a7b2..9752ee8fa 100644 --- a/pumpkin/src/entity/item.rs +++ b/pumpkin/src/entity/item.rs @@ -1,3 +1,4 @@ +use crate::entity::player::statistics::StatisticCategory; use crate::{entity::EntityBaseFuture, server::Server}; use core::f32; use pumpkin_data::data_component_impl::DamageResistantImpl; @@ -453,17 +454,41 @@ impl EntityBase for ItemEntity { return; } + let (item_id, count_before) = { + let stack = self.item_stack.lock().await; + (stack.item.id, stack.item_count) + }; + let inserted = { let mut stack = self.item_stack.lock().await; player.inventory.insert_stack_anywhere(&mut stack).await }; if inserted || player.is_creative() { - let (item_count, is_empty) = { + let (count_after, is_empty) = { let stack = self.item_stack.lock().await; (stack.item_count, stack.is_empty()) }; - player.living_entity.pickup(&self.entity, item_count.into()); + + let amount_picked_up = if player.is_creative() { + count_before + } else { + count_before - count_after + }; + + if amount_picked_up > 0 { + player + .increment_stat( + StatisticCategory::PickedUp, + item_id as i32, + amount_picked_up as i32, + ) + .await; + } + + player + .living_entity + .pickup(&self.entity, amount_picked_up.into()); player .current_screen_handler diff --git a/pumpkin/src/entity/living.rs b/pumpkin/src/entity/living.rs index c7dd04a62..ff603492c 100644 --- a/pumpkin/src/entity/living.rs +++ b/pumpkin/src/entity/living.rs @@ -28,6 +28,7 @@ use crate::entity::attributes::AttributeInstance; use crate::entity::attributes::Modifier; use crate::entity::attributes::ModifierOperation; use crate::entity::mob::slime::SlimeEntity; +use crate::entity::player::statistics::{CustomStatistic, StatisticCategory}; use crate::entity::{EntityBaseFuture, NbtFuture}; use crate::server::Server; use crate::world::loot::{LootContextParameters, LootTableExt}; @@ -1309,6 +1310,9 @@ impl LivingEntity { self.movement_input.store(Vector3::default()); self.jumping.store(false, Relaxed); + // Statistics updates + self.update_death_stats(&*dyn_self, cause).await; + // Plays the death sound world.send_entity_status(&self.entity, EntityStatus::Death); let params = LootContextParameters { @@ -1336,41 +1340,108 @@ impl LivingEntity { } self.entity.pose.store(EntityPose::Dying); - let block_pos = self.entity.block_pos.load(); - - let armor_slots: Vec>> = { - let equipment_lock = self.entity_equipment.lock().await; - self.equipment_slots - .values() - .map(|slot| equipment_lock.get(slot)) - .collect() - }; - - for equipment in armor_slots { - let item = { - let mut item_lock = equipment.lock().await; - mem::replace(&mut *item_lock, ItemStack::EMPTY.clone()) - }; - world.drop_stack(&block_pos, item).await; - } + self.drop_equipment().await; // Broadcast death message if it's a player and the gamerule is enabled - let show_death_messages = { world.level_info.load().game_rules.show_death_messages }; - if self.entity.entity_type == &EntityType::PLAYER && show_death_messages { - //TODO: KillCredit - let death_message = - Self::get_death_message(&*dyn_self, damage_type, source, cause).await; - if let Some(server) = world.server.upgrade() { - for player in server.get_all_players() { - player.send_system_message(&death_message).await; - } - } - } + self.broadcast_death_message(&*dyn_self, damage_type, source, cause) + .await; self.reset_effects_and_attributes().await; } } + async fn drop_equipment(&self) { + let world = self.entity.world.load(); + let block_pos = self.entity.block_pos.load(); + + let armor_slots: Vec>> = { + let equipment_lock = self.entity_equipment.lock().await; + self.equipment_slots + .values() + .map(|slot| equipment_lock.get(slot)) + .collect() + }; + + for equipment in armor_slots { + let item = { + let mut item_lock = equipment.lock().await; + mem::replace(&mut *item_lock, ItemStack::EMPTY.clone()) + }; + world.drop_stack(&block_pos, item).await; + } + } + + async fn broadcast_death_message( + &self, + dyn_self: &dyn EntityBase, + damage_type: DamageType, + source: Option<&dyn EntityBase>, + cause: Option<&dyn EntityBase>, + ) { + let world = self.entity.world.load(); + let show_death_messages = { world.level_info.load().game_rules.show_death_messages }; + if self.entity.entity_type == &EntityType::PLAYER && show_death_messages { + //TODO: KillCredit + let death_message = Self::get_death_message(dyn_self, damage_type, source, cause).await; + if let Some(server) = world.server.upgrade() { + for player in server.get_all_players() { + player.send_system_message(&death_message).await; + } + } + } + } + + async fn update_death_stats(&self, dyn_self: &dyn EntityBase, cause: Option<&dyn EntityBase>) { + if let Some(victim_player) = dyn_self.get_player() { + victim_player + .increment_stat(StatisticCategory::Custom, CustomStatistic::Deaths as i32, 1) + .await; + victim_player + .set_stat( + StatisticCategory::Custom, + CustomStatistic::TimeSinceDeath as i32, + 0, + ) + .await; + if let Some(killer_entity) = cause.map(EntityBase::get_entity) { + victim_player + .increment_stat( + StatisticCategory::KilledBy, + killer_entity.entity_type.id as i32, + 1, + ) + .await; + } + } + + if let Some(killer_player) = cause.and_then(|c| c.get_player()) { + if dyn_self.get_player().is_some() { + killer_player + .increment_stat( + StatisticCategory::Custom, + CustomStatistic::PlayerKills as i32, + 1, + ) + .await; + } else { + killer_player + .increment_stat( + StatisticCategory::Custom, + CustomStatistic::MobKills as i32, + 1, + ) + .await; + } + killer_player + .increment_stat( + StatisticCategory::Killed, + self.entity.entity_type.id as i32, + 1, + ) + .await; + } + } + async fn drop_loot(&self, params: LootContextParameters) { if let Some(loot_table) = &self.get_entity().entity_type.loot_table { let pos = self.entity.block_pos.load(); @@ -1938,6 +2009,28 @@ impl EntityBase for LivingEntity { // Total damage after reductions let effective_amount = amount * (1.0 - resistance_reduction); + if resistance_reduction > 0.0 { + let resisted = amount * resistance_reduction; + if let Some(player) = caller.get_player() { + player + .increment_stat( + StatisticCategory::Custom, + CustomStatistic::DamageResisted as i32, + (resisted * 10.0) as i32, + ) + .await; + } + if let Some(attacker_player) = cause.and_then(|c| c.get_player()) { + attacker_player + .increment_stat( + StatisticCategory::Custom, + CustomStatistic::DamageDealtResisted as i32, + (resisted * 10.0) as i32, + ) + .await; + } + } + // Check for shield blocking if self.is_blocking().await && !damage_type.has_tag(&tag::DamageType::MINECRAFT_BYPASSES_SHIELD) @@ -1951,6 +2044,16 @@ impl EntityBase for LivingEntity { if source_to_player.dot(&look_vec) < 0.0 { world.play_sound(Sound::ItemShieldBlock, SoundCategory::Players, &player_pos); + if let Some(player) = caller.get_player() { + player + .increment_stat( + StatisticCategory::Custom, + CustomStatistic::DamageBlockedByShield as i32, + (effective_amount * 10.0) as i32, + ) + .await; + } + if let Some(attacker_player) = cause.and_then(|c| c.get_player()) { let held_item = attacker_player.inventory().held_item(); let is_axe = held_item.lock().await.is_axe(); @@ -1995,6 +2098,15 @@ impl EntityBase for LivingEntity { let durability_damage = (amount / 1.0).floor().max(1.0) as i32; if stack.damage_item(durability_damage) == DamageResult::Broken { + if let Some(player) = caller.get_player() { + player + .increment_stat( + StatisticCategory::Broken, + stack.item.id as i32, + 1, + ) + .await; + } world.send_entity_status( &self.entity, crate::entity::equipment_break_status(&slot), @@ -2084,6 +2196,27 @@ impl EntityBase for LivingEntity { let mut remaining = damage_amount; let current_abs = self.absorption.load(); if current_abs > 0.0 { + let absorbed = current_abs.min(remaining); + if let Some(player) = caller.get_player() { + player + .increment_stat( + StatisticCategory::Custom, + CustomStatistic::DamageAbsorbed as i32, + (absorbed * 10.0) as i32, + ) + .await; + } + + if let Some(attacker_player) = cause.and_then(|c| c.get_player()) { + attacker_player + .increment_stat( + StatisticCategory::Custom, + CustomStatistic::DamageDealtAbsorbed as i32, + (absorbed * 10.0) as i32, + ) + .await; + } + if current_abs >= remaining { let new_abs = current_abs - remaining; self.set_absorption(new_abs).await; @@ -2109,6 +2242,27 @@ impl EntityBase for LivingEntity { if remaining > 0.0 { self.set_health(clamped_health); + // Statistics updates + if let Some(player) = caller.get_player() { + player + .increment_stat( + StatisticCategory::Custom, + CustomStatistic::DamageTaken as i32, + (remaining * 10.0) as i32, + ) + .await; + } + + if let Some(attacker_player) = cause.and_then(|c| c.get_player()) { + attacker_player + .increment_stat( + StatisticCategory::Custom, + CustomStatistic::DamageDealt as i32, + (remaining * 10.0) as i32, + ) + .await; + } + // Track attacker for RevengeGoal (only after confirming damage) if let Some(attacker) = cause.or(source) { self.last_attacker_id diff --git a/pumpkin/src/entity/mob/mod.rs b/pumpkin/src/entity/mob/mod.rs index 1573fd506..1c9e12d5c 100644 --- a/pumpkin/src/entity/mob/mod.rs +++ b/pumpkin/src/entity/mob/mod.rs @@ -72,6 +72,7 @@ pub struct MobEntity { pub position_target_range: AtomicI32, pub love_ticks: AtomicI32, pub breeding_cooldown: AtomicI32, + pub breeder: AtomicCell>, mob_flags: AtomicU8, last_sent_yaw: AtomicU8, last_sent_pitch: AtomicU8, @@ -108,6 +109,7 @@ impl MobEntity { position_target_range: AtomicI32::new(-1), love_ticks: AtomicI32::new(0), breeding_cooldown: AtomicI32::new(0), + breeder: AtomicCell::new(None), mob_flags: AtomicU8::new(0), last_sent_yaw: AtomicU8::new(0), last_sent_pitch: AtomicU8::new(0), @@ -153,8 +155,9 @@ impl MobEntity { self.love_ticks.load(Relaxed) > 0 } - pub fn set_love_ticks(&self, ticks: i32) { + pub fn set_love_ticks(&self, ticks: i32, breeder: Option) { self.love_ticks.store(ticks, Relaxed); + self.breeder.store(breeder); } pub fn reset_love_ticks(&self) { diff --git a/pumpkin/src/entity/passive/chicken.rs b/pumpkin/src/entity/passive/chicken.rs index 78e5f337f..05597b292 100644 --- a/pumpkin/src/entity/passive/chicken.rs +++ b/pumpkin/src/entity/passive/chicken.rs @@ -121,7 +121,8 @@ impl Mob for ChickenEntity { if is_food && self.is_breeding_ready() && !self.is_in_love() { item_stack.decrement_unless_creative(player.gamemode.load(), 1); - self.mob_entity.set_love_ticks(600); + self.mob_entity + .set_love_ticks(600, Some(player.gameprofile.id)); let entity = &self.mob_entity.living_entity.entity; let world = entity.world.load(); let pos = entity.pos.load(); diff --git a/pumpkin/src/entity/passive/cow.rs b/pumpkin/src/entity/passive/cow.rs index e43585697..dd537a641 100644 --- a/pumpkin/src/entity/passive/cow.rs +++ b/pumpkin/src/entity/passive/cow.rs @@ -82,7 +82,8 @@ impl Mob for CowEntity { if is_food && self.is_breeding_ready() && !self.is_in_love() { item_stack.decrement_unless_creative(player.gamemode.load(), 1); - self.mob_entity.set_love_ticks(600); + self.mob_entity + .set_love_ticks(600, Some(player.gameprofile.id)); let entity = &self.mob_entity.living_entity.entity; let world = entity.world.load(); let pos = entity.pos.load(); diff --git a/pumpkin/src/entity/passive/pig.rs b/pumpkin/src/entity/passive/pig.rs index eeddee5ba..4fbaf337c 100644 --- a/pumpkin/src/entity/passive/pig.rs +++ b/pumpkin/src/entity/passive/pig.rs @@ -87,7 +87,8 @@ impl Mob for PigEntity { if is_food && self.is_breeding_ready() && !self.is_in_love() { item_stack.decrement_unless_creative(player.gamemode.load(), 1); - self.mob_entity.set_love_ticks(600); + self.mob_entity + .set_love_ticks(600, Some(player.gameprofile.id)); let entity = &self.mob_entity.living_entity.entity; let world = entity.world.load(); let pos = entity.pos.load(); diff --git a/pumpkin/src/entity/passive/sheep.rs b/pumpkin/src/entity/passive/sheep.rs index a90ef4352..ef21d3a2a 100644 --- a/pumpkin/src/entity/passive/sheep.rs +++ b/pumpkin/src/entity/passive/sheep.rs @@ -150,7 +150,8 @@ impl Mob for SheepEntity { if is_food && self.is_breeding_ready() && !self.is_in_love() { item_stack.decrement_unless_creative(player.gamemode.load(), 1); - self.mob_entity.set_love_ticks(600); + self.mob_entity + .set_love_ticks(600, Some(player.gameprofile.id)); let entity = &self.mob_entity.living_entity.entity; let world = entity.world.load(); let pos = entity.pos.load(); diff --git a/pumpkin/src/entity/passive/villager/mod.rs b/pumpkin/src/entity/passive/villager/mod.rs index 08395f27e..dd8e55711 100644 --- a/pumpkin/src/entity/passive/villager/mod.rs +++ b/pumpkin/src/entity/passive/villager/mod.rs @@ -466,6 +466,14 @@ impl Mob for VillagerEntity { } drop(offers); + player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::TalkedToVillager as i32, + 1, + ) + .await; + self.open_trading_screen(&player); true diff --git a/pumpkin/src/entity/player.rs b/pumpkin/src/entity/player.rs index 4bab463ad..b66c4066e 100644 --- a/pumpkin/src/entity/player.rs +++ b/pumpkin/src/entity/player.rs @@ -1,4 +1,5 @@ pub mod advancement; +pub mod statistics; use core::f32; use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque}; @@ -42,6 +43,7 @@ use pumpkin_data::entity::{EntityPose, EntityStatus, EntityType}; use pumpkin_data::item_stack::ItemStack; use pumpkin_data::particle::Particle; use pumpkin_data::sound::{Sound, SoundCategory}; +use pumpkin_data::statistic::StatisticCategory; use pumpkin_data::tag::Taggable; use pumpkin_data::{Block, BlockState, Enchantment, screen::WindowType, tag, translation}; use pumpkin_inventory::player::{ @@ -61,15 +63,15 @@ use pumpkin_protocol::bedrock::client::container_open::CContainerOpen; use pumpkin_protocol::codec::var_int::VarInt; use pumpkin_protocol::codec::var_long::VarLong; use pumpkin_protocol::java::client::play::{ - Animation, CAcknowledgeBlockChange, CActionBar, CChangeDifficulty, CCloseContainer, - CCombatDeath, CCustomPayload, CDisguisedChatMessage, CEntityAnimation, CEntityPositionSync, - CGameEvent, CItemCooldown, CMapItemData, COpenScreen, CParticle, CPlayerAbilities, - CPlayerInfoUpdate, CPlayerPosition, CPlayerSpawnPosition, CRespawn, CSetContainerContent, - CSetContainerProperty, CSetContainerSlot, CSetCursorItem, CSetEquipment, CSetExperience, - CSetHealth, CSetPlayerInventory, CSetSelectedSlot, CSoundEffect, CStopSound, CSubtitle, - CSystemChatMessage, CTabList, CTitleAnimation, CTitleText, CUnloadChunk, CUpdateMobEffect, - CUpdateTime, GameEvent, MapIcon, MapPatch, Metadata, PlayerAction, PlayerInfoFlags, - PreviousMessage, + Animation, CAcknowledgeBlockChange, CActionBar, CAwardStats, CChangeDifficulty, + CCloseContainer, CCombatDeath, CCustomPayload, CDisguisedChatMessage, CEntityAnimation, + CEntityPositionSync, CGameEvent, CItemCooldown, CMapItemData, COpenScreen, CParticle, + CPlayerAbilities, CPlayerInfoUpdate, CPlayerPosition, CPlayerSpawnPosition, CRespawn, + CSetContainerContent, CSetContainerProperty, CSetContainerSlot, CSetCursorItem, CSetEquipment, + CSetExperience, CSetHealth, CSetPlayerInventory, CSetSelectedSlot, CSoundEffect, CStopSound, + CSubtitle, CSystemChatMessage, CTabList, CTitleAnimation, CTitleText, CUnloadChunk, + CUpdateMobEffect, CUpdateTime, GameEvent, MapIcon, MapPatch, Metadata, PlayerAction, + PlayerInfoFlags, PreviousMessage, Statistic, }; use pumpkin_protocol::java::server::play::{ SClickSlot, SContainerButtonClick, SRenameItem, SlotActionType, @@ -440,6 +442,8 @@ pub struct Player { /// /// **Note:** When the `abilities` field is updated, the server should send a `send_abilities_update` packet to the client to notify them of the changes. pub abilities: Mutex, + /// Player statistics + pub stats: Mutex, /// The current stage of block destruction of the block the player is breaking. pub current_block_destroy_stage: AtomicI32, /// Indicates if the player is currently mining a block. @@ -647,6 +651,7 @@ impl Player { mining: AtomicBool::new(false), mining_pos: Mutex::new(BlockPos::ZERO), abilities: Mutex::new(abilities), + stats: Mutex::new(statistics::Statistics::default()), gamemode: AtomicCell::new(gamemode), previous_gamemode: AtomicCell::new(None), // TODO: Send the CPlayerSpawnPosition packet when the client connects with proper values @@ -852,6 +857,10 @@ impl Player { /// Removes the [`Player`] out of the current [`World`]. pub async fn remove(self: &Arc) { + self.stats + .lock() + .await + .increment_custom(statistics::CustomStatistic::LeaveGame, 1); let world = self.world(); world.remove_player(self, true).await; @@ -1170,6 +1179,12 @@ impl Player { // Send the break status before clearing the slot so the client can // use the item texture for break particles. if result == pumpkin_data::item_stack::DamageResult::Broken { + self.increment_stat( + statistics::StatisticCategory::Broken, + updated_stack.item.id as i32, + 1, + ) + .await; self.world().send_entity_status( &self.living_entity.entity, super::equipment_break_status(slot), @@ -1656,6 +1671,13 @@ impl Player { None::, )]); + self.set_stat( + statistics::StatisticCategory::Custom, + statistics::CustomStatistic::TimeSinceRest as i32, + 0, + ) + .await; + let chunk_pos = self.living_entity.entity.chunk_pos.load(); world.broadcast_to_chunk( chunk_pos, @@ -1815,6 +1837,19 @@ impl Player { .send_packet_now(&CAcknowledgeBlockChange::new(seq.into())) .await; } + + // Statistics updates + { + let mut stats = self.stats.lock().await; + stats.increment_custom(statistics::CustomStatistic::PlayTime, 1); + stats.increment_custom(statistics::CustomStatistic::TotalWorldTime, 1); + stats.increment_custom(statistics::CustomStatistic::TimeSinceDeath, 1); + stats.increment_custom(statistics::CustomStatistic::TimeSinceRest, 1); + if self.living_entity.entity.sneaking.load(Ordering::Relaxed) { + stats.increment_custom(statistics::CustomStatistic::SneakTime, 1); + } + } + { let mut xp = self.experience_pick_up_delay.lock().await; if *xp > 0 { @@ -1942,6 +1977,10 @@ impl Player { } pub async fn jump(&self) { + self.stats + .lock() + .await + .increment_custom(statistics::CustomStatistic::Jump, 1); if self.living_entity.entity.is_sprinting() { self.add_exhaustion(0.2).await; } else { @@ -2144,6 +2183,122 @@ impl Player { } } + pub async fn send_stats(&self) { + if let ClientPlatform::Java(java) = &self.client { + let stats_guard = self.stats.lock().await; + let packet_stats: Vec = stats_guard + .stats + .iter() + .map(|((category, stat), value)| Statistic { + category_id: VarInt(*category), + statistic_id: VarInt(*stat), + value: VarInt(*value), + }) + .collect(); + + java.enqueue_packet(&CAwardStats { + stats: &packet_stats, + }) + .await; + } + } + + pub async fn increment_stat( + &self, + category: statistics::StatisticCategory, + stat: i32, + amount: i32, + ) { + self.stats.lock().await.increment(category, stat, amount); + } + + pub async fn set_stat(&self, category: statistics::StatisticCategory, stat: i32, value: i32) { + self.stats.lock().await.set(category, stat, value); + } + + pub async fn get_movement_statistic(&self) -> statistics::CustomStatistic { + let entity = self.get_entity(); + if entity.has_vehicle().await { + let vehicle = entity.vehicle.lock().await; + if let Some(vehicle) = vehicle.as_ref() { + let entity_type = vehicle.get_entity().entity_type; + if entity_type == &EntityType::OAK_BOAT + || entity_type == &EntityType::SPRUCE_BOAT + || entity_type == &EntityType::BIRCH_BOAT + || entity_type == &EntityType::JUNGLE_BOAT + || entity_type == &EntityType::ACACIA_BOAT + || entity_type == &EntityType::DARK_OAK_BOAT + || entity_type == &EntityType::MANGROVE_BOAT + || entity_type == &EntityType::CHERRY_BOAT + || entity_type == &EntityType::BAMBOO_RAFT + { + return statistics::CustomStatistic::BoatOneCm; + } + if entity_type == &EntityType::MINECART + || entity_type == &EntityType::CHEST_MINECART + || entity_type == &EntityType::FURNACE_MINECART + || entity_type == &EntityType::TNT_MINECART + || entity_type == &EntityType::HOPPER_MINECART + || entity_type == &EntityType::COMMAND_BLOCK_MINECART + || entity_type == &EntityType::SPAWNER_MINECART + { + return statistics::CustomStatistic::MinecartOneCm; + } + if entity_type == &EntityType::HORSE + || entity_type == &EntityType::DONKEY + || entity_type == &EntityType::MULE + || entity_type == &EntityType::SKELETON_HORSE + || entity_type == &EntityType::ZOMBIE_HORSE + { + return statistics::CustomStatistic::HorseOneCm; + } + if entity_type == &EntityType::PIG { + return statistics::CustomStatistic::PigOneCm; + } + if entity_type == &EntityType::STRIDER { + return statistics::CustomStatistic::StriderOneCm; + } + } + } + + if self.is_flying().await { + return statistics::CustomStatistic::FlyOneCm; + } + + if entity.fall_flying.load(Ordering::Relaxed) { + return statistics::CustomStatistic::AviateOneCm; + } + + if entity.swimming.load(Ordering::Relaxed) { + return statistics::CustomStatistic::SwimOneCm; + } + + let pos = entity.block_pos.load(); + let world = entity.world.load_full(); + let block = world.get_block(&pos); + if block.has_tag(&pumpkin_data::tag::Block::MINECRAFT_CLIMBABLE) { + return statistics::CustomStatistic::ClimbOneCm; + } + + if entity.touching_water.load(Ordering::Relaxed) { + return statistics::CustomStatistic::WalkUnderWaterOneCm; + } + + if entity.sneaking.load(Ordering::Relaxed) { + return statistics::CustomStatistic::CrouchOneCm; + } + + if entity.sprinting.load(Ordering::Relaxed) { + return statistics::CustomStatistic::SprintOneCm; + } + + if !entity.on_ground.load(Ordering::Relaxed) && entity.velocity.load().y < -0.005 { + return statistics::CustomStatistic::FallOneCm; + } + + statistics::CustomStatistic::WalkOneCm + } + /// Updates the client of the player's current permission level. pub fn send_permission_lvl_update(&self) { let status = match self.permission_lvl.load() { @@ -2805,6 +2960,18 @@ impl Player { } pub async fn drop_item(&self, item_stack: ItemStack) { + self.increment_stat( + statistics::StatisticCategory::Dropped, + item_stack.item.id as i32, + item_stack.item_count as i32, + ) + .await; + self.increment_stat( + statistics::StatisticCategory::Custom, + statistics::CustomStatistic::Drop as i32, + 1, + ) + .await; let item_pos = self.living_entity.entity.pos.load() + Vector3::new(0.0, self.living_entity.entity.get_eye_height() - 0.3, 0.0); let entity = Entity::new(self.world(), item_pos, &EntityType::ITEM); @@ -3926,6 +4093,7 @@ impl NBTStorage for Player { nbt.put_bool("SpawnForced", respawn.force); } nbt.put_int("XpSeed", self.enchantment_seed.load(Ordering::Relaxed)); + self.stats.lock().await.write_nbt(nbt); }) } @@ -3983,6 +4151,7 @@ impl NBTStorage for Player { nbt.get_int("XpSeed").unwrap_or(rand::random()), Ordering::Relaxed, ); + self.stats.lock().await.read_nbt(nbt); }) } } @@ -4686,4 +4855,15 @@ impl InventoryPlayer for Player { } }) } + + fn increment_stat( + &self, + category: StatisticCategory, + stat_id: i32, + amount: i32, + ) -> PlayerFuture<'_, ()> { + Box::pin(async move { + self.increment_stat(category, stat_id, amount).await; + }) + } } diff --git a/pumpkin/src/entity/player/statistics.rs b/pumpkin/src/entity/player/statistics.rs new file mode 100644 index 000000000..d4cda6a9c --- /dev/null +++ b/pumpkin/src/entity/player/statistics.rs @@ -0,0 +1,52 @@ +pub use pumpkin_data::statistic::{CustomStatistic, StatisticCategory}; +use pumpkin_nbt::compound::NbtCompound; +use pumpkin_nbt::tag::NbtTag; +use std::collections::HashMap; + +#[derive(Default)] +pub struct Statistics { + /// (Category ID, Statistic ID) -> Value + pub stats: HashMap<(i32, i32), i32>, +} + +impl Statistics { + pub fn increment(&mut self, category: StatisticCategory, stat: i32, amount: i32) { + let entry = self.stats.entry((category as i32, stat)).or_insert(0); + *entry += amount; + } + + pub fn increment_custom(&mut self, stat: CustomStatistic, amount: i32) { + self.increment(StatisticCategory::Custom, stat as i32, amount); + } + + pub fn set(&mut self, category: StatisticCategory, stat: i32, value: i32) { + self.stats.insert((category as i32, stat), value); + } + + #[must_use] + pub fn get(&self, category: StatisticCategory, stat: i32) -> i32 { + *self.stats.get(&(category as i32, stat)).unwrap_or(&0) + } + + pub fn write_nbt(&self, nbt: &mut NbtCompound) { + let mut stats_compound = NbtCompound::new(); + for ((category, stat), value) in &self.stats { + stats_compound.put_int(&format!("{category}:{stat}"), *value); + } + nbt.put_compound("Statistics", stats_compound); + } + + pub fn read_nbt(&mut self, nbt: &NbtCompound) { + if let Some(stats_compound) = nbt.get_compound("Statistics") { + for (key, tag) in &stats_compound.child_tags { + let parts: Vec<&str> = key.split(':').collect(); + if let (NbtTag::Int(value), [cat_str, stat_str]) = (tag, parts.as_slice()) + && let (Ok(category), Ok(stat)) = + (cat_str.parse::(), stat_str.parse::()) + { + self.stats.insert((category, stat), *value); + } + } + } + } +} diff --git a/pumpkin/src/entity/projectile/fishing_bobber.rs b/pumpkin/src/entity/projectile/fishing_bobber.rs index 9f563d655..44666380b 100644 --- a/pumpkin/src/entity/projectile/fishing_bobber.rs +++ b/pumpkin/src/entity/projectile/fishing_bobber.rs @@ -47,7 +47,8 @@ impl FishingBobberEntity { } } - pub fn reel_in(&self, player: &Player) -> i32 { + pub async fn reel_in(&self, player: &Player) -> i32 { + use pumpkin_data::item::Item; let world = self.entity.world.load(); let hooked_id = self.hooked_entity_id.load(Ordering::Relaxed); @@ -67,8 +68,15 @@ impl FishingBobberEntity { if self.bite_countdown.load(Ordering::Relaxed) > 0 { // Caught something! + player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::FishCaught as i32, + 1, + ) + .await; + // TODO: Use actual loot tables. For now, just give a raw cod. - use pumpkin_data::item::Item; let _item_stack = ItemStack::new(1, &Item::COD); // player.inventory().add_item(item_stack).await; // Need public add_item diff --git a/pumpkin/src/item/items/fishing_rod.rs b/pumpkin/src/item/items/fishing_rod.rs index fee5566d7..9c709eae4 100644 --- a/pumpkin/src/item/items/fishing_rod.rs +++ b/pumpkin/src/item/items/fishing_rod.rs @@ -63,7 +63,7 @@ impl ItemBehaviour for FishingRodItem { if let Some(bobber) = bobber_base.cast_any().downcast_ref::() { - let _result = bobber.reel_in(player); + let _result = bobber.reel_in(player).await; // TODO: give items } bobber_base.get_entity().remove().await; diff --git a/pumpkin/src/net/java/play.rs b/pumpkin/src/net/java/play.rs index 29f62320e..609b2be5f 100644 --- a/pumpkin/src/net/java/play.rs +++ b/pumpkin/src/net/java/play.rs @@ -15,6 +15,7 @@ use crate::block::registry::BlockActionResult; use crate::block::{self, BlockIsReplacing}; use crate::entity::EntityBase; use crate::entity::equipment_break_status; +use crate::entity::player::statistics::{CustomStatistic, StatisticCategory}; use crate::entity::player::{ChatMode, ChatSession, Player}; use crate::error::PumpkinError; use crate::log_at_level; @@ -360,6 +361,15 @@ impl JavaClient { let last_pos = entity.pos.load(); player.get_entity().set_pos(pos); + let distance = last_pos.squared_distance_to_vec(&pos).sqrt(); + let cm = (distance * 100.0) as i32; + if cm > 0 { + let stat = player.get_movement_statistic().await; + player + .increment_stat(StatisticCategory::Custom, stat as i32, cm) + .await; + } + let height_difference = pos.y - last_pos.y; if entity.on_ground.load(Ordering::Relaxed) && packet.collision & FLAG_ON_GROUND == 0 && height_difference > 0.0 { player.jump().await; @@ -486,6 +496,15 @@ impl JavaClient { let last_pos = entity.pos.load(); player.get_entity().set_pos(pos); + let distance = last_pos.squared_distance_to_vec(&pos).sqrt(); + let cm = (distance * 100.0) as i32; + if cm > 0 { + let stat = player.get_movement_statistic().await; + player + .increment_stat(StatisticCategory::Custom, stat as i32, cm) + .await; + } + let height_difference = pos.y - last_pos.y; if entity.on_ground.load(Ordering::Relaxed) && (packet.collision & FLAG_ON_GROUND) != 0 @@ -1609,7 +1628,7 @@ impl JavaClient { } 1 => { // Request stats - debug!("todo"); + player.send_stats().await; } _ => { self.kick(TextComponent::text("Invalid client status")) @@ -1807,6 +1826,22 @@ impl JavaClient { let world = entity.world.load_full(); let (block, state) = world.get_block_and_state(&position); + if block == &pumpkin_data::Block::NOTE_BLOCK { + let props = + pumpkin_data::block_properties::NoteBlockLikeProperties::from_state_id( + state.id, block, + ); + crate::block::blocks::note::NoteBlock::play_note(&props, &world, &position) + .await; + player + .increment_stat( + StatisticCategory::Custom, + CustomStatistic::PlayNoteblock as i32, + 1, + ) + .await; + } + let inventory = player.inventory(); let held = inventory.held_item(); if !server @@ -1865,6 +1900,17 @@ impl JavaClient { .broken(&world, block, player, &position, server, broken_state) .await; player.apply_tool_damage_for_block_break(broken_state).await; + let item_id = player.inventory().held_item().lock().await.item.id; + player + .increment_stat(StatisticCategory::Used, item_id as i32, 1) + .await; + player + .increment_stat( + StatisticCategory::Mined, + broken_state.id as i32, + 1, + ) + .await; } self.sync_block_state_to_client(&world, position).await; } else { @@ -1936,8 +1982,17 @@ impl JavaClient { .block_registry .broken(&world, block, player, &location, server, state) .await; + player.apply_tool_damage_for_block_break(state).await; + let item_id = player.inventory().held_item().lock().await.item.id; + player + .increment_stat(StatisticCategory::Used, item_id as i32, 1) + .await; + player + .increment_stat(StatisticCategory::Mined, state.id as i32, 1) + .await; } + self.sync_block_state_to_client(&world, location).await; self.update_sequence(player, player_action.sequence.0); @@ -2087,6 +2142,11 @@ impl JavaClient { off_hand_item }; + let item_id = item.lock().await.item.id; + player + .increment_stat(StatisticCategory::Used, item_id as i32, 1) + .await; + let entity = &player.get_entity(); let world = entity.world.load_full(); let block = world.get_block(&position); @@ -2303,6 +2363,14 @@ impl JavaClient { inventory.off_hand_item().await }; + let (item_id, _item) = { + let guard = item_in_hand.lock().await; + (guard.item.id, guard.item) + }; + player + .increment_stat(StatisticCategory::Used, item_id as i32, 1) + .await; + let hit_result = player .world() .raycast( diff --git a/pumpkin/src/server/mod.rs b/pumpkin/src/server/mod.rs index b7c4cb173..6715b21b6 100644 --- a/pumpkin/src/server/mod.rs +++ b/pumpkin/src/server/mod.rs @@ -537,6 +537,13 @@ impl Server { } pub async fn remove_player(&self, player: &Player) { + player + .increment_stat( + pumpkin_data::statistic::StatisticCategory::Custom, + pumpkin_data::statistic::CustomStatistic::LeaveGame as i32, + 1, + ) + .await; // TODO: Config if we want decrease online self.listing.lock().await.remove_player(player); }