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(class = "MinecartItem")]
19pub struct MinecartItem {
20 #[json_arg(vanilla_entities, json = "type")]
21 entity_type: EntityTypeRef,
22}
23
24impl MinecartItem {
25 #[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 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}