Implement Drop Item Events (#252)

<!-- Please make sure that your PR is aligned with the guidelines in
CONTRIBUTING.md to the best of your ability. -->
<!-- Good PRs have tests! Make sure you have sufficient test coverage.
-->

## Description

<!-- Describe the changes you've made. You may include any justification
you want here. -->
Dropping items is heavily coupled to inventories. Clients also predict
state changes when they try to drop items, so we need to be able to
replicate that change in order to stay in sync.

This will also remove `DropItem` events in favor of just `DropItemStack`
events. Having 2 event streams that basically mean the same thing seems
verbose and error prone.

As of right now, these changes require the event loop to have a
reference to the client's inventory. This seems like something we are
going to need to do a lot more of to complete #199.

## Test Plan

<!-- Explain how you tested your changes, and include any code that you
used to test this. -->
<!-- If there is an example that is sufficient to use in place of a
playground, replace the playground section with a note that indicates
this. -->

<details>

<summary>Playground</summary>

```rust
use tracing::info;
use valence::client::despawn_disconnected_clients;
use valence::client::event::{default_event_handler, DropItemStack};
use valence::prelude::*;

#[allow(unused_imports)]
use crate::extras::*;

const SPAWN_Y: i32 = 64;

pub fn build_app(app: &mut App) {
    app.add_plugin(ServerPlugin::new(()).with_connection_mode(ConnectionMode::Offline))
        .add_system_to_stage(EventLoop, default_event_handler)
        .add_startup_system(setup)
        .add_system(init_clients)
        .add_system(despawn_disconnected_clients)
        .add_system(toggle_gamemode_on_sneak)
        .add_system(drop_items);
}

fn setup(world: &mut World) {
    let mut instance = world
        .resource::<Server>()
        .new_instance(DimensionId::default());

    for z in -5..5 {
        for x in -5..5 {
            instance.insert_chunk([x, z], Chunk::default());
        }
    }

    for z in -25..25 {
        for x in -25..25 {
            instance.set_block([x, SPAWN_Y, z], BlockState::GRASS_BLOCK);
        }
    }

    world.spawn(instance);
}

fn init_clients(
    mut clients: Query<&mut Client, Added<Client>>,
    instances: Query<Entity, With<Instance>>,
) {
    let instance = instances.get_single().unwrap();

    for mut client in &mut clients {
        client.set_position([0.5, SPAWN_Y as f64 + 1.0, 0.5]);
        client.set_instance(instance);
        client.set_game_mode(GameMode::Creative);
    }
}

fn drop_items(clients: Query<&Client>, mut drop_stack_events: EventReader<DropItemStack>) {
    if drop_stack_events.is_empty() {
        return;
    }

    for event in drop_stack_events.iter() {
        info!("drop stack: {:?}", event);
    }
}

```

</details>

<!-- You need to include steps regardless of whether or not you are
using a playground. -->
Steps:
1. `cargo test -p valence --tests`
2. Run playground `cargo run -p playground`
3. Open creative menu
4. Pick an item and click to drop it outside of the creative menu
5. Pick an entire stack of an item, place it in your hotbar
6. Hover over the item, press your drop key to drop an item from the
stack
7. Press shift to switch to survival
8. Select the item stack in your hotbar, press your drop key to drop an
item from the stack
9. Open your inventory, grab the stack, hover outside the window and
click to drop the entire stack
10. Grab another stack from creative, place it in your hotbar
11. switch back to survival, select the stack, and press your control +
drop key to drop the entire stack
12. For each item you dropped, you should see a log message with the
event

#### Related

<!-- Link to any issues that have context for this or that this PR
fixes. -->
This commit is contained in:
Carson McManus 2023-02-20 18:37:09 -05:00 committed by GitHub
parent 7d05cb8309
commit 0319635a8b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 401 additions and 31 deletions

View file

@ -1,2 +1,24 @@
//! Put stuff in here if you find that you have to write the same code for
//! multiple playgrounds.
use valence::client::event::StartSneaking;
use valence::prelude::*;
/// Toggles client's game mode between survival and creative when they start
/// sneaking.
pub fn toggle_gamemode_on_sneak(
mut clients: Query<&mut Client>,
mut events: EventReader<StartSneaking>,
) {
for event in events.iter() {
let Ok(mut client) = clients.get_component_mut::<Client>(event.client) else {
continue;
};
let mode = client.game_mode();
client.set_game_mode(match mode {
GameMode::Survival => GameMode::Creative,
GameMode::Creative => GameMode::Survival,
_ => GameMode::Creative,
});
}
}

View file

@ -1,5 +1,6 @@
use valence::bevy_app::App;
#[allow(dead_code)]
mod extras;
mod playground;

View file

@ -38,10 +38,6 @@ fn setup(world: &mut World) {
}
}
instance.set_block(CHEST_POS, BlockState::CHEST);
instance.set_block(
[CHEST_POS[0], CHEST_POS[1] - 1, CHEST_POS[2]],
BlockState::STONE,
);
world.spawn(instance);

View file

@ -154,7 +154,7 @@ impl Client {
window_id: 0,
inventory_state_id: Wrapping(0),
inventory_slots_modified: 0,
held_item_slot: 0,
held_item_slot: 36,
}
}

View file

@ -22,6 +22,7 @@ use valence_protocol::{BlockFace, BlockPos, Ident, ItemStack};
use crate::client::Client;
use crate::entity::{EntityAnimation, EntityKind, McEntity, TrackedData};
use crate::inventory::Inventory;
#[derive(Clone, Debug)]
pub struct QueryBlockEntity {
@ -330,14 +331,11 @@ pub struct FinishDigging {
pub sequence: i32,
}
#[derive(Clone, Debug)]
pub struct DropItem {
pub client: Entity,
}
#[derive(Clone, Debug)]
pub struct DropItemStack {
pub client: Entity,
pub from_slot: Option<u16>,
pub stack: ItemStack,
}
/// Eating food, pulling back bows, using buckets, etc.
@ -647,7 +645,6 @@ events! {
StartDigging
CancelDigging
FinishDigging
DropItem
DropItemStack
UpdateHeldItemState
SwapItemInHand
@ -681,7 +678,7 @@ events! {
}
pub(crate) fn event_loop_run_criteria(
mut clients: Query<(Entity, &mut Client)>,
mut clients: Query<(Entity, &mut Client, &mut Inventory)>,
mut clients_to_check: Local<Vec<Entity>>,
mut events: ClientEvents,
) -> ShouldRun {
@ -690,8 +687,9 @@ pub(crate) fn event_loop_run_criteria(
update_all_event_buffers(&mut events);
for (entity, client) in &mut clients {
for (entity, client, inventory) in &mut clients {
let client = client.into_inner();
let inventory = inventory.into_inner();
let Ok(bytes) = client.conn.try_recv() else {
// Client is disconnected.
@ -706,7 +704,7 @@ pub(crate) fn event_loop_run_criteria(
client.dec.queue_bytes(bytes);
match handle_one_packet(client, entity, &mut events) {
match handle_one_packet(client, inventory, entity, &mut events) {
Ok(had_packet) => {
if had_packet {
// We decoded one packet, but there might be more.
@ -729,12 +727,12 @@ pub(crate) fn event_loop_run_criteria(
// Continue to filter the list of clients we need to check until there are none
// left.
clients_to_check.retain(|&entity| {
let Ok((_, mut client)) = clients.get_mut(entity) else {
let Ok((_, mut client, mut inventory)) = clients.get_mut(entity) else {
// Client was deleted during the last run of the stage.
return false;
};
match handle_one_packet(&mut client, entity, &mut events) {
match handle_one_packet(&mut client, &mut inventory, entity, &mut events) {
Ok(had_packet) => had_packet,
Err(e) => {
// TODO: validate packets in separate systems.
@ -761,6 +759,7 @@ pub(crate) fn event_loop_run_criteria(
fn handle_one_packet(
client: &mut Client,
inventory: &mut Inventory,
entity: Entity,
events: &mut ClientEvents,
) -> anyhow::Result<bool> {
@ -859,6 +858,36 @@ fn handle_one_packet(
});
}
C2sPlayPacket::ClickContainer(p) => {
if p.slot_idx < 0 {
if let Some(stack) = client.cursor_item.take() {
events.2.drop_item_stack.send(DropItemStack {
client: entity,
from_slot: None,
stack,
});
}
} else if p.mode == ClickContainerMode::DropKey {
let entire_stack = p.button == 1;
if let Some(stack) = inventory.slot(p.slot_idx as u16) {
let dropped = if entire_stack || stack.count() == 1 {
inventory.replace_slot(p.slot_idx as u16, None)
} else {
let mut stack = stack.clone();
stack.set_count(stack.count() - 1);
let mut old_slot = inventory.replace_slot(p.slot_idx as u16, Some(stack));
// we already checked that the slot was not empty and that the
// stack count is > 1
old_slot.as_mut().unwrap().set_count(1);
old_slot
}
.expect("dropped item should exist"); // we already checked that the slot was not empty
events.2.drop_item_stack.send(DropItemStack {
client: entity,
from_slot: Some(p.slot_idx as u16),
stack: dropped,
});
}
} else {
events.0.click_container.send(ClickContainer {
client: entity,
window_id: p.window_id,
@ -870,6 +899,7 @@ fn handle_one_packet(
carried_item: p.carried_item,
});
}
}
C2sPlayPacket::CloseContainerC2s(p) => {
events.0.close_container.send(CloseContainer {
client: entity,
@ -1157,11 +1187,36 @@ fn handle_one_packet(
face: p.face,
sequence: p.sequence.0,
}),
DiggingStatus::DropItemStack => events
.2
.drop_item_stack
.send(DropItemStack { client: entity }),
DiggingStatus::DropItem => events.2.drop_item.send(DropItem { client: entity }),
DiggingStatus::DropItemStack => {
if let Some(stack) = inventory.replace_slot(client.held_item_slot(), None) {
client.inventory_slots_modified |= 1 << client.held_item_slot();
events.2.drop_item_stack.send(DropItemStack {
client: entity,
from_slot: Some(client.held_item_slot()),
stack,
});
}
}
DiggingStatus::DropItem => {
if let Some(stack) = inventory.slot(client.held_item_slot()) {
let mut old_slot = if stack.count() == 1 {
inventory.replace_slot(client.held_item_slot(), None)
} else {
let mut stack = stack.clone();
stack.set_count(stack.count() - 1);
inventory.replace_slot(client.held_item_slot(), Some(stack.clone()))
}
.expect("old slot should exist"); // we already checked that the slot was not empty
client.inventory_slots_modified |= 1 << client.held_item_slot();
old_slot.set_count(1);
events.2.drop_item_stack.send(DropItemStack {
client: entity,
from_slot: Some(client.held_item_slot()),
stack: old_slot,
});
}
}
DiggingStatus::UpdateHeldItemState => events
.2
.update_held_item_state
@ -1280,6 +1335,15 @@ fn handle_one_packet(
});
}
C2sPlayPacket::SetCreativeModeSlot(p) => {
if p.slot == -1 {
if let Some(stack) = p.clicked_item.as_ref() {
events.2.drop_item_stack.send(DropItemStack {
client: entity,
from_slot: None,
stack: stack.clone(),
});
}
}
events.3.set_creative_mode_slot.send(SetCreativeModeSlot {
client: entity,
slot: p.slot,

View file

@ -1088,4 +1088,291 @@ mod test {
Ok(())
}
mod dropping_items {
use valence_protocol::types::{ClickContainerMode, DiggingStatus};
use valence_protocol::{BlockFace, BlockPos};
use super::*;
use crate::client::event::DropItemStack;
#[test]
fn should_drop_item_player_action() -> anyhow::Result<()> {
let mut app = App::new();
let (client_ent, mut client_helper) = scenario_single_client(&mut app);
let mut inventory = app
.world
.get_mut::<Inventory>(client_ent)
.expect("could not find inventory");
inventory.replace_slot(36, ItemStack::new(ItemKind::IronIngot, 3, None));
// Process a tick to get past the "on join" logic.
app.update();
client_helper.clear_sent();
client_helper.send(&valence_protocol::packets::c2s::play::PlayerAction {
status: DiggingStatus::DropItem,
position: BlockPos::new(0, 0, 0),
face: BlockFace::Bottom,
sequence: VarInt(0),
});
app.update();
// Make assertions
let inventory = app
.world
.get::<Inventory>(client_ent)
.expect("could not find client");
assert_eq!(
inventory.slot(36),
Some(&ItemStack::new(ItemKind::IronIngot, 2, None))
);
let events = app
.world
.get_resource::<Events<DropItemStack>>()
.expect("expected drop item stack events");
let events = events.iter_current_update_events().collect::<Vec<_>>();
assert_eq!(events.len(), 1);
assert_eq!(events[0].client, client_ent);
assert_eq!(events[0].from_slot, Some(36));
assert_eq!(
events[0].stack,
ItemStack::new(ItemKind::IronIngot, 1, None)
);
let sent_packets = client_helper.collect_sent()?;
assert_packet_count!(sent_packets, 0, S2cPlayPacket::SetContainerSlot(_));
Ok(())
}
#[test]
fn should_drop_item_stack_player_action() -> anyhow::Result<()> {
let mut app = App::new();
let (client_ent, mut client_helper) = scenario_single_client(&mut app);
let mut inventory = app
.world
.get_mut::<Inventory>(client_ent)
.expect("could not find inventory");
inventory.replace_slot(36, ItemStack::new(ItemKind::IronIngot, 32, None));
// Process a tick to get past the "on join" logic.
app.update();
client_helper.clear_sent();
client_helper.send(&valence_protocol::packets::c2s::play::PlayerAction {
status: DiggingStatus::DropItemStack,
position: BlockPos::new(0, 0, 0),
face: BlockFace::Bottom,
sequence: VarInt(0),
});
app.update();
// Make assertions
let client = app
.world
.get::<Client>(client_ent)
.expect("could not find client");
assert_eq!(client.held_item_slot(), 36);
let inventory = app
.world
.get::<Inventory>(client_ent)
.expect("could not find inventory");
assert_eq!(inventory.slot(36), None);
let events = app
.world
.get_resource::<Events<DropItemStack>>()
.expect("expected drop item stack events");
let events = events.iter_current_update_events().collect::<Vec<_>>();
assert_eq!(events.len(), 1);
assert_eq!(events[0].client, client_ent);
assert_eq!(events[0].from_slot, Some(36));
assert_eq!(
events[0].stack,
ItemStack::new(ItemKind::IronIngot, 32, None)
);
Ok(())
}
#[test]
fn should_drop_item_stack_set_creative_mode_slot() -> anyhow::Result<()> {
let mut app = App::new();
let (client_ent, mut client_helper) = scenario_single_client(&mut app);
// Process a tick to get past the "on join" logic.
app.update();
client_helper.clear_sent();
client_helper.send(&valence_protocol::packets::c2s::play::SetCreativeModeSlot {
slot: -1,
clicked_item: Some(ItemStack::new(ItemKind::IronIngot, 32, None)),
});
app.update();
// Make assertions
let events = app
.world
.get_resource::<Events<DropItemStack>>()
.expect("expected drop item stack events");
let events = events.iter_current_update_events().collect::<Vec<_>>();
assert_eq!(events.len(), 1);
assert_eq!(events[0].client, client_ent);
assert_eq!(events[0].from_slot, None);
assert_eq!(
events[0].stack,
ItemStack::new(ItemKind::IronIngot, 32, None)
);
Ok(())
}
#[test]
fn should_drop_item_stack_click_container_outside() -> anyhow::Result<()> {
let mut app = App::new();
let (client_ent, mut client_helper) = scenario_single_client(&mut app);
let mut client = app
.world
.get_mut::<Client>(client_ent)
.expect("could not find client");
client.cursor_item = Some(ItemStack::new(ItemKind::IronIngot, 32, None));
let state_id = client.inventory_state_id.0;
// Process a tick to get past the "on join" logic.
app.update();
client_helper.clear_sent();
client_helper.send(&valence_protocol::packets::c2s::play::ClickContainer {
window_id: 0,
slot_idx: -999,
button: 0,
mode: ClickContainerMode::Click,
state_id: VarInt(state_id),
slots: vec![],
carried_item: None,
});
app.update();
// Make assertions
let client = app
.world
.get::<Client>(client_ent)
.expect("could not find client");
assert_eq!(client.cursor_item(), None);
let events = app
.world
.get_resource::<Events<DropItemStack>>()
.expect("expected drop item stack events");
let events = events.iter_current_update_events().collect::<Vec<_>>();
assert_eq!(events.len(), 1);
assert_eq!(events[0].client, client_ent);
assert_eq!(events[0].from_slot, None);
assert_eq!(
events[0].stack,
ItemStack::new(ItemKind::IronIngot, 32, None)
);
Ok(())
}
#[test]
fn should_drop_item_click_container_with_dropkey_single() -> anyhow::Result<()> {
let mut app = App::new();
let (client_ent, mut client_helper) = scenario_single_client(&mut app);
let client = app
.world
.get_mut::<Client>(client_ent)
.expect("could not find client");
let state_id = client.inventory_state_id.0;
let mut inventory = app
.world
.get_mut::<Inventory>(client_ent)
.expect("could not find inventory");
inventory.replace_slot(40, ItemStack::new(ItemKind::IronIngot, 32, None));
// Process a tick to get past the "on join" logic.
app.update();
client_helper.clear_sent();
client_helper.send(&valence_protocol::packets::c2s::play::ClickContainer {
window_id: 0,
slot_idx: 40,
button: 0,
mode: ClickContainerMode::DropKey,
state_id: VarInt(state_id),
slots: vec![],
carried_item: None,
});
app.update();
// Make assertions
let events = app
.world
.get_resource::<Events<DropItemStack>>()
.expect("expected drop item stack events");
let events = events.iter_current_update_events().collect::<Vec<_>>();
assert_eq!(events.len(), 1);
assert_eq!(events[0].client, client_ent);
assert_eq!(events[0].from_slot, Some(40));
assert_eq!(
events[0].stack,
ItemStack::new(ItemKind::IronIngot, 1, None)
);
Ok(())
}
#[test]
fn should_drop_item_stack_click_container_with_dropkey() -> anyhow::Result<()> {
let mut app = App::new();
let (client_ent, mut client_helper) = scenario_single_client(&mut app);
let client = app
.world
.get_mut::<Client>(client_ent)
.expect("could not find client");
let state_id = client.inventory_state_id.0;
let mut inventory = app
.world
.get_mut::<Inventory>(client_ent)
.expect("could not find inventory");
inventory.replace_slot(40, ItemStack::new(ItemKind::IronIngot, 32, None));
// Process a tick to get past the "on join" logic.
app.update();
client_helper.clear_sent();
client_helper.send(&valence_protocol::packets::c2s::play::ClickContainer {
window_id: 0,
slot_idx: 40,
button: 1, // pressing control
mode: ClickContainerMode::DropKey,
state_id: VarInt(state_id),
slots: vec![],
carried_item: None,
});
app.update();
// Make assertions
let events = app
.world
.get_resource::<Events<DropItemStack>>()
.expect("expected drop item stack events");
let events = events.iter_current_update_events().collect::<Vec<_>>();
assert_eq!(events.len(), 1);
assert_eq!(events[0].client, client_ent);
assert_eq!(events[0].from_slot, Some(40));
assert_eq!(
events[0].stack,
ItemStack::new(ItemKind::IronIngot, 32, None)
);
Ok(())
}
}
}