Skip to main content

steel_core/block_entity/entities/
piston_moving.rs

1//! Vanilla moving-piston block entity.
2
3use std::cell::Cell;
4use std::sync::{Arc, Weak};
5
6use glam::DVec3;
7use simdnbt::borrow::{BaseNbtCompound as BorrowedNbtCompound, NbtCompound as NbtCompoundView};
8use simdnbt::owned::NbtCompound;
9use steel_registry::blocks::behavior::PushReaction;
10use steel_registry::blocks::block_state_ext::BlockStateExt as _;
11use steel_registry::blocks::properties::{BlockStateProperties, Direction, PistonType};
12use steel_registry::{vanilla_block_entity_types, vanilla_blocks};
13use steel_utils::axis::Axis;
14use steel_utils::nbt::NbtNumeric as _;
15use steel_utils::nbt::NbtValueInput as _;
16use steel_utils::types::UpdateFlags;
17use steel_utils::{
18    BlockLocalAabb, BlockPos, BlockStateId, DowncastType, DowncastTypeKey, WorldAabb,
19    locks::SyncMutex,
20};
21
22use crate::behavior::{BLOCK_BEHAVIORS, BlockCollisionBoxes, BlockCollisionContext};
23use crate::block_entity::block_state_nbt;
24use crate::block_entity::{BlockEntity, BlockEntityBase, BlockEntityLifecycleExt as _};
25use crate::entity::{Entity, SharedEntity};
26use crate::physics::MoverType;
27use crate::world::{LevelReader, World};
28
29const PUSH_OFFSET: f64 = 0.01;
30
31thread_local! {
32    static NOCLIP: Cell<Option<Direction>> = const { Cell::new(None) };
33}
34
35struct NoClipGuard;
36
37impl NoClipGuard {
38    fn set(direction: Direction) -> Self {
39        NOCLIP.set(Some(direction));
40        Self
41    }
42}
43
44impl Drop for NoClipGuard {
45    fn drop(&mut self) {
46        NOCLIP.set(None);
47    }
48}
49
50/// Block entity attached to a block while a piston pushes or pulls it,
51/// tracking the in-progress movement animation.
52pub struct PistonMovingBlockEntity {
53    base: BlockEntityBase,
54    moving: SyncMutex<PistonMovingState>,
55}
56
57#[derive(Clone, Copy)]
58struct PistonMovingState {
59    moved_state: BlockStateId,
60    direction: Direction,
61    extending: bool,
62    source_piston: bool,
63    progress: f32,
64    progress_o: f32,
65    last_ticked: i64,
66}
67
68// SAFETY: This key is owned by Steel and uniquely identifies `PistonMovingBlockEntity`.
69unsafe impl DowncastType for PistonMovingBlockEntity {
70    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:block_entity/piston_moving");
71}
72
73impl PistonMovingBlockEntity {
74    /// Creates the default instance used while loading a piston block entity.
75    #[must_use]
76    pub fn new(world: Weak<World>, pos: BlockPos, state: BlockStateId) -> Self {
77        Self::new_moving(
78            world,
79            pos,
80            state,
81            vanilla_blocks::AIR.default_state(),
82            Direction::Down,
83            false,
84            false,
85        )
86    }
87
88    /// Creates a moving block or source-piston entity.
89    #[must_use]
90    pub fn new_moving(
91        world: Weak<World>,
92        pos: BlockPos,
93        state: BlockStateId,
94        moved_state: BlockStateId,
95        direction: Direction,
96        extending: bool,
97        source_piston: bool,
98    ) -> Self {
99        Self {
100            base: BlockEntityBase::new(&vanilla_block_entity_types::PISTON, world, pos, state),
101            moving: SyncMutex::new(PistonMovingState {
102                moved_state,
103                direction,
104                extending,
105                source_piston,
106                progress: 0.0,
107                progress_o: 0.0,
108                last_ticked: 0,
109            }),
110        }
111    }
112
113    /// Returns whether this block is extending.
114    #[must_use]
115    pub fn is_extending(&self) -> bool {
116        self.moving.lock().extending
117    }
118
119    /// Returns the piston facing direction.
120    #[must_use]
121    pub fn direction(&self) -> Direction {
122        self.moving.lock().direction
123    }
124
125    /// Returns whether this entity represents the source piston or its head.
126    #[must_use]
127    pub fn is_source_piston(&self) -> bool {
128        self.moving.lock().source_piston
129    }
130
131    /// Returns the state being moved.
132    #[must_use]
133    pub fn moved_state(&self) -> BlockStateId {
134        self.moving.lock().moved_state
135    }
136
137    /// Returns the last game time at which this entity ticked.
138    #[must_use]
139    pub fn last_ticked(&self) -> i64 {
140        self.moving.lock().last_ticked
141    }
142
143    /// Returns interpolated movement progress.
144    #[must_use]
145    pub fn progress(&self, partial_tick: f32) -> f32 {
146        let partial_tick = partial_tick.min(1.0);
147        let moving = self.moving.lock();
148        (moving.progress - moving.progress_o).mul_add(partial_tick, moving.progress_o)
149    }
150
151    /// Returns the movement direction, which reverses while retracting.
152    #[must_use]
153    pub fn movement_direction(&self) -> Direction {
154        self.moving.lock().movement_direction()
155    }
156
157    /// Returns the direction used for the final neighbor notification.
158    #[must_use]
159    pub fn push_direction(&self) -> Direction {
160        self.moving.lock().movement_direction()
161    }
162
163    /// Resolves the transient collision boxes at the current progress.
164    #[must_use]
165    pub fn collision_boxes(&self, world: &dyn LevelReader, pos: BlockPos) -> BlockCollisionBoxes {
166        let moving = *self.moving.lock();
167        moving.collision_boxes(world, pos)
168    }
169
170    /// Completes an in-flight moving block before its piston starts retracting.
171    pub fn final_tick(&self, world: &Arc<World>) -> bool {
172        let moving = {
173            let mut moving = self.moving.lock();
174            if moving.progress_o >= 1.0 {
175                return false;
176            }
177            moving.progress = 1.0;
178            moving.progress_o = 1.0;
179            *moving
180        };
181        let pos = self.get_block_pos();
182        let owned_position = world.remove_block_entity_if_same(self);
183        self.set_removed();
184        if owned_position {
185            moving.finish_movement_early(world, pos);
186        }
187        true
188    }
189}
190
191impl PistonMovingState {
192    // Vanilla's two-argument `EntityGetter.getEntities` applies
193    // `EntitySelector.NO_SPECTATORS` before the piston loop.
194    fn can_move_collided_entity(entity: &dyn Entity, cause_bounce: bool) -> bool {
195        !entity.is_spectator()
196            && entity.piston_push_reaction() != PushReaction::IgnoreEntity
197            && (!cause_bounce || entity.as_player().is_none())
198    }
199
200    const fn movement_direction(self) -> Direction {
201        if self.extending {
202            self.direction
203        } else {
204            self.direction.opposite()
205        }
206    }
207
208    fn extended_progress(self, progress: f32) -> f32 {
209        if self.extending {
210            progress - 1.0
211        } else {
212            1.0 - progress
213        }
214    }
215
216    fn collision_related_state(self) -> BlockStateId {
217        let behavior = BLOCK_BEHAVIORS.get_behavior(self.moved_state.get_block());
218        if !self.extending && self.source_piston && behavior.is_piston_base() {
219            vanilla_blocks::PISTON_HEAD
220                .default_state()
221                .set_value(&BlockStateProperties::SHORT, self.progress > 0.25)
222                .set_value(
223                    &BlockStateProperties::PISTON_TYPE,
224                    if self.moved_state.get_block() == &vanilla_blocks::STICKY_PISTON {
225                        PistonType::Sticky
226                    } else {
227                        PistonType::Normal
228                    },
229                )
230                .set_value(
231                    &BlockStateProperties::FACING,
232                    self.moved_state.get_value(&BlockStateProperties::FACING),
233                )
234        } else {
235            self.moved_state
236        }
237    }
238
239    fn state_collision_boxes(
240        state: BlockStateId,
241        world: &dyn LevelReader,
242        pos: BlockPos,
243    ) -> BlockCollisionBoxes {
244        BLOCK_BEHAVIORS
245            .get_behavior(state.get_block())
246            .get_collision_boxes(state, world, pos, BlockCollisionContext::empty())
247    }
248
249    fn boxes_bounds(boxes: &BlockCollisionBoxes) -> Option<BlockLocalAabb> {
250        let mut boxes = boxes.iter().filter(|aabb| !aabb.is_empty());
251        let mut bounds = *boxes.next()?;
252        for aabb in boxes {
253            bounds = BlockLocalAabb::encapsulating(&bounds, aabb);
254        }
255        Some(bounds)
256    }
257
258    /// Resolves the transient collision boxes at the current progress.
259    #[must_use]
260    pub fn collision_boxes(&self, world: &dyn LevelReader, pos: BlockPos) -> BlockCollisionBoxes {
261        let mut result = BlockCollisionBoxes::new();
262        let moved_behavior = BLOCK_BEHAVIORS.get_behavior(self.moved_state.get_block());
263        if !self.extending && self.source_piston && moved_behavior.is_piston_base() {
264            let extended = self
265                .moved_state
266                .set_value(&BlockStateProperties::EXTENDED, true);
267            result.extend(Self::state_collision_boxes(extended, world, pos));
268        }
269
270        let no_clip_direction = NOCLIP.get();
271        if self.progress < 1.0 && no_clip_direction == Some(self.movement_direction()) {
272            return result;
273        }
274
275        let moving_state = if self.source_piston {
276            vanilla_blocks::PISTON_HEAD
277                .default_state()
278                .set_value(&BlockStateProperties::FACING, self.direction)
279                .set_value(
280                    &BlockStateProperties::SHORT,
281                    self.extending != ((1.0 - self.progress) < 0.25),
282                )
283        } else {
284            self.moved_state
285        };
286        let amount = f64::from(self.extended_progress(self.progress));
287        let (x, y, z) = self.direction.offset();
288        let offset = DVec3::new(
289            f64::from(x) * amount,
290            f64::from(y) * amount,
291            f64::from(z) * amount,
292        );
293        result.extend(
294            Self::state_collision_boxes(moving_state, world, pos)
295                .into_iter()
296                .map(|aabb| aabb.translate(offset)),
297        );
298        result
299    }
300
301    fn move_by_position_and_progress(&self, pos: BlockPos, aabb: BlockLocalAabb) -> WorldAabb {
302        let amount = f64::from(self.extended_progress(self.progress));
303        let (x, y, z) = self.direction.offset();
304        aabb.at_block(pos).translate(DVec3::new(
305            f64::from(x) * amount,
306            f64::from(y) * amount,
307            f64::from(z) * amount,
308        ))
309    }
310
311    fn movement_area(aabb: WorldAabb, direction: Direction, amount: f64) -> WorldAabb {
312        let signed_amount = if matches!(
313            direction,
314            Direction::West | Direction::Down | Direction::North
315        ) {
316            -amount
317        } else {
318            amount
319        };
320        let min = signed_amount.min(0.0);
321        let max = signed_amount.max(0.0);
322        match direction {
323            Direction::West => WorldAabb::new(
324                aabb.min_x() + min,
325                aabb.min_y(),
326                aabb.min_z(),
327                aabb.min_x() + max,
328                aabb.max_y(),
329                aabb.max_z(),
330            ),
331            Direction::East => WorldAabb::new(
332                aabb.max_x() + min,
333                aabb.min_y(),
334                aabb.min_z(),
335                aabb.max_x() + max,
336                aabb.max_y(),
337                aabb.max_z(),
338            ),
339            Direction::Down => WorldAabb::new(
340                aabb.min_x(),
341                aabb.min_y() + min,
342                aabb.min_z(),
343                aabb.max_x(),
344                aabb.min_y() + max,
345                aabb.max_z(),
346            ),
347            Direction::Up => WorldAabb::new(
348                aabb.min_x(),
349                aabb.max_y() + min,
350                aabb.min_z(),
351                aabb.max_x(),
352                aabb.max_y() + max,
353                aabb.max_z(),
354            ),
355            Direction::North => WorldAabb::new(
356                aabb.min_x(),
357                aabb.min_y(),
358                aabb.min_z() + min,
359                aabb.max_x(),
360                aabb.max_y(),
361                aabb.min_z() + max,
362            ),
363            Direction::South => WorldAabb::new(
364                aabb.min_x(),
365                aabb.min_y(),
366                aabb.max_z() + min,
367                aabb.max_x(),
368                aabb.max_y(),
369                aabb.max_z() + max,
370            ),
371        }
372    }
373
374    fn overlap_movement(outside: WorldAabb, movement: Direction, entity: WorldAabb) -> f64 {
375        match movement {
376            Direction::East => outside.max_x() - entity.min_x(),
377            Direction::West => entity.max_x() - outside.min_x(),
378            Direction::Up => outside.max_y() - entity.min_y(),
379            Direction::Down => entity.max_y() - outside.min_y(),
380            Direction::South => outside.max_z() - entity.min_z(),
381            Direction::North => entity.max_z() - outside.min_z(),
382        }
383    }
384
385    fn move_entity_by_piston(
386        piston_direction: Direction,
387        entity: &SharedEntity,
388        delta: f64,
389        movement: Direction,
390    ) {
391        let _no_clip = NoClipGuard::set(piston_direction);
392        let (x, y, z) = movement.offset();
393        let previous_position = entity.position();
394        Arc::clone(entity).move_entity(
395            MoverType::Piston,
396            DVec3::new(
397                delta * f64::from(x),
398                delta * f64::from(y),
399                delta * f64::from(z),
400            ),
401        );
402        entity.apply_effects_from_blocks_between(previous_position, entity.position());
403        entity.remove_latest_movement_recording();
404    }
405
406    fn fix_entity_within_piston_base(
407        pos: BlockPos,
408        entity: &SharedEntity,
409        direction: Direction,
410        delta_progress: f64,
411    ) {
412        let entity_aabb = entity.bounding_box();
413        let box_at_pos = BlockLocalAabb::FULL_BLOCK.at_block(pos);
414        if !entity_aabb.intersects(box_at_pos) {
415            return;
416        }
417
418        let opposite = direction.opposite();
419        let delta = Self::overlap_movement(box_at_pos, opposite, entity_aabb) + PUSH_OFFSET;
420        let intersection = WorldAabb::new(
421            entity_aabb.min_x().max(box_at_pos.min_x()),
422            entity_aabb.min_y().max(box_at_pos.min_y()),
423            entity_aabb.min_z().max(box_at_pos.min_z()),
424            entity_aabb.max_x().min(box_at_pos.max_x()),
425            entity_aabb.max_y().min(box_at_pos.max_y()),
426            entity_aabb.max_z().min(box_at_pos.max_z()),
427        );
428        let intersected_delta =
429            Self::overlap_movement(box_at_pos, opposite, intersection) + PUSH_OFFSET;
430        if (delta - intersected_delta).abs() < PUSH_OFFSET {
431            let delta = delta.min(delta_progress) + PUSH_OFFSET;
432            Self::move_entity_by_piston(direction, entity, delta, opposite);
433        }
434    }
435
436    fn move_collided_entities(self, world: &Arc<World>, pos: BlockPos, new_progress: f32) {
437        let movement = self.movement_direction();
438        let delta_progress = f64::from(new_progress - self.progress);
439        let shape =
440            Self::state_collision_boxes(self.collision_related_state(), world.as_ref(), pos);
441        let Some(bounds) = Self::boxes_bounds(&shape) else {
442            return;
443        };
444        let aabb = self.move_by_position_and_progress(pos, bounds);
445        let query =
446            WorldAabb::encapsulating(&Self::movement_area(aabb, movement, delta_progress), &aabb);
447        let entities = world.get_entities_in_aabb(&query);
448        let cause_bounce = self.moved_state.get_block() == &vanilla_blocks::SLIME_BLOCK;
449
450        for entity in entities {
451            if !Self::can_move_collided_entity(entity.as_ref(), cause_bounce) {
452                continue;
453            }
454            if cause_bounce {
455                let mut velocity = entity.velocity();
456                let (x, y, z) = movement.offset();
457                match movement.axis() {
458                    Axis::X => velocity.x = f64::from(x),
459                    Axis::Y => velocity.y = f64::from(y),
460                    Axis::Z => velocity.z = f64::from(z),
461                }
462                entity.set_velocity(velocity);
463            }
464
465            let mut delta: f64 = 0.0;
466            let entity_aabb = entity.bounding_box();
467            for shape_aabb in &shape {
468                let moving_aabb = Self::movement_area(
469                    self.move_by_position_and_progress(pos, *shape_aabb),
470                    movement,
471                    delta_progress,
472                );
473                if moving_aabb.intersects(entity_aabb) {
474                    delta = delta.max(Self::overlap_movement(moving_aabb, movement, entity_aabb));
475                    if delta >= delta_progress {
476                        break;
477                    }
478                }
479            }
480
481            if delta <= 0.0 {
482                continue;
483            }
484            let delta = delta.min(delta_progress) + PUSH_OFFSET;
485            Self::move_entity_by_piston(movement, &entity, delta, movement);
486            if !self.extending && self.source_piston {
487                Self::fix_entity_within_piston_base(pos, &entity, movement, delta_progress);
488            }
489        }
490    }
491
492    fn move_stuck_entities(self, world: &Arc<World>, pos: BlockPos, new_progress: f32) {
493        if self.moved_state.get_block() != &vanilla_blocks::HONEY_BLOCK {
494            return;
495        }
496        let movement = self.movement_direction();
497        if !movement.is_horizontal() {
498            return;
499        }
500
501        let collision = Self::state_collision_boxes(self.moved_state, world.as_ref(), pos);
502        let sticky_top = collision
503            .iter()
504            .map(BlockLocalAabb::max_y)
505            .fold(f64::NEG_INFINITY, f64::max);
506        let local = BlockLocalAabb::new(0.0, sticky_top, 0.0, 1.0, 1.500_001, 1.0);
507        let aabb = self.move_by_position_and_progress(pos, local);
508        let entities = world.get_entities_in_aabb_matching(&aabb, |entity| {
509            let position = entity.position();
510            entity.piston_push_reaction() == PushReaction::PushPull
511                && entity.on_ground()
512                && (entity.is_supported_by(pos)
513                    || (position.x >= aabb.min_x()
514                        && position.x <= aabb.max_x()
515                        && position.z >= aabb.min_z()
516                        && position.z <= aabb.max_z()))
517        });
518        let delta_progress = f64::from(new_progress - self.progress);
519        for entity in entities {
520            Self::move_entity_by_piston(movement, &entity, delta_progress, movement);
521        }
522    }
523
524    fn finish_tick(self, world: &Arc<World>, pos: BlockPos) {
525        if world.get_block_state(pos).get_block() != &vanilla_blocks::MOVING_PISTON {
526            return;
527        }
528
529        let mut new_state = world.update_from_neighbor_shapes(self.moved_state, pos);
530        if new_state.get_block() == &vanilla_blocks::AIR {
531            world.set_block(
532                pos,
533                self.moved_state,
534                UpdateFlags::UPDATE_INVISIBLE
535                    | UpdateFlags::UPDATE_KNOWN_SHAPE
536                    | UpdateFlags::UPDATE_MOVE_BY_PISTON
537                    | UpdateFlags::UPDATE_SKIP_BLOCK_ENTITY_SIDEEFFECTS,
538            );
539            world.update_or_destroy(
540                self.moved_state,
541                new_state,
542                pos,
543                UpdateFlags::UPDATE_ALL,
544                World::UPDATE_LIMIT,
545            );
546            return;
547        }
548
549        if new_state.try_get_value(&BlockStateProperties::WATERLOGGED) == Some(true) {
550            new_state = new_state.set_value(&BlockStateProperties::WATERLOGGED, false);
551        }
552        world.set_block(
553            pos,
554            new_state,
555            UpdateFlags::UPDATE_ALL | UpdateFlags::UPDATE_MOVE_BY_PISTON,
556        );
557        world.neighbor_changed(pos, new_state.get_block());
558    }
559
560    fn finish_movement_early(self, world: &Arc<World>, pos: BlockPos) {
561        if world.get_block_state(pos).get_block() == &vanilla_blocks::MOVING_PISTON {
562            let new_state = if self.source_piston {
563                vanilla_blocks::AIR.default_state()
564            } else {
565                world.update_from_neighbor_shapes(self.moved_state, pos)
566            };
567            world.set_block(pos, new_state, UpdateFlags::UPDATE_ALL);
568            world.neighbor_changed(pos, new_state.get_block());
569        }
570    }
571
572    const fn direction_from_legacy_id(id: i8) -> Direction {
573        let remainder = (id as i32) % 6;
574        let normalized = if remainder < 0 { -remainder } else { remainder };
575        match normalized {
576            1 => Direction::Up,
577            2 => Direction::North,
578            3 => Direction::South,
579            4 => Direction::West,
580            5 => Direction::East,
581            _ => Direction::Down,
582        }
583    }
584
585    const fn direction_legacy_id(direction: Direction) -> i8 {
586        match direction {
587            Direction::Down => 0,
588            Direction::Up => 1,
589            Direction::North => 2,
590            Direction::South => 3,
591            Direction::West => 4,
592            Direction::East => 5,
593        }
594    }
595}
596
597impl BlockEntity for PistonMovingBlockEntity {
598    fn base(&self) -> &BlockEntityBase {
599        &self.base
600    }
601
602    fn pre_remove_side_effects(&self, _pos: BlockPos, _state: BlockStateId) {
603        if let Some(world) = self.get_level() {
604            self.final_tick(&world);
605        }
606    }
607
608    fn load_additional(&self, nbt: &BorrowedNbtCompound<'_>) {
609        let view = NbtCompoundView::from(nbt);
610        let mut moving = self.moving.lock();
611        moving.moved_state = view
612            .compound("blockState")
613            .and_then(block_state_nbt::load)
614            .unwrap_or_else(|| vanilla_blocks::AIR.default_state());
615        moving.direction = PistonMovingState::direction_from_legacy_id(
616            view.get("facing")
617                .and_then(|tag| tag.codec_i8())
618                .unwrap_or(0),
619        );
620        moving.progress = view.float_value("progress").unwrap_or(0.0);
621        moving.progress_o = moving.progress;
622        moving.extending = view.byte_value("extending").is_some_and(|value| value != 0);
623        moving.source_piston = view.byte_value("source").is_some_and(|value| value != 0);
624    }
625
626    fn save_additional(&self, nbt: &mut NbtCompound) {
627        let moving = self.moving.lock();
628        nbt.insert("blockState", block_state_nbt::save(moving.moved_state));
629        nbt.insert(
630            "facing",
631            PistonMovingState::direction_legacy_id(moving.direction),
632        );
633        nbt.insert("progress", moving.progress_o);
634        nbt.insert("extending", i8::from(moving.extending));
635        nbt.insert("source", i8::from(moving.source_piston));
636    }
637
638    fn get_update_tag(&self) -> Option<NbtCompound> {
639        Some(self.save_custom_only())
640    }
641
642    fn tick(&self, world: &Arc<World>) {
643        let game_time = world.game_time();
644        let (moving, new_progress) = {
645            let mut moving = self.moving.lock();
646            moving.last_ticked = game_time;
647            moving.progress_o = moving.progress;
648            let new_progress = (moving.progress_o < 1.0).then(|| moving.progress + 0.5);
649            (*moving, new_progress)
650        };
651        let pos = self.get_block_pos();
652        let Some(new_progress) = new_progress else {
653            let owned_position = world.remove_block_entity_if_same(self);
654            self.set_removed();
655            if owned_position {
656                moving.finish_tick(world, pos);
657            }
658            return;
659        };
660
661        moving.move_collided_entities(world, pos, new_progress);
662        moving.move_stuck_entities(world, pos, new_progress);
663        self.moving.lock().progress = new_progress.min(1.0);
664    }
665}
666
667#[cfg(test)]
668mod tests {
669    use std::io::Cursor;
670
671    use super::*;
672    use crate::behavior::init_behaviors;
673    use crate::block_entity::SharedBlockEntity;
674    use crate::entity::SharedEntity;
675    use crate::player::Player;
676    use crate::test_support::{
677        TestEntity, TestPlayerBuilder, fresh_test_world, insert_ready_full_chunk,
678    };
679    use glam::DVec3;
680    use simdnbt::borrow::read_compound as read_borrowed_compound;
681    use simdnbt::owned::NbtTag;
682    use steel_registry::{init_vanilla_registry, vanilla_entities};
683    use steel_utils::{ChunkPos, types::GameType};
684
685    fn test_player(world: Arc<World>) -> Arc<Player> {
686        TestPlayerBuilder::new(world, "PistonTestPlayer", 1).build()
687    }
688
689    #[test]
690    fn moving_state_and_progress_round_trip_with_vanilla_keys() {
691        init_vanilla_registry();
692        let state = vanilla_blocks::MOVING_PISTON
693            .default_state()
694            .set_value(&BlockStateProperties::FACING, Direction::West)
695            .set_value(&BlockStateProperties::PISTON_TYPE, PistonType::Sticky);
696        let moved = vanilla_blocks::PISTON_HEAD
697            .default_state()
698            .set_value(&BlockStateProperties::FACING, Direction::West)
699            .set_value(&BlockStateProperties::PISTON_TYPE, PistonType::Sticky)
700            .set_value(&BlockStateProperties::SHORT, true);
701        let source = PistonMovingBlockEntity::new_moving(
702            Weak::new(),
703            BlockPos::new(8, 64, -3),
704            state,
705            moved,
706            Direction::West,
707            true,
708            true,
709        );
710        source.moving.lock().progress_o = 0.5;
711
712        let mut nbt = NbtCompound::new();
713        source.save_additional(&mut nbt);
714        assert!(matches!(nbt.get("facing"), Some(NbtTag::Byte(4))));
715        let mut bytes = Vec::new();
716        nbt.write(&mut bytes);
717        let borrowed = read_borrowed_compound(&mut Cursor::new(bytes.as_slice()))
718            .expect("test NBT should reborrow");
719        let view = NbtCompoundView::from(&borrowed);
720        assert_eq!(view.byte("facing"), Some(4));
721        assert_eq!(view.int("facing"), None);
722
723        let loaded = PistonMovingBlockEntity::new(Weak::new(), source.get_block_pos(), state);
724        loaded.load_additional(&borrowed);
725        assert_eq!(loaded.moved_state(), moved);
726        assert_eq!(loaded.direction(), Direction::West);
727        assert!(loaded.is_extending());
728        assert!(loaded.is_source_piston());
729        assert!((loaded.progress(0.0) - 0.5).abs() < f32::EPSILON);
730    }
731
732    #[test]
733    fn collided_entity_filter_matches_vanilla_player_and_spectator_rules() {
734        init_vanilla_registry();
735        init_behaviors();
736        let world_fixture = fresh_test_world("piston_entity_filter");
737        let world = &world_fixture.world;
738        let player = test_player(Arc::clone(world));
739
740        assert!(PistonMovingState::can_move_collided_entity(
741            player.as_ref(),
742            false
743        ));
744        assert!(!PistonMovingState::can_move_collided_entity(
745            player.as_ref(),
746            true
747        ));
748
749        player.restore_game_modes(GameType::Spectator, None);
750        assert!(!PistonMovingState::can_move_collided_entity(
751            player.as_ref(),
752            false
753        ));
754
755        let entity = TestEntity::new(
756            8_000,
757            DVec3::ZERO,
758            Arc::downgrade(world),
759            &vanilla_entities::MINECART,
760        );
761        assert!(PistonMovingState::can_move_collided_entity(&entity, true));
762    }
763
764    #[test]
765    fn movement_area_matches_vanilla_directional_sweep() {
766        let aabb = WorldAabb::new(1.0, 2.0, 3.0, 2.0, 3.0, 4.0);
767        assert_eq!(
768            PistonMovingState::movement_area(aabb, Direction::East, 0.5),
769            WorldAabb::new(2.0, 2.0, 3.0, 2.5, 3.0, 4.0)
770        );
771        assert_eq!(
772            PistonMovingState::movement_area(aabb, Direction::North, 0.5),
773            WorldAabb::new(1.0, 2.0, 2.5, 2.0, 3.0, 3.0)
774        );
775    }
776
777    #[test]
778    fn piston_entity_move_can_reenter_moving_block_collision() {
779        init_vanilla_registry();
780        init_behaviors();
781        let world_fixture = fresh_test_world("piston_collision_reentry");
782        let world = &world_fixture.world;
783        let pos = BlockPos::new(8, 64, 8);
784        insert_ready_full_chunk(world, ChunkPos::from_block_pos(pos));
785        let state = vanilla_blocks::MOVING_PISTON
786            .default_state()
787            .set_value(&BlockStateProperties::FACING, Direction::East);
788        assert!(world.set_block(pos, state, UpdateFlags::UPDATE_NONE));
789
790        let piston = Arc::new(PistonMovingBlockEntity::new_moving(
791            Arc::downgrade(world),
792            pos,
793            state,
794            vanilla_blocks::STONE.default_state(),
795            Direction::East,
796            true,
797            false,
798        ));
799        let block_entity: SharedBlockEntity = Arc::<PistonMovingBlockEntity>::clone(&piston);
800        assert!(world.set_block_entity(block_entity));
801
802        let start = DVec3::new(f64::from(pos.x()) + 0.1, f64::from(pos.y()), 8.5);
803        let entity: SharedEntity = TestEntity::shared(
804            8_001,
805            start,
806            Arc::downgrade(world),
807            &vanilla_entities::MINECART,
808        );
809        world
810            .try_add_entity(Arc::clone(&entity))
811            .expect("test entity should enter the loaded chunk");
812
813        piston.tick(world);
814
815        assert!((piston.progress(1.0) - 0.5).abs() < f32::EPSILON);
816        assert!(entity.position().x > start.x);
817    }
818
819    #[test]
820    fn final_tick_marks_a_detached_moving_entity_removed() {
821        init_vanilla_registry();
822        init_behaviors();
823        let world_fixture = fresh_test_world("detached_piston_final_tick");
824        let world = &world_fixture.world;
825        let pos = BlockPos::new(8, 64, 8);
826        insert_ready_full_chunk(world, ChunkPos::from_block_pos(pos));
827        let state = vanilla_blocks::MOVING_PISTON
828            .default_state()
829            .set_value(&BlockStateProperties::FACING, Direction::East);
830        let piston = PistonMovingBlockEntity::new_moving(
831            Arc::downgrade(world),
832            pos,
833            state,
834            vanilla_blocks::STONE.default_state(),
835            Direction::East,
836            true,
837            false,
838        );
839
840        assert!(piston.final_tick(world));
841        assert!(piston.is_removed());
842    }
843
844    #[test]
845    fn stale_final_tick_cannot_remove_or_finish_a_replacement() {
846        init_vanilla_registry();
847        init_behaviors();
848        let world_fixture = fresh_test_world("stale_piston_final_tick");
849        let world = &world_fixture.world;
850        let pos = BlockPos::new(8, 64, 8);
851        insert_ready_full_chunk(world, ChunkPos::from_block_pos(pos));
852        let state = vanilla_blocks::MOVING_PISTON
853            .default_state()
854            .set_value(&BlockStateProperties::FACING, Direction::East);
855        assert!(world.set_block(pos, state, UpdateFlags::UPDATE_NONE));
856        let stale_piston = Arc::new(PistonMovingBlockEntity::new_moving(
857            Arc::downgrade(world),
858            pos,
859            state,
860            vanilla_blocks::STONE.default_state(),
861            Direction::East,
862            true,
863            false,
864        ));
865        let stale_entity: SharedBlockEntity = Arc::<PistonMovingBlockEntity>::clone(&stale_piston);
866        assert!(world.set_block_entity(stale_entity));
867        let replacement = Arc::new(PistonMovingBlockEntity::new_moving(
868            Arc::downgrade(world),
869            pos,
870            state,
871            vanilla_blocks::GOLD_BLOCK.default_state(),
872            Direction::East,
873            true,
874            false,
875        ));
876        let replacement_entity: SharedBlockEntity =
877            Arc::<PistonMovingBlockEntity>::clone(&replacement);
878        assert!(world.set_block_entity(Arc::clone(&replacement_entity)));
879
880        assert!(stale_piston.final_tick(world));
881
882        let Some(current) = world.get_block_entity(pos) else {
883            panic!("the replacement should remain stored");
884        };
885        assert!(Arc::ptr_eq(&current, &replacement_entity));
886        assert_eq!(world.get_block_state(pos), state);
887        assert!(!replacement.is_removed());
888    }
889}