Skip to main content

steel_core/behavior/items/
minecart.rs

1use glam::DVec3;
2use steel_macros::item_behavior;
3use steel_registry::blocks::block_state_ext::BlockStateExt as _;
4use steel_registry::blocks::properties::{BlockStateProperties, RailShape};
5use steel_registry::entity_type::EntityTypeRef;
6use steel_registry::item_stack::ItemStack;
7use steel_registry::vanilla_block_tags::BlockTag;
8use steel_registry::vanilla_game_events;
9
10use crate::behavior::{BLOCK_BEHAVIORS, InteractionResult, ItemBehavior, UseOnContext};
11use crate::entity::entities::objects::vehicles::{CreateMinecartError, create_minecart};
12use crate::world::game_event::GameEventContext;
13
14const RAIL_HEIGHT_OFFSET: f64 = 0.0625;
15const SLOPED_RAIL_HEIGHT_OFFSET: f64 = 0.5;
16
17/// Item behavior for minecart entity.
18#[item_behavior(class = "MinecartItem")]
19pub struct MinecartItem {
20    #[json_arg(vanilla_entities, json = "type")]
21    entity_type: EntityTypeRef,
22}
23
24impl MinecartItem {
25    /// Creates a minecart item behavior.
26    #[must_use]
27    pub const fn new(entity_type: EntityTypeRef) -> Self {
28        Self { entity_type }
29    }
30}
31
32impl ItemBehavior for MinecartItem {
33    fn use_on(&self, context: &mut UseOnContext) -> InteractionResult {
34        let pos = context.hit_result.block_pos;
35        let state = context.world.get_block_state(pos);
36        let block = state.get_block();
37
38        if !block.has_tag(&BlockTag::RAILS) {
39            return InteractionResult::Fail;
40        }
41
42        let shape = if BLOCK_BEHAVIORS.get_behavior(block).as_rail().is_some() {
43            state.get_value(&BlockStateProperties::RAIL_SHAPE)
44        } else {
45            RailShape::NorthSouth
46        };
47
48        let offset = if shape.is_slope() {
49            SLOPED_RAIL_HEIGHT_OFFSET
50        } else {
51            0.0
52        };
53        let position = DVec3::new(
54            f64::from(pos.x()) + 0.5,
55            f64::from(pos.y()) + RAIL_HEIGHT_OFFSET + offset,
56            f64::from(pos.z()) + 0.5,
57        );
58
59        let stack = context.inv.with_item(|stack| stack.clone());
60        let cart = match create_minecart(
61            context.world,
62            position,
63            self.entity_type,
64            &stack,
65            Some(context.player),
66        ) {
67            Ok(cart) => cart,
68            // TODO: Place chest, furnace, TNT, hopper, spawner and command block minecarts once
69            // their entities implement `AbstractMinecart`.
70            Err(CreateMinecartError::Unsupported) => return InteractionResult::Pass,
71            Err(CreateMinecartError::Spawn(error)) => {
72                log::warn!(
73                    "Failed to create minecart {}: {error:?}",
74                    self.entity_type.key
75                );
76                return InteractionResult::Fail;
77            }
78        };
79
80        if let Err(error) = context.world.try_add_entity(cart) {
81            log::warn!("Failed to add minecart {}: {error}", self.entity_type.key);
82            return InteractionResult::Fail;
83        }
84
85        context.world.game_event(
86            &vanilla_game_events::ENTITY_PLACE,
87            pos,
88            &GameEventContext::new(
89                Some(context.player.as_ref()),
90                Some(context.world.get_block_state(pos.below())),
91            ),
92        );
93
94        context.inv.with_item(ItemStack::shrink_one);
95
96        InteractionResult::Success
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use std::sync::Arc;
103
104    use glam::DVec3;
105    use steel_registry::blocks::{
106        BlockRef,
107        block_state_ext::BlockStateExt as _,
108        properties::{BlockStateProperties, RailShape},
109    };
110    use steel_registry::data_components::vanilla_components::CUSTOM_NAME;
111    use steel_registry::item_stack::ItemStack;
112    use steel_registry::stat::vanilla_stat_types;
113    use steel_registry::{vanilla_blocks, vanilla_entities, vanilla_items};
114    use steel_utils::types::{GameType, InteractionHand, UpdateFlags};
115    use steel_utils::{BlockPos, ChunkPos, Direction, WorldAabb};
116    use text_components::TextComponent;
117
118    use crate::behavior::{BlockHitResult, InteractionResult};
119    use crate::bootstrap::init_globals;
120    use crate::entity::{Entity, SharedEntity, next_entity_id};
121    use crate::player::{Player, game_mode::use_item_on};
122    use crate::test_support::{
123        TestPlayerBuilder, TestWorld, fresh_test_world, insert_ready_full_chunk,
124    };
125
126    struct Fixture {
127        world_fixture: TestWorld,
128        player: Arc<Player>,
129        pos: BlockPos,
130    }
131
132    fn create_fixture(
133        key: &'static str,
134        pos: BlockPos,
135        block: BlockRef,
136        shape: Option<RailShape>,
137        game_mode: GameType,
138    ) -> Fixture {
139        init_globals();
140
141        let world_fixture = fresh_test_world(key);
142        let world = &world_fixture.world;
143        insert_ready_full_chunk(world, ChunkPos::from_block_pos(pos));
144
145        let flags = UpdateFlags::UPDATE_NONE | UpdateFlags::UPDATE_SKIP_ON_PLACE;
146
147        for support in [
148            pos.below(),
149            pos.north(),
150            pos.south(),
151            pos.east(),
152            pos.west(),
153        ] {
154            assert!(world.set_block(support, vanilla_blocks::STONE.default_state(), flags,));
155        }
156
157        let state = match shape {
158            Some(shape) => block
159                .default_state()
160                .set_value(&BlockStateProperties::RAIL_SHAPE, shape),
161            None => block.default_state(),
162        };
163        assert!(world.set_block(pos, state, flags));
164        assert_eq!(world.get_block_state(pos), state);
165
166        let player =
167            TestPlayerBuilder::new(Arc::clone(world), "CartTester", next_entity_id()).build();
168
169        player
170            .try_set_position(DVec3::new(
171                f64::from(pos.x()) + 0.5,
172                f64::from(pos.y()) + 1.0,
173                f64::from(pos.z()) + 2.5,
174            ))
175            .expect("test player should move into the loaded chunk");
176
177        player.restore_game_modes(game_mode, None);
178        player.abilities.lock().update_for_game_mode(game_mode);
179
180        Fixture {
181            world_fixture,
182            player,
183            pos,
184        }
185    }
186
187    fn other_hand(hand: InteractionHand) -> InteractionHand {
188        match hand {
189            InteractionHand::MainHand => InteractionHand::OffHand,
190            InteractionHand::OffHand => InteractionHand::MainHand,
191        }
192    }
193
194    fn prepare_hand(player: &Player, hand: InteractionHand, stack: ItemStack) {
195        let mut inventory = player.inventory.lock();
196        inventory.set_item_in_hand(hand, stack);
197        inventory.set_item_in_hand(other_hand(hand), ItemStack::new(&vanilla_items::STONE));
198    }
199
200    fn held_stack(player: &Player, hand: InteractionHand) -> ItemStack {
201        player.inventory.lock().get_item_in_hand(hand).clone()
202    }
203
204    fn use_cart(fixture: &Fixture, hand: InteractionHand) -> InteractionResult {
205        let hit = BlockHitResult {
206            location: DVec3::new(
207                f64::from(fixture.pos.x()) + 0.5,
208                f64::from(fixture.pos.y()) + 1.0,
209                f64::from(fixture.pos.z()) + 0.5,
210            ),
211            direction: Direction::Up,
212            block_pos: fixture.pos,
213            miss: false,
214            inside: false,
215            world_border_hit: false,
216        };
217
218        use_item_on(&fixture.player, &fixture.world_fixture.world, hand, &hit)
219    }
220
221    fn minecart_use_count(player: &Player) -> i32 {
222        let target = vanilla_stat_types::ITEM_USED.get(&vanilla_items::MINECART);
223
224        player
225            .stats()
226            .into_iter()
227            .find_map(|(stat, count)| (stat == target).then_some(count))
228            .unwrap_or(0)
229    }
230
231    fn entities_near(
232        fixture: &Fixture,
233        matches: impl Fn(&dyn Entity) -> bool,
234    ) -> Vec<SharedEntity> {
235        let pos = fixture.pos;
236        let bounds = WorldAabb::new(
237            f64::from(pos.x()) - 2.0,
238            f64::from(pos.y()) - 1.0,
239            f64::from(pos.z()) - 2.0,
240            f64::from(pos.x()) + 3.0,
241            f64::from(pos.y()) + 3.0,
242            f64::from(pos.z()) + 3.0,
243        );
244
245        fixture
246            .world_fixture
247            .world
248            .get_entities_in_aabb_matching(&bounds, matches)
249    }
250
251    fn carts_at(fixture: &Fixture) -> Vec<SharedEntity> {
252        entities_near(fixture, |entity| {
253            entity.entity_type() == &vanilla_entities::MINECART
254        })
255    }
256
257    fn single_cart(fixture: &Fixture) -> SharedEntity {
258        let mut carts = carts_at(fixture);
259        assert_eq!(carts.len(), 1, "placement should create exactly one cart");
260        carts.pop().expect("the single cart should be present")
261    }
262
263    #[test]
264    fn non_rails_fail_without_consumption_spawning_or_statistics() {
265        init_globals();
266
267        for hand in [InteractionHand::MainHand, InteractionHand::OffHand] {
268            let fixture = create_fixture(
269                "minecart_item_nonrail",
270                BlockPos::new(8, 64, 8),
271                &vanilla_blocks::STONE,
272                None,
273                GameType::Survival,
274            );
275
276            let mut stack = ItemStack::new(&vanilla_items::MINECART);
277            stack.set(CUSTOM_NAME, TextComponent::plain("Unused Cart"));
278            let original = stack.clone();
279            prepare_hand(&fixture.player, hand, stack);
280
281            assert_eq!(use_cart(&fixture, hand), InteractionResult::Fail);
282            assert_eq!(held_stack(&fixture.player, hand), original);
283            assert_eq!(
284                held_stack(&fixture.player, other_hand(hand)),
285                ItemStack::new(&vanilla_items::STONE),
286            );
287            assert!(carts_at(&fixture).is_empty());
288            assert_eq!(minecart_use_count(&fixture.player), 0);
289        }
290    }
291
292    #[test]
293    fn unimplemented_minecart_variants_pass_without_consumption_or_spawning() {
294        init_globals();
295
296        for item in [
297            &vanilla_items::CHEST_MINECART,
298            &vanilla_items::FURNACE_MINECART,
299            &vanilla_items::TNT_MINECART,
300            &vanilla_items::HOPPER_MINECART,
301            &vanilla_items::COMMAND_BLOCK_MINECART,
302        ] {
303            let fixture = create_fixture(
304                "minecart_item_unimplemented_variant",
305                BlockPos::new(8, 64, 8),
306                &vanilla_blocks::RAIL,
307                Some(RailShape::NorthSouth),
308                GameType::Survival,
309            );
310            let stack = ItemStack::new(item);
311            prepare_hand(&fixture.player, InteractionHand::MainHand, stack.clone());
312
313            assert_eq!(
314                use_cart(&fixture, InteractionHand::MainHand),
315                InteractionResult::Pass,
316                "{}",
317                item.key
318            );
319            assert_eq!(
320                held_stack(&fixture.player, InteractionHand::MainHand),
321                stack
322            );
323            let player_id = fixture.player.id();
324            assert!(
325                entities_near(&fixture, |entity| entity.id() != player_id).is_empty(),
326                "{} should not spawn an entity",
327                item.key
328            );
329        }
330    }
331
332    #[test]
333    fn placement_uses_the_clicked_rail_height_position_and_hand() {
334        init_globals();
335
336        let cases: &[(BlockRef, RailShape, f64)] = &[
337            (&vanilla_blocks::RAIL, RailShape::NorthSouth, 0.0625),
338            (&vanilla_blocks::RAIL, RailShape::EastWest, 0.0625),
339            (&vanilla_blocks::RAIL, RailShape::AscendingEast, 0.5625),
340            (&vanilla_blocks::RAIL, RailShape::AscendingWest, 0.5625),
341            (&vanilla_blocks::RAIL, RailShape::AscendingNorth, 0.5625),
342            (&vanilla_blocks::RAIL, RailShape::AscendingSouth, 0.5625),
343            (&vanilla_blocks::POWERED_RAIL, RailShape::EastWest, 0.0625),
344            (
345                &vanilla_blocks::POWERED_RAIL,
346                RailShape::AscendingWest,
347                0.5625,
348            ),
349            (&vanilla_blocks::DETECTOR_RAIL, RailShape::EastWest, 0.0625),
350            (
351                &vanilla_blocks::DETECTOR_RAIL,
352                RailShape::AscendingWest,
353                0.5625,
354            ),
355            (&vanilla_blocks::ACTIVATOR_RAIL, RailShape::EastWest, 0.0625),
356            (
357                &vanilla_blocks::ACTIVATOR_RAIL,
358                RailShape::AscendingWest,
359                0.5625,
360            ),
361        ];
362
363        for pos in [BlockPos::new(8, 64, 8), BlockPos::new(-8, 64, -8)] {
364            for &(block, shape, height_offset) in cases {
365                for hand in [InteractionHand::MainHand, InteractionHand::OffHand] {
366                    let fixture = create_fixture(
367                        "minecart_item_placement",
368                        pos,
369                        block,
370                        Some(shape),
371                        GameType::Survival,
372                    );
373                    prepare_hand(
374                        &fixture.player,
375                        hand,
376                        ItemStack::new(&vanilla_items::MINECART),
377                    );
378
379                    assert_eq!(use_cart(&fixture, hand), InteractionResult::Success);
380
381                    let cart = single_cart(&fixture);
382                    assert_eq!(
383                        cart.position(),
384                        DVec3::new(
385                            f64::from(pos.x()) + 0.5,
386                            f64::from(pos.y()) + height_offset,
387                            f64::from(pos.z()) + 0.5,
388                        ),
389                    );
390                    assert!(held_stack(&fixture.player, hand).is_empty());
391                    assert_eq!(
392                        held_stack(&fixture.player, other_hand(hand)),
393                        ItemStack::new(&vanilla_items::STONE),
394                    );
395                    assert_eq!(minecart_use_count(&fixture.player), 1);
396                }
397            }
398        }
399    }
400
401    #[test]
402    fn placing_the_last_item_transfers_its_custom_name_before_consumption() {
403        init_globals();
404
405        let fixture = create_fixture(
406            "minecart_item_custom_name",
407            BlockPos::new(8, 64, 8),
408            &vanilla_blocks::RAIL,
409            Some(RailShape::NorthSouth),
410            GameType::Survival,
411        );
412        let name = TextComponent::plain("Rail Runner");
413        let mut stack = ItemStack::new(&vanilla_items::MINECART);
414        stack.set(CUSTOM_NAME, name.clone());
415        prepare_hand(&fixture.player, InteractionHand::MainHand, stack);
416
417        assert_eq!(
418            use_cart(&fixture, InteractionHand::MainHand),
419            InteractionResult::Success,
420        );
421
422        let cart = single_cart(&fixture);
423        assert_eq!(cart.custom_name(), Some(name));
424        assert!(held_stack(&fixture.player, InteractionHand::MainHand).is_empty());
425        assert_eq!(minecart_use_count(&fixture.player), 1);
426    }
427}