Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/bin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ ferrumc-scheduler = { workspace = true }
ferrumc-registry = { workspace = true }
ferrumc-messages = { workspace = true }
ferrumc-components = { workspace = true }
ferrumc-nbt = { workspace = true }
ferrumc-net = { workspace = true }
ferrumc-performance = { workspace = true }
ferrumc-net-codec = { workspace = true }
Expand Down
21 changes: 17 additions & 4 deletions src/bin/src/packet_handlers/play_packets/set_held_item.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use bevy_ecs::prelude::{Query, Res};
use bevy_ecs::prelude::{MessageWriter, Query, Res};
use ferrumc_inventories::hotbar::Hotbar;
use ferrumc_messages::inventory::HeldItemChanged;
use ferrumc_net::SetHeldItemReceiver;
use ferrumc_state::GlobalStateResource;
use tracing::{debug, error};
Expand All @@ -8,15 +9,27 @@ pub fn handle(
receiver: Res<SetHeldItemReceiver>,
state: Res<GlobalStateResource>,
mut query: Query<&mut Hotbar>,
mut held_events: MessageWriter<HeldItemChanged>,
) {
for (event, entity) in receiver.0.try_iter() {
if state.0.players.is_connected(entity) {
if 0 <= event.slot_index && event.slot_index < 9 {
if let Ok(mut hotbar) = query.get_mut(entity) {
hotbar.selected_slot = event.slot_index as u8;
let old_slot = hotbar.selected_slot;
let new_slot = event.slot_index as u8;

hotbar.selected_slot = new_slot;

// Fire the HeldItemChanged message for plugins and equipment broadcast
held_events.write(HeldItemChanged {
player: entity,
old_slot,
new_slot,
});

debug!(
"Set held item for player {} to slot {}",
entity, event.slot_index
"Set held item for player {} to slot {} (was {})",
entity, new_slot, old_slot
);
} else {
error!("Could not find hotbar for player {}", entity);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@ pub fn handle(
InventorySlot {
count: new_data.item_count,
item_id: Some(ItemID(new_data.item_id)),
components_to_add: None,
components_to_remove: None,
components_to_add_count: None,
components_to_remove_count: None,
components_to_add: Vec::new(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't these have the count or am I missing something?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Counts were intentionally removed. They're not derived from Vec::len() during encoding. Explicit count fields were redundant and a source of potential count/data mismatch bugs.

components_to_remove: Vec::new(),
},
)
.expect("failed to write to inventory");
Expand Down
6 changes: 6 additions & 0 deletions src/bin/src/register_messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use ferrumc_commands::messages::{CommandDispatched, ResolvedCommandDispatched};
use ferrumc_core::conn::force_player_recount_event::ForcePlayerRecount;
use ferrumc_messages::chunk_calc::ChunkCalc;
use ferrumc_messages::entity_update::SendEntityUpdate;
use ferrumc_messages::inventory::{EquipmentChanged, HeldItemChanged, InventorySynced};
use ferrumc_messages::particle::SendParticle;
use ferrumc_messages::teleport_player::TeleportPlayer;
use ferrumc_messages::{
Expand Down Expand Up @@ -37,4 +38,9 @@ pub fn register_messages(world: &mut World) {
MessageRegistry::register_message::<SendParticle>(world);
MessageRegistry::register_message::<BlockBrokenEvent>(world);
MessageRegistry::register_message::<TeleportPlayer>(world);

// Inventory sync messages
MessageRegistry::register_message::<InventorySynced>(world);
MessageRegistry::register_message::<EquipmentChanged>(world);
MessageRegistry::register_message::<HeldItemChanged>(world);
}
14 changes: 9 additions & 5 deletions src/bin/src/systems/connection_killer.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use bevy_ecs::prelude::{Commands, Entity, MessageWriter, Query, Res};
use ferrumc_components::player::offline_player_data::OfflinePlayerData;
use ferrumc_components::player::offline_player_data::{
OfflinePlayerData, StorageOfflinePlayerData,
};
use ferrumc_components::{
active_effects::ActiveEffects,
health::Health,
Expand Down Expand Up @@ -103,11 +105,11 @@ pub fn connection_killer(
);
}

// Save data to cache
let data_to_cache = OfflinePlayerData {
// Save player data to cache
let data = OfflinePlayerData {
abilities: *abilities,
gamemode: gamemode.0,
position: (*pos).into(),
position: *pos,
rotation: *rot,
inventory: inv.clone(),
health: *health,
Expand All @@ -116,10 +118,12 @@ pub fn connection_killer(
ender_chest: echest.clone(),
active_effects: effects.clone(),
};
// Convert to storage format and save
let storage_data = StorageOfflinePlayerData::from(&data);
if let Err(err) = state
.0
.world
.save_player_data(player_identity.uuid, &data_to_cache)
.save_player_data(player_identity.uuid, &storage_data)
{
warn!(
"Failed to save player data for {}: {:?}",
Expand Down
256 changes: 256 additions & 0 deletions src/bin/src/systems/inventory_sync.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
//! Inventory synchronization systems for player equipment visibility.
//!
//! This module implements:
//! - Phase 1: Initial inventory sync on join (sends full inventory)
//! - Phase 2: Equipment broadcast (armor/held items visible to others)
//! - Phase 3: Join equipment exchange (see existing players' gear)
//! - Phase 4: Plugin hooks via messages

use bevy_ecs::prelude::*;
use ferrumc_core::identity::player_identity::PlayerIdentity;
use ferrumc_inventories::hotbar::Hotbar;
use ferrumc_inventories::inventory::Inventory;
use ferrumc_inventories::slot::InventorySlot;
use ferrumc_inventories::sync::{EquipmentState, NeedsInventorySync};
use ferrumc_messages::inventory::{EquipmentChanged, InventorySynced};
use ferrumc_net::connection::StreamWriter;
use ferrumc_net::packets::outgoing::set_container_content::SetContainerContent;
use ferrumc_net::packets::outgoing::set_equipment::{EquipmentEntry, SetEquipmentPacket};
use ferrumc_net_codec::net_types::length_prefixed_vec::LengthPrefixedVec;
use ferrumc_net_codec::net_types::var_int::VarInt;
use ferrumc_state::GlobalStateResource;
use tracing::{debug, error, trace};

// ============================================================================
// Phase 1: Initial Inventory Sync
// ============================================================================

/// Syncs full inventory to newly connected players.
/// Runs on players with the `NeedsInventorySync` marker component.
pub fn initial_inventory_sync(
mut commands: Commands,
state: Res<GlobalStateResource>,
query: Query<(Entity, &Inventory, &StreamWriter), With<NeedsInventorySync>>,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The marker component is not removed/added in the same order every tick, you should probably use a normal component with a flag in it

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced NeedsInventorySync marker with Bevy's Added<Inventory> filter and added an explicit .after(emit_player_joined) ordering constraint. Eliminates the marker entirely and guarantees the system runs after ApplyDeferred flushes the entity.

mut sync_events: MessageWriter<InventorySynced>,
) {
for (entity, inventory, writer) in query.iter() {
if !state.0.players.is_connected(entity) {
continue;
}

// Build slot list from inventory (46 slots for player inventory)
let slots: Vec<InventorySlot> = inventory
.slots
.iter()
.map(|slot| slot.clone().unwrap_or_default())
.collect();

let packet = SetContainerContent {
window_id: VarInt::new(0), // 0 = player inventory
state_id: VarInt::new(0), // State tracking (0 for initial)
slots: LengthPrefixedVec::new(slots),
carried_item: InventorySlot::empty(), // Cursor item (empty on join)
};

if let Err(e) = writer.send_packet(packet) {
error!("Failed to send initial inventory to {:?}: {:?}", entity, e);
continue;
}

debug!("Sent initial inventory sync to {:?}", entity);

// Remove the marker so we don't sync again
commands.entity(entity).remove::<NeedsInventorySync>();

// Fire the event for plugins
sync_events.write(InventorySynced { player: entity });
}
}

// ============================================================================
// Phase 2: Equipment Broadcast
// ============================================================================

/// Detects equipment changes and broadcasts them to other players.
/// Uses `Changed<Inventory>` and `Changed<Hotbar>` filters.
#[expect(
clippy::type_complexity,
reason = "Bevy ECS queries require complex tuples"
)]
pub fn equipment_broadcast(
state: Res<GlobalStateResource>,
mut changed_query: Query<
(
Entity,
&PlayerIdentity,
&Inventory,
&Hotbar,
&mut EquipmentState,
),
Or<(Changed<Inventory>, Changed<Hotbar>)>,
>,
other_players: Query<(Entity, &StreamWriter)>,
mut equipment_events: MessageWriter<EquipmentChanged>,
) {
for (entity, identity, inventory, hotbar, mut cached_state) in changed_query.iter_mut() {
if !state.0.players.is_connected(entity) {
continue;
}

// Compute current equipment state
let current_state = EquipmentState::from_inventory(inventory, hotbar);

// Find which slots changed
let changed_slots = cached_state.diff(&current_state);

if changed_slots.is_empty() {
// Update cache even if diff is empty (handles component differences)
*cached_state = current_state;
continue;
}

trace!(
"Equipment changed for {}: {:?}",
identity.username,
changed_slots
);

// Build equipment entries for changed slots
let entries: Vec<EquipmentEntry> = changed_slots
.iter()
.map(|&slot| EquipmentEntry {
slot,
item: current_state.get(slot).cloned().unwrap_or_default(),
})
.collect();

let packet = SetEquipmentPacket::new(identity.short_uuid, entries);

// Broadcast to all other connected players
for (other_entity, writer) in other_players.iter() {
if other_entity == entity {
continue; // Don't send to self
}
if !state.0.players.is_connected(other_entity) {
continue;
}

if let Err(e) = writer.send_packet_ref(&packet) {
error!(
"Failed to send equipment update to {:?}: {:?}",
other_entity, e
);
}
}

// Fire the event for plugins
equipment_events.write(EquipmentChanged {
player: entity,
slots: changed_slots,
});

// Update cached state
*cached_state = current_state;
}
}

// ============================================================================
// Phase 3: Join Equipment Exchange
// ============================================================================

/// When a new player joins, send their equipment to everyone else,
/// and send everyone else's equipment to them.
///
/// Uses `Added<PlayerIdentity>` instead of `PlayerJoined` message to ensure
/// the entity is queryable (commands have been applied).
pub fn join_equipment_exchange(
state: Res<GlobalStateResource>,
// Query new players using Added<> filter - ensures entity exists and is queryable
new_players: Query<
(Entity, &PlayerIdentity, &Inventory, &Hotbar, &StreamWriter),
Added<PlayerIdentity>,
>,
// Query all players for exchange
all_players: Query<(Entity, &PlayerIdentity, &Inventory, &Hotbar, &StreamWriter)>,
) {
for (joining_entity, joining_identity, joining_inv, joining_hotbar, joining_writer) in
new_players.iter()
{
if !state.0.players.is_connected(joining_entity) {
continue;
}

trace!(
"Processing equipment exchange for joining player: {}",
joining_identity.username
);

// Build joining player's equipment
let joining_equipment = EquipmentState::from_inventory(joining_inv, joining_hotbar);

// Only send if they have equipment
let joining_packet = if !joining_equipment.is_empty() {
let entries: Vec<EquipmentEntry> = joining_equipment
.non_empty_slots()
.map(|(slot, item)| EquipmentEntry {
slot,
item: item.clone(),
})
.collect();
Some(SetEquipmentPacket::new(
joining_identity.short_uuid,
entries,
))
} else {
None
};

// Exchange with all other players
for (other_entity, other_identity, other_inv, other_hotbar, other_writer) in
all_players.iter()
{
if other_entity == joining_entity {
continue;
}
if !state.0.players.is_connected(other_entity) {
continue;
}

// Send joining player's equipment to this other player
if let Some(ref packet) = joining_packet {
if let Err(e) = other_writer.send_packet_ref(packet) {
error!(
"Failed to send joining player equipment to {:?}: {:?}",
other_entity, e
);
}
}

// Send this other player's equipment to the joining player
let other_equipment = EquipmentState::from_inventory(other_inv, other_hotbar);

if !other_equipment.is_empty() {
let other_entries: Vec<EquipmentEntry> = other_equipment
.non_empty_slots()
.map(|(slot, item)| EquipmentEntry {
slot,
item: item.clone(),
})
.collect();
let other_packet =
SetEquipmentPacket::new(other_identity.short_uuid, other_entries);
if let Err(e) = joining_writer.send_packet(other_packet) {
error!(
"Failed to send other player equipment to joining player: {:?}",
e
);
}
}
}

debug!(
"Completed equipment exchange for {}",
joining_identity.username
);
}
}
6 changes: 6 additions & 0 deletions src/bin/src/systems/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod chunk_unloader;
pub mod connection_killer;
pub mod day_cycle;
pub mod emit_player_joined;
mod inventory_sync;
pub mod keep_alive_system;
pub mod lan_pinger;
pub mod listeners;
Expand Down Expand Up @@ -38,6 +39,11 @@ pub fn register_game_systems(schedule: &mut bevy_ecs::schedule::Schedule) {

schedule.add_systems(day_cycle::tick_daylight_cycle);

// Inventory sync systems (must run after new_connections to pick up NeedsInventorySync)
schedule.add_systems(inventory_sync::initial_inventory_sync);
schedule.add_systems(inventory_sync::equipment_broadcast);
schedule.add_systems(inventory_sync::join_equipment_exchange);

// Should always be last
schedule.add_systems(connection_killer::connection_killer);
schedule.add_systems(particles::handle);
Expand Down
Loading